From 4543ce6a230d5817f0ab500da4b2c89e423499f8 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:10:02 +0200 Subject: [PATCH 01/38] chore: sync API types from infrastructure (#5417) This PR was automatically created to sync API types from the infrastructure repository. Changes were detected in the generated API code after syncing with the latest spec from infrastructure. Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> --- apps/cli-go/pkg/api/client.gen.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go index 5de49ee896..43da53e9ae 100644 --- a/apps/cli-go/pkg/api/client.gen.go +++ b/apps/cli-go/pkg/api/client.gen.go @@ -12011,7 +12011,7 @@ func (r V1RevokeTokenResponse) StatusCode() int { type V1ExchangeOauthTokenResponse struct { Body []byte HTTPResponse *http.Response - JSON201 *OAuthTokenResponse + JSON200 *OAuthTokenResponse } // Status returns HTTPResponse.Status @@ -17534,12 +17534,12 @@ func ParseV1ExchangeOauthTokenResponse(rsp *http.Response) (*V1ExchangeOauthToke } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: var dest OAuthTokenResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON201 = &dest + response.JSON200 = &dest } From a274c022111db86eef6f0b2c584155da7decf4e6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 10:40:44 +0200 Subject: [PATCH 02/38] fix(cli): read Go Windows credentials in legacy TS (#5418) Fixes https://github.com/supabase/cli/issues/5415 Fixes a credential lookup mismatch between the Go CLI and the legacy TypeScript CLI on Windows. The Go keyring implementation writes Windows credentials with an explicit target in the form `Supabase CLI:`, while the legacy TypeScript credentials layer only checked the default `@napi-rs/keyring` entry. As commands are ported from Go to TypeScript, this meant a token written by Go login could be missed by legacy TS command code. This adds a fallback read/delete path for the Go Windows target while preserving the existing default keyring lookup and filesystem fallback behavior. The legacy credentials unit tests now cover reading and deleting credentials stored with the Go Windows target shape. --- .../legacy/auth/legacy-credentials.layer.ts | 38 ++++++++++++++----- .../legacy-credentials.layer.unit.test.ts | 30 +++++++++++++-- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 725d07db12..95c77385e4 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -15,6 +15,7 @@ const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`."; type KeyringModule = typeof import("@napi-rs/keyring"); +type KeyringEntry = InstanceType; const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => Effect.gen(function* () { @@ -32,11 +33,14 @@ const tryKeyringRead = ( ): Effect.Effect> => Effect.try({ try: () => { - const entry = new module.Entry(KEYRING_SERVICE, account); - const value = entry.getPassword(); - return value && value.length > 0 - ? Option.some(normalizeKeyringToken(value)) - : Option.none(); + for (const entry of [ + new module.Entry(KEYRING_SERVICE, account), + module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account), + ]) { + const value = readEntryPassword(entry); + if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); + } + return Option.none(); }, catch: () => Option.none(), }).pipe(Effect.orElseSucceed(() => Option.none())); @@ -58,15 +62,29 @@ const tryKeyringWrite = ( const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect => Effect.try({ try: () => { - const entry = new module.Entry(KEYRING_SERVICE, account); - const value = entry.getPassword(); - if (!value) return false; - entry.deleteCredential(); - return true; + let deleted = false; + for (const entry of [ + new module.Entry(KEYRING_SERVICE, account), + module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account), + ]) { + const value = readEntryPassword(entry); + if (!value) continue; + entry.deleteCredential(); + deleted = true; + } + return deleted; }, catch: () => false, }).pipe(Effect.orElseSucceed(() => false)); +function readEntryPassword(entry: KeyringEntry): string | null { + try { + return entry.getPassword(); + } catch { + return null; + } +} + const makeLegacyCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 2069c9c141..80243780b9 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -26,12 +26,22 @@ vi.mock("@napi-rs/keyring", () => ({ Entry: class Entry { service: string; account: string; - constructor(service: string, account: string) { + target?: string; + constructor(service: string, account: string, target?: string) { this.service = service; this.account = account; + this.target = target; + } + static withTarget(target: string, service: string, account: string) { + return new this(service, account, target); + } + key(): string { + return this.target === undefined + ? `${this.service}/${this.account}` + : `${this.target}/${this.service}/${this.account}`; } getPassword(): string | null { - const key = `${this.service}/${this.account}`; + const key = this.key(); if (throwOnGetPasswordAccounts.has(key)) { throw new Error("Keyring unavailable"); } @@ -39,10 +49,10 @@ vi.mock("@napi-rs/keyring", () => ({ } setPassword(value: string): void { if (throwOnSetPassword) throw new Error("Keyring unavailable"); - passwords.set(`${this.service}/${this.account}`, value); + passwords.set(this.key(), value); } deleteCredential(): boolean { - const key = `${this.service}/${this.account}`; + const key = this.key(); if (!passwords.has(key)) throw new Error("not found"); passwords.delete(key); return true; @@ -90,6 +100,7 @@ const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); const encodeGoKeyringBase64 = (token: string) => `go-keyring-base64:${Buffer.from(token).toString("base64")}`; +const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; const expectSomeToken = (token: Option.Option>, expected: string) => { expect(Option.isSome(token)).toBe(true); @@ -126,6 +137,15 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); + it.effect("reads Windows credentials created by Go keyring", () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + it.effect("falls through to the legacy access-token keyring entry", () => { passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); return Effect.gen(function* () { @@ -224,6 +244,7 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { it.effect("removes both keyring entries plus the filesystem file", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); const supaDir = join(tempHome, ".supabase"); mkdirSync(supaDir, { recursive: true }); writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); @@ -232,6 +253,7 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { expect(yield* deleteAccessToken).toBe(true); expect(passwords.has("Supabase CLI/supabase")).toBe(false); expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); expect(existsSync(join(supaDir, "access-token"))).toBe(false); }).pipe(Effect.provide(makeLayer())); }); From 8680362e5cfd86cc9e8e663373ad108d3f29186e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 11:13:38 +0200 Subject: [PATCH 03/38] fix(ci): pass release channel to PR smoke workflow (#5419) The PR smoke workflow calls `release-shared.yml`, which declares `channel` as a required `workflow_call` input. Because `smoke-test-pr.yml` did not pass that input, GitHub rejected the workflow during startup before creating any jobs. This adds `channel: beta` to the PR smoke workflow call, matching the prerelease semantics used by the release workflow while keeping `dry_run: true` for PR validation. --- .github/workflows/smoke-test-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/smoke-test-pr.yml b/.github/workflows/smoke-test-pr.yml index 0dfd0fb19c..4e936867e2 100644 --- a/.github/workflows/smoke-test-pr.yml +++ b/.github/workflows/smoke-test-pr.yml @@ -51,6 +51,7 @@ jobs: version: 0.0.0-pr-${{ github.event.pull_request.number }} shell: legacy npm_tag: latest + channel: beta prerelease: true dry_run: true # release-shared.yml's publish/homebrew/scoop jobs reference From 39bf0e7daa93c051c814c304618ca7154f94f6da Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 12:08:38 +0200 Subject: [PATCH 04/38] fix(cli): enable vector buckets by default (#5421) ## Summary Enable local storage vector bucket support by default in generated CLI config. This makes fresh and missing `storage.vector.enabled` config paths opt in to vector buckets automatically, while still allowing users to explicitly disable the feature with `enabled = false`. The local start happy path now expects the default vector bucket seed call so the test coverage matches the new behavior. --- .../interactions.json | 32 +++++++++++++++++++ apps/cli-go/internal/start/start_test.go | 4 +++ apps/cli-go/pkg/config/config_test.go | 1 + apps/cli-go/pkg/config/templates/config.toml | 2 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json index d4e5ef05b9..78125524cf 100644 --- a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json @@ -62,5 +62,37 @@ "name": "my-bucket" } } + }, + { + "request": { + "method": "POST", + "path": "/storage/v1/vector/ListVectorBuckets", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "apikey": "__JWT__", + "authorization": "Bearer __ACCESS_TOKEN__", + "content-length": "3", + "content-type": "application/json", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "access-control-allow-origin": "*", + "content-type": "application/json; charset=utf-8", + "sb-gateway-mode": "direct", + "sb-gateway-version": "1", + "sb-project-ref": "__PROJECT_REF__", + "sb-request-id": "__UUID__", + "x-content-type-options": "nosniff" + }, + "body": { + "vectorBuckets": [] + } + } } ] diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index d8d4deb982..ee468f562c 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -241,6 +241,10 @@ func TestDatabaseStart(t *testing.T) { Get("/storage/v1/bucket"). Reply(http.StatusOK). JSON([]storage.BucketResponse{}) + gock.New(utils.Config.Api.ExternalUrl). + Post("/storage/v1/vector/ListVectorBuckets"). + Reply(http.StatusOK). + JSON(storage.ListVectorBucketsResponse{}) // Run test err = run(ctx, fsys, []string{}, pgconn.Config{Host: utils.DbId}, conn.Intercept) // Check error diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index d7bca3948d..2a1a189076 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -37,6 +37,7 @@ func TestConfigParsing(t *testing.T) { err := config.Load("", fs.MapFS{}) // Check error assert.NoError(t, err) + assert.True(t, config.Storage.VectorBuckets.Enabled) }) t.Run("auth external url defaults from api external url", func(t *testing.T) { diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index c172cc4f4e..0c146d117c 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -145,7 +145,7 @@ max_catalogs = 2 # Store vector embeddings in S3 for large and durable datasets [storage.vector] -enabled = false +enabled = true max_buckets = 10 max_indexes = 5 From 83e5b94386948dce1d4837c389f6675854abcf61 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 2 Jun 2026 12:23:19 +0100 Subject: [PATCH 05/38] fix(cli): bind a free port for edge-runtime diff containers (#5424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed The schema diff path (`supabase db pull` and friends) executes one-shot scripts — migra, pg-delta, pgcache — by running `edge-runtime start --main-service=.` inside a container. Both call sites (`RunEdgeRuntimeScript` in `internal/utils/edgeruntime.go` and `diffWithStream` in `internal/db/diff/diff.go`) launched it with `NetworkMode: host` but **without** a `--port` flag. `edge-runtime start` is an HTTP server and always binds a TCP listener. With no explicit port it bound the edge-runtime **default** port, and with host networking that bind landed directly in the host (Docker VM) network namespace. When the port was already taken — a leftover diff container from an interrupted run, the local stack, or anything else on that port — the bind failed and the container exited 1. This change adds a shared `EdgeRuntimeStartCmd` helper that allocates a free host port and passes it as `--port`, used by both call sites, so concurrent or leftover one-shot containers no longer contend for the default port. On the rare port-allocation failure it falls back to the previous portless command. ## Why Reported in #5407: `supabase db pull` on Windows fails at "Diffing schemas..." with `Error: Address already in use (os error 98)`. Host networking on Docker Desktop (Windows/macOS) shares the VM namespace and makes the default-port collision far more likely. `functions serve` was never affected because it already passes an explicit `--port` (`serve.go:190`). ## Reviewer notes - Covers all diff engines that go through `RunEdgeRuntimeScript`: migra, pg-delta (×3), pgcache, apply — plus the streaming `diffWithStream`. - With Docker Desktop host networking the port is probed on the real host while the bind happens in the VM namespace, so a probed-free port isn't strictly guaranteed free in the VM. Moving off the single shared default to a random ephemeral port removes virtually all real-world collisions; a fully bulletproof fix would be moving these containers to bridge networking with port mapping (like `serve.go`), which can follow separately. Fixes #5407 --- apps/cli-go/internal/db/diff/diff.go | 2 +- apps/cli-go/internal/utils/edgeruntime.go | 29 +++++++++++- .../cli-go/internal/utils/edgeruntime_test.go | 47 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 apps/cli-go/internal/utils/edgeruntime_test.go diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 05991423d6..46d465aff2 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -230,7 +230,7 @@ func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations [ } func diffWithStream(ctx context.Context, env []string, script string, stdout io.Writer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := utils.EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index 06a42b464c..d7070f3f9e 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -3,6 +3,8 @@ package utils import ( "bytes" "context" + "fmt" + "net" "strings" "github.com/docker/docker/api/types/container" @@ -11,10 +13,35 @@ import ( "github.com/spf13/viper" ) +// getFreeHostPort asks the OS for an unused TCP port on the host. +func getFreeHostPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, errors.Errorf("failed to allocate free port: %w", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +// EdgeRuntimeStartCmd builds the base command for launching a one-shot Edge +// Runtime script. The runtime's HTTP listener is bound to a free host port so +// concurrent or leftover containers (which share the host network namespace +// because diff containers run with NetworkMode=host) don't collide on the +// edge-runtime default port, which surfaces as "Address already in use (os +// error 98)". See https://github.com/supabase/cli/issues/5407. +func EdgeRuntimeStartCmd() []string { + cmd := []string{"edge-runtime", "start", "--main-service=."} + // Skip the flag on the rare allocation failure to preserve prior behavior. + if port, err := getFreeHostPort(); err == nil { + cmd = append(cmd, fmt.Sprintf("--port=%d", port)) + } + return cmd +} + // RunEdgeRuntimeScript executes a TypeScript program inside the configured Edge // Runtime container and streams stdout/stderr back to the caller. func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, binds []string, errPrefix string, stdout, stderr *bytes.Buffer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/utils/edgeruntime_test.go b/apps/cli-go/internal/utils/edgeruntime_test.go new file mode 100644 index 0000000000..8bed5b0974 --- /dev/null +++ b/apps/cli-go/internal/utils/edgeruntime_test.go @@ -0,0 +1,47 @@ +package utils + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEdgeRuntimeStartCmd(t *testing.T) { + t.Run("binds an explicit free port", func(t *testing.T) { + cmd := EdgeRuntimeStartCmd() + // Base command must always be present. + assert.Equal(t, []string{"edge-runtime", "start", "--main-service=."}, cmd[:3]) + // A --port flag avoids collisions on the edge-runtime default port (#5407). + var portFlag string + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + portFlag = arg + } + } + require.NotEmpty(t, portFlag, "expected a --port flag to be set") + port, err := strconv.Atoi(strings.TrimPrefix(portFlag, "--port=")) + require.NoError(t, err) + assert.Greater(t, port, 0) + assert.LessOrEqual(t, port, 65535) + }) + + t.Run("allocates a distinct port per invocation", func(t *testing.T) { + first := getPortArg(t, EdgeRuntimeStartCmd()) + second := getPortArg(t, EdgeRuntimeStartCmd()) + assert.NotEqual(t, first, second) + }) +} + +func getPortArg(t *testing.T, cmd []string) string { + t.Helper() + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + return arg + } + } + require.FailNow(t, "missing --port flag") + return "" +} From 1dfe7d54fa3115aed76dce5311541d0d310b39d7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 13:27:58 +0200 Subject: [PATCH 06/38] ci(cli): publish pkg.pr.new previews after preview builds (#5420) ## Summary Adds pkg.pr.new preview publishing for internal PRs after the normal Test workflow passes. The preview workflow resolves the current PR head, skips draft/fork/stale runs, builds the legacy release-shaped CLI artifacts, publishes all platform wrapper packages plus apps/cli to pkg.pr.new, verifies the PR-scoped npx command, and updates a single PR comment with the latest preview command. Extracts the shared CLI artifact build into a reusable workflow so production releases, manual release smoke tests, and PR previews all build the same packages/dist artifact shape. Release publishing still runs the full smoke matrix before npm/GitHub/Homebrew/Scoop publication, while manual release smoke is available through a dedicated dry-run workflow. Adds pkg-pr-new as a root dev dependency and keeps the Windows release smoke coverage aligned with the published artifacts by checking the native wrapper, release tarball, and Scoop install paths. ## Reviewer Notes The preview flow intentionally posts its own pkg.pr.new comment because it runs after Test via workflow_run and needs to publish the stable PR-scoped command, not a commit-SHA-only command. --- .github/workflows/build-cli-artifacts.yml | 88 +++++++ .../publish-preview-cli-packages.yml | 214 ++++++++++++++++++ .github/workflows/release-shared.yml | 72 +----- .github/workflows/release-smoke-test.yml | 47 ++++ .github/workflows/smoke-test-pr.yml | 63 ------ apps/cli/tests/smoke-test-windows.ts | 44 +++- package.json | 1 + pnpm-lock.yaml | 9 + 8 files changed, 413 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/build-cli-artifacts.yml create mode 100644 .github/workflows/publish-preview-cli-packages.yml create mode 100644 .github/workflows/release-smoke-test.yml delete mode 100644 .github/workflows/smoke-test-pr.yml diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml new file mode 100644 index 0000000000..2b01361340 --- /dev/null +++ b/.github/workflows/build-cli-artifacts.yml @@ -0,0 +1,88 @@ +name: Build CLI Artifacts + +on: + workflow_call: + inputs: + version: + description: CLI package version to build + required: true + type: string + shell: + description: CLI shell to package as the shipped supabase binary + required: true + type: string + ref: + description: Optional git ref or SHA to check out before building + required: false + type: string + default: "" + secrets: + SENTRY_DSN: + required: false + POSTHOG_API_KEY: + required: false + POSTHOG_ENDPOINT: + required: false + +permissions: + contents: read + +jobs: + build: + name: Build CLI artifacts + runs-on: blacksmith-32vcpu-ubuntu-2404 + env: + BUN_SHELL: ${{ inputs.shell }} + VERSION: ${{ inputs.version }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: apps/cli-go/go.mod + cache: true + cache-dependency-path: apps/cli-go/go.sum + + - name: Pre-download Go modules + working-directory: apps/cli-go + run: go mod download -x + + - name: Install nfpm + run: | + echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list + sudo apt-get update + sudo apt-get install -y nfpm + + - name: Sync versions + run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" + + - name: Build selected shell + run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" + + - name: Verify build artifacts + run: | + for pkg in cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-musl cli-windows-arm64 cli-windows-x64; do + echo "Checking packages/$pkg/bin/..." + ls -la "packages/$pkg/bin/" + done + echo "Checking dist/..." + ls -la dist/ + + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cli-build-${{ inputs.shell }}-${{ inputs.version }} + path: | + packages/cli-*/bin/ + dist/ diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml new file mode 100644 index 0000000000..40b6382a4a --- /dev/null +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -0,0 +1,214 @@ +name: Publish Preview CLI Packages + +on: + workflow_run: + workflows: + - Test + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha || github.run_id }} + cancel-in-progress: true + +jobs: + resolve: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + name: Resolve preview build context + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + outputs: + should_build: ${{ steps.context.outputs.should_build }} + pr_number: ${{ steps.context.outputs.pr_number }} + pr_head_sha: ${{ steps.context.outputs.pr_head_sha }} + preview_version: ${{ steps.context.outputs.preview_version }} + steps: + - name: Resolve PR context + id: context + run: | + set -euo pipefail + + should_build=false + pr_number="$(jq -r '.workflow_run.pull_requests[0].number // ""' "$GITHUB_EVENT_PATH")" + pr_head_sha="$(jq -r '.workflow_run.head_sha // ""' "$GITHUB_EVENT_PATH")" + pr_head_branch="$(jq -r '.workflow_run.head_branch // ""' "$GITHUB_EVENT_PATH")" + + if [[ -z "${pr_head_sha}" ]]; then + echo "Workflow run has no head SHA; skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -z "${pr_number}" && -n "${pr_head_branch}" ]]; then + pr_number="$( + gh pr list \ + --repo "${REPOSITORY}" \ + --head "${pr_head_branch}" \ + --state open \ + --json number,headRefOid \ + --jq 'map(select(.headRefOid == "'"${pr_head_sha}"'")) | .[0].number // ""' + )" + fi + + if [[ -z "${pr_number}" ]]; then + echo "Test run is not associated with an open pull request; skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pr_json="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")" + current_head_sha="$(jq -r '.head.sha' <<< "${pr_json}")" + state="$(jq -r '.state' <<< "${pr_json}")" + draft="$(jq -r '.draft' <<< "${pr_json}")" + head_repo="$(jq -r '.head.repo.full_name' <<< "${pr_json}")" + base_repo="$(jq -r '.base.repo.full_name' <<< "${pr_json}")" + + if [[ "${state}" != "open" ]]; then + echo "PR #${pr_number} is ${state}; skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${draft}" == "true" ]]; then + echo "PR #${pr_number} is draft; skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${head_repo}" != "${base_repo}" ]]; then + echo "PR #${pr_number} comes from fork ${head_repo}; skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${pr_head_sha}" != "${current_head_sha}" ]]; then + echo "Test SHA ${pr_head_sha} is stale; current PR head is ${current_head_sha}. Skipping." + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + preview_version="0.0.0-pr.${pr_number}" + should_build=true + + { + echo "should_build=${should_build}" + echo "pr_number=${pr_number}" + echo "pr_head_sha=${pr_head_sha}" + echo "preview_version=${preview_version}" + } >> "$GITHUB_OUTPUT" + + build: + needs: resolve + if: needs.resolve.outputs.should_build == 'true' + name: Build preview CLI packages + uses: ./.github/workflows/build-cli-artifacts.yml + with: + version: ${{ needs.resolve.outputs.preview_version }} + shell: legacy + ref: ${{ needs.resolve.outputs.pr_head_sha }} + + publish: + needs: [resolve, build] + if: needs.resolve.outputs.should_build == 'true' && needs.build.result == 'success' + name: Publish preview package + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: write + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + PREVIEW_VERSION: ${{ needs.resolve.outputs.preview_version }} + PR_HEAD_SHA: ${{ needs.resolve.outputs.pr_head_sha }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.resolve.outputs.pr_head_sha }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Download preview build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: cli-build-legacy-${{ needs.resolve.outputs.preview_version }} + + - name: Prepare package files + run: | + set -euo pipefail + pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${PREVIEW_VERSION}" + pnpm --dir apps/cli build:shim + find packages -path '*/bin/supabase*' -type f -exec chmod +x {} + + + - name: Publish preview package + run: | + pnpm exec pkg-pr-new publish \ + --pnpm \ + --bin \ + --comment=off \ + --json pkg-pr-new.json \ + --no-template \ + './packages/cli-darwin-arm64' \ + './packages/cli-darwin-x64' \ + './packages/cli-linux-arm64' \ + './packages/cli-linux-arm64-musl' \ + './packages/cli-linux-x64' \ + './packages/cli-linux-x64-musl' \ + './packages/cli-windows-arm64' \ + './packages/cli-windows-x64' \ + './apps/cli' + + - name: Smoke test preview command + run: | + set -euo pipefail + preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + npx --yes "${preview_url}" --version + + - name: Update PR comment + run: | + set -euo pipefail + preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + short_sha="${PR_HEAD_SHA:0:7}" + marker="" + cat > comment.md < comment.json + comment_id="$( + gh api "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --paginate \ + --jq '.[] | select(.body | contains("'"${marker}"'")) | .id' \ + | head -n1 + )" + + if [[ -n "${comment_id}" ]]; then + gh api --method PATCH "repos/${REPOSITORY}/issues/comments/${comment_id}" --input comment.json >/dev/null + else + gh api --method POST "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" --input comment.json >/dev/null + fi diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 4c9fe3b8f7..a428dde730 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -53,75 +53,27 @@ on: required: false jobs: build: - runs-on: blacksmith-32vcpu-ubuntu-2404 - env: - BUN_SHELL: ${{ inputs.shell }} - VERSION: ${{ inputs.version }} + name: Build CLI artifacts + uses: ./.github/workflows/build-cli-artifacts.yml + with: + version: ${{ inputs.version }} + shell: ${{ inputs.shell }} + secrets: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - - - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - cache-dependency-path: apps/cli-go/go.sum - - - name: Pre-download Go modules - working-directory: apps/cli-go - run: go mod download -x - - - name: Install nfpm - run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm - - - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - - - name: Build selected shell - run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" - - - name: Verify build artifacts - run: | - for pkg in cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-musl cli-windows-arm64 cli-windows-x64; do - echo "Checking packages/$pkg/bin/..." - ls -la "packages/$pkg/bin/" - done - echo "Checking dist/..." - ls -la dist/ - - - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - path: | - packages/cli-*/bin/ - dist/ smoke-test: needs: build strategy: fail-fast: false - # macos-15-intel is the slowest smoke leg and the only one not on - # Blacksmith (Blacksmith macOS is ARM-only). Drop it from the matrix - # on prereleases (PR smoke + develop -> beta) so beta wall-clock isn't - # gated by it; stable releases on main still run the full matrix. - # The matrix list is built via fromJSON because GitHub Actions does - # not allow the `matrix` context in a job-level `if:` (matrix - # expansion happens after job conditions are evaluated). matrix: - runner: ${{ fromJSON(inputs.prerelease && '["blacksmith-8vcpu-ubuntu-2404","blacksmith-6vcpu-macos-latest","blacksmith-8vcpu-windows-2025"]' || '["blacksmith-8vcpu-ubuntu-2404","blacksmith-6vcpu-macos-latest","macos-15-intel","blacksmith-8vcpu-windows-2025"]') }} + runner: + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-6vcpu-macos-latest + - macos-15-intel + - blacksmith-8vcpu-windows-2025 + - windows-11-arm runs-on: ${{ matrix.runner }} env: NPM_TAG: ${{ inputs.npm_tag }} diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml new file mode 100644 index 0000000000..038092a77c --- /dev/null +++ b/.github/workflows/release-smoke-test.yml @@ -0,0 +1,47 @@ +name: Release Smoke Test + +on: + workflow_dispatch: + inputs: + version: + description: Version to build and smoke test + required: false + type: string + default: 0.0.0-smoke + shell: + description: CLI shell to package as the shipped supabase binary + required: false + type: choice + options: + - legacy + - next + default: legacy + npm_tag: + description: npm tag to use for local package smoke tests + required: false + type: choice + options: + - latest + - alpha + - beta + default: beta + +permissions: + # release-shared.yml declares privileged publish jobs. They are gated by + # dry_run here, but GitHub validates nested-workflow permissions at startup. + contents: write + id-token: write + +jobs: + smoke: + name: Run release smoke tests + uses: ./.github/workflows/release-shared.yml + with: + version: ${{ inputs.version }} + shell: ${{ inputs.shell }} + npm_tag: ${{ inputs.npm_tag }} + channel: beta + prerelease: true + dry_run: true + secrets: + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/smoke-test-pr.yml b/.github/workflows/smoke-test-pr.yml deleted file mode 100644 index 4e936867e2..0000000000 --- a/.github/workflows/smoke-test-pr.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Smoke Test (PR) - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - branches: - - develop - # Trigger on any change that could affect the build phase or how the - # built artifacts behave at runtime. Anything in this list is something - # `release-shared.yml`'s build/smoke jobs need to re-validate. - paths: - - "apps/cli/scripts/build.ts" - - "apps/cli/scripts/sync-versions.ts" - - "apps/cli/src/**" - - "apps/cli/package.json" - - "apps/cli-go/**" - - "packages/cli-*/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - ".github/actions/setup/**" - -permissions: - # release-shared.yml's `publish` job declares `contents: write` and - # `id-token: write`. Even though that job is gated by `!inputs.dry_run` - # and never runs here, GitHub validates nested-workflow permissions at - # startup and rejects the run if the caller grants less than any - # nested job requests. Granting the superset here is safe because (a) - # `dry_run: true` short-circuits the privileged jobs at runtime and - # (b) for fork PRs GitHub still issues a read-only GITHUB_TOKEN - # regardless of the declared scope. - contents: write - id-token: write - actions: read - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -jobs: - smoke: - if: github.event.pull_request.draft == false - uses: ./.github/workflows/release-shared.yml - with: - # PR-scoped version so concurrent PRs don't collide on the build artifact - # name (release-shared.yml uploads `cli-build-${shell}-${version}`). - version: 0.0.0-pr-${{ github.event.pull_request.number }} - shell: legacy - npm_tag: latest - channel: beta - prerelease: true - dry_run: true - # release-shared.yml's publish/homebrew/scoop jobs reference - # `secrets.APP_ID` and `secrets.GH_APP_PRIVATE_KEY`. They are gated by - # `!inputs.dry_run` and never execute here, but GitHub validates secret - # references at startup, so the called workflow needs the secrets bag - # propagated even when the jobs that use them are skipped. - secrets: - GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/apps/cli/tests/smoke-test-windows.ts b/apps/cli/tests/smoke-test-windows.ts index 1e5c171628..8b4c26d63c 100644 --- a/apps/cli/tests/smoke-test-windows.ts +++ b/apps/cli/tests/smoke-test-windows.ts @@ -1,4 +1,6 @@ import { $ } from "bun"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; @@ -19,6 +21,10 @@ if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { } const root = path.resolve(import.meta.dir, "../../.."); +async function gitBashPath(filePath: string) { + return process.platform === "win32" ? (await $`cygpath -u ${filePath}`.text()).trim() : filePath; +} + interface TestResult { name: string; status: "pass" | "fail"; @@ -32,9 +38,11 @@ console.log(`\n${"=".repeat(60)}`); console.log("Native binary tests"); console.log("=".repeat(60)); +const arch = process.arch === "arm64" ? "arm64" : "x64"; + { - const name = "native-windows-x64"; - const binPath = path.join(root, "packages", "cli-windows-x64", "bin", "supabase.exe"); + const name = `native-windows-${arch}`; + const binPath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); console.log(`[${name}] Running ${binPath} --version...`); try { @@ -51,6 +59,38 @@ console.log("=".repeat(60)); } } +// --- Release tarball --- + +console.log(`\n${"=".repeat(60)}`); +console.log("Release tarball test"); +console.log("=".repeat(60)); + +{ + const archiveArch = arch === "arm64" ? "arm64" : "amd64"; + const name = `windows-${archiveArch}-tarball`; + const archivePath = path.join(root, "dist", `supabase_${version}_windows_${archiveArch}.tar.gz`); + const extractDir = await mkdtemp(path.join(tmpdir(), "supabase-windows-tarball-")); + + console.log(`[${name}] Extracting ${archivePath}...`); + try { + await $`tar -xzf ${await gitBashPath(archivePath)} -C ${await gitBashPath(extractDir)}`; + const binPath = path.join(extractDir, "supabase.exe"); + const output = await $`${binPath} --version`.text(); + const trimmed = output.trim(); + const shellCheck = await verifyExpectedShell(binPath); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + + console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + } catch (e) { + console.error(`[${name}] Error: ${e}`); + results.push({ name, status: "fail" }); + } finally { + await rm(extractDir, { recursive: true, force: true }); + } +} + // --- Scoop --- console.log(`\n${"=".repeat(60)}`); diff --git a/package.json b/package.json index 7845c78455..9f1662441e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@swc-node/register": "catalog:", "@swc/core": "catalog:", "nx": "catalog:", + "pkg-pr-new": "0.0.75", "verdaccio": "^6.7.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef026fe268..0467e6880a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: nx: specifier: 'catalog:' version: 22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) + pkg-pr-new: + specifier: 0.0.75 + version: 0.0.75 verdaccio: specifier: ^6.7.2 version: 6.7.2(typanion@3.14.0) @@ -5253,6 +5256,10 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + pkg-pr-new@0.0.75: + resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} + hasBin: true + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -11177,6 +11184,8 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + pkg-pr-new@0.0.75: {} + postcss@8.4.31: dependencies: nanoid: 3.3.12 From a74261832173e9939ef931ae7353606769d60fd5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 2 Jun 2026 12:34:44 +0100 Subject: [PATCH 07/38] feat(ci): notify Slack on release failures regardless of channel (#5425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed The release pipeline only posted to Slack on **success** and only for the **stable** channel (`notify-slack` in `release.yml`). A failed release on any channel — alpha, beta, or stable — was silent in Slack; the only signal was GitHub's red badge / email. This adds failure reporting: - **`release.yml`** — new `notify-slack-failure` job gated on `failure() && needs.plan.outputs.dry_run != 'true'`. `failure()` evaluates over the `[plan, release]` `needs` chain, so it fires whenever `plan` or `release` (and anything inside the reusable `release-shared.yml`) fails, on **every channel**. Skipped jobs (the fast-forward path, a release that never started) and dry runs stay quiet. The existing success notifier now passes `status: success` explicitly; its behavior is unchanged (still stable-only). - **`slack-notify.yml`** — new `status` input (`success` | `failure`, default `success`). Failures render a ❌ message with channel, commit, and a link to the failed run. When the planning step itself fails its outputs are empty, so version/channel fall back to `unknown` and the run link carries the actionable detail. ## Reviewer notes The heredoc terminators (`EOF`) must stay at the `run:` block's base indentation (10 spaces) so YAML strips them to column 0 for bash — they're intentionally not indented to match the surrounding `if`/`else` nesting. Reindenting them would silently break the JSON payload. --- .github/workflows/release.yml | 20 +++++++++++ .github/workflows/slack-notify.yml | 57 +++++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c7418c22..505118e4eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,6 +223,26 @@ jobs: needs.plan.outputs.channel == 'stable' uses: ./.github/workflows/slack-notify.yml with: + status: success + version: ${{ needs.plan.outputs.version }} + channel: ${{ needs.plan.outputs.channel }} + secrets: + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + + # Reports a broken release on every channel. `failure()` evaluates against the + # `needs` chain, so this fires whenever `plan` or `release` (and anything in + # the reusable release-shared workflow) fails. Skipped jobs — e.g. the + # fast-forward path or a release that never started — don't count as failures, + # so this stays quiet there. Dry runs are excluded; an operator running one is + # already watching it live. When `plan` fails its outputs are empty, so the + # message falls back to the workflow run link as the actionable detail. + notify-slack-failure: + name: Notify Slack (failure) + needs: [plan, release] + if: failure() && needs.plan.outputs.dry_run != 'true' + uses: ./.github/workflows/slack-notify.yml + with: + status: failure version: ${{ needs.plan.outputs.version }} channel: ${{ needs.plan.outputs.channel }} secrets: diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml index d92968c8b2..7fe2216917 100644 --- a/.github/workflows/slack-notify.yml +++ b/.github/workflows/slack-notify.yml @@ -11,6 +11,11 @@ on: description: Release channel (alpha | beta | stable), used to label the message required: false type: string + status: + description: Notification kind (success | failure). Failure messages report a broken release on any channel. + required: false + type: string + default: success secrets: SLACK_RELEASE_WEBHOOK: required: true @@ -26,6 +31,7 @@ jobs: env: VERSION: ${{ inputs.version }} CHANNEL: ${{ inputs.channel }} + STATUS: ${{ inputs.status }} REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} SHA: ${{ github.sha }} @@ -34,16 +40,50 @@ jobs: set -euo pipefail SHORT_SHA="${SHA:0:7}" - HEADER="🚀 Supabase CLI v${VERSION} released" - if [[ "$CHANNEL" == "beta" ]]; then - HEADER="🚀 Supabase CLI v${VERSION} released (beta)" - fi - - CHANGELOG_URL="https://github.com/${REPO}/releases/tag/v${VERSION}" COMMIT_URL="https://github.com/${REPO}/commit/${SHA}" RUN_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" - payload=$(cat < Date: Tue, 2 Jun 2026 17:57:51 +0530 Subject: [PATCH 08/38] feat(cli): port telemetry (#5422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR ports `supabase telemetry` to native ts ## What’s introduced native ts implementations for `telemetry enable`, `telemetry disable`, and `telemetry status` telemetry state handling in ts, including malformed state recovery and session refresh & coverage for telemetry state behavior ## ref - closes CLI-1309 --------- Co-authored-by: Colum Ferry --- apps/cli-e2e/src/tests/telemetry.e2e.test.ts | 2 +- apps/cli/docs/go-cli-porting-status.md | 3 + .../telemetry/disable/SIDE_EFFECTS.md | 57 ++++++ .../telemetry/disable/disable.command.ts | 11 +- .../telemetry/disable/disable.handler.ts | 8 +- .../commands/telemetry/enable/SIDE_EFFECTS.md | 57 ++++++ .../telemetry/enable/enable.command.ts | 11 +- .../telemetry/enable/enable.handler.ts | 8 +- .../commands/telemetry/status/SIDE_EFFECTS.md | 67 +++++++ .../telemetry/status/status.command.ts | 11 +- .../telemetry/status/status.handler.ts | 8 +- .../telemetry/telemetry.integration.test.ts | 172 ++++++++++++++++++ .../telemetry/legacy-telemetry-state.layer.ts | 130 ++++++++----- 13 files changed, 485 insertions(+), 60 deletions(-) create mode 100644 apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 5d428a2e93..8dbb4de63f 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -62,7 +62,7 @@ describe("telemetry", () => { writeFileSync(telemetryPath, "{{not valid json}}"); const result = await run(["telemetry", "status"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + expect(result.stdout).toContain("Telemetry is enabled."); expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); }); diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f17de55366..bf0453b113 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -271,6 +271,9 @@ Legend: | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | | `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | | `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | | `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | | `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | | `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | diff --git a/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..1be0ffbf9f --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md @@ -0,0 +1,57 @@ +# `supabase telemetry disable` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | --------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to preserve prior identity fields before rewriting it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | + +## API Routes + +`disable` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +## Telemetry Events Fired + +None. The command disables analytics capture so toggling telemetry does not emit +`cli_command_executed`, matching Go. + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is disabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. +- Malformed JSON is treated as missing state and replaced with a fresh disabled + state, matching `apps/cli-go/internal/telemetry/state.go`. diff --git a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts index 26275cf7eb..4914b2c2a6 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts @@ -1,5 +1,8 @@ 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 { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryDisable } from "./disable.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryDisableFlags = CliCommand.Command.Config.Infer legacyTelemetryDisable(flags)), + Command.withHandler((flags) => + legacyTelemetryDisable(flags).pipe( + withLegacyCommandInstrumentation({ analytics: false, flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "disable"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts b/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts index 656053ed86..a5a3410f49 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { setLegacyTelemetryEnabled } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryDisableFlags } from "./disable.command.ts"; export const legacyTelemetryDisable = Effect.fn("legacy.telemetry.disable")(function* ( _flags: LegacyTelemetryDisableFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "disable"]); + const output = yield* Output; + yield* setLegacyTelemetryEnabled(false); + yield* output.raw("Telemetry is disabled.\n"); }); diff --git a/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..3942a107dc --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md @@ -0,0 +1,57 @@ +# `supabase telemetry enable` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | --------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to preserve prior identity fields before rewriting it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | + +## API Routes + +`enable` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +## Telemetry Events Fired + +None. The command disables analytics capture so toggling telemetry does not emit +`cli_command_executed`, matching Go. + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is enabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. +- Malformed JSON is treated as missing state and replaced with a fresh enabled + state, matching `apps/cli-go/internal/telemetry/state.go`. diff --git a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts index 1e9f9b42c0..5497f0e929 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts @@ -1,5 +1,8 @@ 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 { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryEnable } from "./enable.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryEnableFlags = CliCommand.Command.Config.Infer legacyTelemetryEnable(flags)), + Command.withHandler((flags) => + legacyTelemetryEnable(flags).pipe( + withLegacyCommandInstrumentation({ analytics: false, flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "enable"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts b/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts index 886495f024..56c65eb8f5 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { setLegacyTelemetryEnabled } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryEnableFlags } from "./enable.command.ts"; export const legacyTelemetryEnable = Effect.fn("legacy.telemetry.enable")(function* ( _flags: LegacyTelemetryEnableFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "enable"]); + const output = yield* Output; + yield* setLegacyTelemetryEnabled(true); + yield* output.raw("Telemetry is enabled.\n"); }); diff --git a/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..2c93acd75b --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md @@ -0,0 +1,67 @@ +# `supabase telemetry status` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | ------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to load the current state before printing it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | -------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always, because `status` refreshes `session_last_active` and recreates malformed state | + +## API Routes + +`status` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +Malformed JSON does not fail the command; it is treated as missing state and +replaced with a fresh enabled state. + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is enabled. +``` + +or + +```text +Telemetry is disabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- `status` always rewrites the state file, matching Go's + `telemetry.Status(...)->LoadOrCreateState(...)` path. +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. diff --git a/apps/cli/src/legacy/commands/telemetry/status/status.command.ts b/apps/cli/src/legacy/commands/telemetry/status/status.command.ts index a14273f858..d5bb93f79e 100644 --- a/apps/cli/src/legacy/commands/telemetry/status/status.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/status/status.command.ts @@ -1,5 +1,8 @@ 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 { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryStatus } from "./status.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryStatusFlags = CliCommand.Command.Config.Infer legacyTelemetryStatus(flags)), + Command.withHandler((flags) => + legacyTelemetryStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "status"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts b/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts index 5dfb8531ec..b153b5fc85 100644 --- a/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { loadOrCreateLegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryStatusFlags } from "./status.command.ts"; export const legacyTelemetryStatus = Effect.fn("legacy.telemetry.status")(function* ( _flags: LegacyTelemetryStatusFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "status"]); + const output = yield* Output; + const state = yield* loadOrCreateLegacyTelemetryState(); + yield* output.raw(`Telemetry is ${state.enabled ? "enabled" : "disabled"}.\n`); }); diff --git a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts new file mode 100644 index 0000000000..ced529bbf0 --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; + +import { mockAnalytics, mockOutput, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { legacyTelemetryCommand } from "./telemetry.command.ts"; + +function makeTempDir(): string { + return mkdtempSync(path.join(tmpdir(), "supabase-legacy-telemetry-")); +} + +function telemetryPath(dir: string): string { + return path.join(dir, "telemetry.json"); +} + +function readTelemetryConfig(dir: string): Record { + return JSON.parse(readFileSync(telemetryPath(dir), "utf8")) as Record; +} + +function setup(dir: string) { + const out = mockOutput(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processEnvLayer({ SUPABASE_HOME: dir }), + ); + return { out, layer }; +} + +function legacyTestRoot() { + return Command.make("supabase").pipe(Command.withSubcommands([legacyTelemetryCommand])); +} + +describe("legacy telemetry integration", () => { + it.live("status creates legacy telemetry.json and prints Go-style enabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + expect(existsSync(telemetryPath(dir))).toBe(true); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("enable preserves prior identity fields and prints Go-style enabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.device_id).toBe("device-123"); + expect(config.distinct_id).toBe("user-123"); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("disable preserves prior identity fields and prints Go-style disabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); + expect(out.stdoutText).toBe("Telemetry is disabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(false); + expect(config.device_id).toBe("device-123"); + expect(config.distinct_id).toBe("user-123"); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("status recovers a malformed legacy telemetry.json instead of failing", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync(telemetryPath(dir), "{not valid json}"); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live( + "status treats malformed typed fields as a corrupted file and regenerates identity", + () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "not-a-time", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "telemetry", + "status", + ]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.device_id).not.toBe("device-123"); + expect(config.session_id).not.toBe("session-123"); + expect(config.distinct_id).toBeUndefined(); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }, + ); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index 057fbfba5d..fa709cc384 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -15,7 +15,7 @@ interface State { const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; -function telemetryPath(env: Record, pathSvc: Path.Path): string { +function legacyTelemetryPath(env: Record, pathSvc: Path.Path): string { const supabaseHome = env["SUPABASE_HOME"]?.trim(); if (supabaseHome !== undefined && supabaseHome.length > 0) { return pathSvc.join(supabaseHome, "telemetry.json"); @@ -23,12 +23,6 @@ function telemetryPath(env: Record, pathSvc: Path.Pa return pathSvc.join(homedir(), ".supabase", "telemetry.json"); } -function isStringField(value: unknown, key: string): boolean { - if (typeof value !== "object" || value === null) return false; - const field = (value as Record)[key]; - return typeof field === "string" && field.length > 0; -} - interface PriorState { enabled?: boolean; device_id?: string; @@ -37,25 +31,95 @@ interface PriorState { distinct_id?: string; } +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + function readExistingState(text: string): PriorState | undefined { try { const parsed = JSON.parse(text); if (typeof parsed !== "object" || parsed === null) return undefined; const record = parsed as Record; const out: PriorState = {}; - if (typeof record.enabled === "boolean") out.enabled = record.enabled; - if (isStringField(parsed, "device_id")) out.device_id = record.device_id as string; - if (isStringField(parsed, "session_id")) out.session_id = record.session_id as string; - if (isStringField(parsed, "session_last_active")) { - out.session_last_active = record.session_last_active as string; + if (hasOwn(record, "enabled")) { + if (typeof record.enabled !== "boolean") return undefined; + out.enabled = record.enabled; + } + if (hasOwn(record, "device_id")) { + if (typeof record.device_id !== "string") return undefined; + out.device_id = record.device_id; + } + if (hasOwn(record, "session_id")) { + if (typeof record.session_id !== "string") return undefined; + out.session_id = record.session_id; + } + if (hasOwn(record, "session_last_active")) { + if (typeof record.session_last_active !== "string") return undefined; + const parsedTime = new Date(record.session_last_active).getTime(); + if (!Number.isFinite(parsedTime)) return undefined; + out.session_last_active = record.session_last_active; + } + if (hasOwn(record, "distinct_id")) { + if (typeof record.distinct_id !== "string") return undefined; + out.distinct_id = record.distinct_id; + } + if (hasOwn(record, "schema_version")) { + if (!Number.isInteger(record.schema_version)) return undefined; } - if (isStringField(parsed, "distinct_id")) out.distinct_id = record.distinct_id as string; return out; } catch { return undefined; } } +export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.loadOrCreateState")( + function* (opts: { readonly now?: Date } = {}) { + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const filePath = legacyTelemetryPath(process.env, pathSvc); + const exists = yield* fs.exists(filePath); + const existing = exists ? yield* fs.readFileString(filePath) : undefined; + const prior = existing !== undefined ? readExistingState(existing) : undefined; + const now = opts.now ?? new Date(); + const nowIso = now.toISOString(); + + const priorActive = + prior?.session_last_active !== undefined ? new Date(prior.session_last_active).getTime() : 0; + const expired = + !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; + + const state: State = { + enabled: prior?.enabled ?? true, + device_id: prior?.device_id ?? crypto.randomUUID(), + session_id: + !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), + session_last_active: nowIso, + ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), + schema_version: SCHEMA_VERSION, + }; + + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(state)); + return state; + }, +); + +export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled")(function* ( + enabled: boolean, + opts: { readonly now?: Date } = {}, +) { + const state = yield* loadOrCreateLegacyTelemetryState(opts); + if (state.enabled === enabled) return state; + + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const nextState: State = { ...state, enabled }; + const filePath = legacyTelemetryPath(process.env, pathSvc); + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(nextState)); + return nextState; +}); + /** * Writes `/telemetry.json` on every command run. * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): @@ -77,41 +141,13 @@ export const legacyTelemetryStateLayer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; - const env = process.env; - return LegacyTelemetryState.of({ - flush: Effect.gen(function* () { - const filePath = telemetryPath(env, pathSvc); - - const existing = yield* fs.readFileString(filePath).pipe( - Effect.option, - Effect.map((opt) => (opt._tag === "Some" ? opt.value : undefined)), - ); - const prior = existing !== undefined ? readExistingState(existing) : undefined; - - const now = new Date(); - const nowIso = now.toISOString(); - - const priorActive = - prior?.session_last_active !== undefined - ? new Date(prior.session_last_active).getTime() - : 0; - const expired = - !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; - - const state: State = { - enabled: prior?.enabled ?? true, - device_id: prior?.device_id ?? crypto.randomUUID(), - session_id: - !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), - session_last_active: nowIso, - ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), - schema_version: SCHEMA_VERSION, - }; - - yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(state)); - }).pipe(Effect.ignore), + flush: loadOrCreateLegacyTelemetryState().pipe( + Effect.asVoid, + Effect.ignore, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathSvc), + ), }); }), ); From 6aac73b1dd109952f9c3100b6dc4dd99a8395a79 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 16:12:14 +0200 Subject: [PATCH 09/38] docs(cli): modernize README and add installer (#5428) ## Summary Refreshes the root README with a cleaner, more modern first impression inspired by opencode: a centered Supabase CLI lockup, focused npm/build badges, a compact installation block, and a shorter project-start flow. Adds a first-party `install` script for curl-based installs. The script detects platform and architecture, supports version-pinned installs, verifies release checksums when available, preserves the companion `supabase-go` binary from release archives, and handles Alpine/musl installs via the published `.apk` package. Also uploads the installer as part of future GitHub Releases so release consumers can use the script as a stable artifact. --- .github/workflows/release-shared.yml | 1 + README.md | 279 +++++++--------- docs/assets/supabase-cli-wordmark-dark.svg | 24 ++ docs/assets/supabase-cli-wordmark-light.svg | 24 ++ install | 332 ++++++++++++++++++++ 5 files changed, 497 insertions(+), 163 deletions(-) create mode 100644 docs/assets/supabase-cli-wordmark-dark.svg create mode 100644 docs/assets/supabase-cli-wordmark-light.svg create mode 100755 install diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index a428dde730..4c1f6663d5 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -292,6 +292,7 @@ jobs: dist/supabase_linux_amd64.tar.gz dist/supabase_windows_arm64.tar.gz dist/supabase_windows_amd64.tar.gz + install - name: Publish GitHub Release (immutable) env: diff --git a/README.md b/README.md index 8b9629770b..1e0b1d528e 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,167 @@ -# Supabase CLI +

+ + + + Supabase CLI + + +

-[![Coverage Status](https://coveralls.io/repos/github/supabase/cli/badge.svg?branch=develop)](https://coveralls.io/github/supabase/cli?branch=develop) [![Bitbucket Pipelines](https://img.shields.io/bitbucket/pipelines/supabase-cli/setup-cli/master?style=flat-square&label=Bitbucket%20Canary)](https://bitbucket.org/supabase-cli/setup-cli/pipelines) [![Gitlab Pipeline Status](https://img.shields.io/gitlab/pipeline-status/sweatybridge%2Fsetup-cli?label=Gitlab%20Canary) -](https://gitlab.com/sweatybridge/setup-cli/-/pipelines) +

+ Develop locally and deploy to the Supabase Platform from your terminal. +

-[Supabase](https://supabase.io) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools. +

+ npm + Build + License + Discord +

-This repository contains all the functionality for Supabase CLI. +--- -- [x] Running Supabase locally -- [x] Managing database migrations -- [x] Creating and deploying Supabase Functions -- [x] Generating types directly from your database schema -- [x] Making authenticated HTTP requests to [Management API](https://supabase.com/docs/reference/api/introduction) +Supabase CLI brings the Supabase Platform to your terminal. Run the full local stack, manage database migrations, deploy Edge Functions, generate types, and automate project workflows. -## Getting started +## Installation -### Install the CLI +```sh +# YOLO +curl -fsSL https://raw.githubusercontent.com/supabase/cli/main/install | bash -Available via [NPM](https://www.npmjs.com) as dev dependency. To install: +# npm +npm install -D supabase # or bun/pnpm/yarn add -D supabase +npm install -D supabase@beta # beta channel -```bash -npm i supabase --save-dev -``` +# macOS and Linux +brew install supabase/tap/supabase # always up to date +brew install supabase # official formula, may be delayed +brew install supabase/tap/supabase-beta # beta channel -To install the beta release channel: +# Windows +scoop bucket add supabase https://github.com/supabase/scoop-bucket.git +scoop install supabase +scoop install supabase-beta # beta channel -```bash -npm i supabase@beta --save-dev +# Linux packages +# Download .apk, .deb, .rpm, or .pkg.tar.zst from GitHub Releases. ``` -When installing with yarn 4, you need to disable experimental fetch with the following nodejs config. - -``` -NODE_OPTIONS=--no-experimental-fetch yarn add supabase -``` - -> **Note** -For Bun versions below v1.0.17, you must add `supabase` as a [trusted dependency](https://bun.sh/guides/install/trusted) before running `bun add -D supabase`. - -
- macOS - - Available via [Homebrew](https://brew.sh). To install: - - ```sh - brew install supabase/tap/supabase - ``` - - To install the beta release channel: - - ```sh - brew install supabase/tap/supabase-beta - brew link --overwrite supabase-beta - ``` - - To upgrade: - - ```sh - brew upgrade supabase - ``` - - Beta channel: - - ```sh - brew upgrade supabase-beta - ``` -
+Linux packages are available from [Releases](https://github.com/supabase/cli/releases). Community-maintained packages are also available through [pkgx](https://pkgx.sh/) and [Nixpkgs](https://nixos.org/). -
- Windows +## Start Local Development - Available via [Scoop](https://scoop.sh). To install: +Create a Supabase workspace and start the local stack: - ```powershell - scoop bucket add supabase https://github.com/supabase/scoop-bucket.git - scoop install supabase - ``` - - To install the beta release channel: - - ```powershell - scoop install supabase-beta - ``` - - To upgrade: - - ```powershell - scoop update supabase - ``` - - Beta channel: +```sh +supabase init +supabase start +supabase status +``` - ```powershell - scoop update supabase-beta - ``` -
+The local stack includes Postgres, Auth, Realtime, Storage, Edge Functions, and the Supabase APIs. -
- Linux +Start from a template: - Available via [Homebrew](https://brew.sh) and Linux packages. +```sh +supabase bootstrap +``` - #### via Homebrew +## Link A Project - To install: +Connect your local workspace to a hosted Supabase project: - ```sh - brew install supabase/tap/supabase - ``` +```sh +supabase login +supabase link +``` - To install the beta release channel: +## Manage Your Database - ```sh - brew install supabase/tap/supabase-beta - brew link --overwrite supabase-beta - ``` +Create migrations, compare schemas, and apply changes locally or to your linked project: - To upgrade: +```sh +supabase migration new create_profiles +supabase db diff +supabase db push +supabase db reset +``` - ```sh - brew upgrade supabase - ``` +## Deploy Edge Functions - Beta channel: +Build, serve, and deploy functions from the same project workspace: - ```sh - brew upgrade supabase-beta - ``` +```sh +supabase functions new hello-world +supabase functions serve +supabase functions deploy hello-world +``` - #### via Linux packages +## Generate Types - Linux packages are provided in [Releases](https://github.com/supabase/cli/releases). To install, download the `.apk`/`.deb`/`.rpm`/`.pkg.tar.zst` file depending on your package manager and run the respective commands. +Generate TypeScript types from your local database or linked project: - ```sh - sudo apk add --allow-untrusted <...>.apk - ``` +```sh +supabase gen types --local +supabase gen types --linked +``` - ```sh - sudo dpkg -i <...>.deb - ``` +## Reference - ```sh - sudo rpm -i <...>.rpm - ``` +Use `--help` on any command to explore flags and examples: - ```sh - sudo pacman -U <...>.pkg.tar.zst - ``` -
+```sh +supabase db --help +supabase functions deploy --help +``` -
- Other Platforms +- [CLI reference](https://supabase.com/docs/reference/cli/about) +- [Local development guide](https://supabase.com/docs/guides/local-development) +- [Supabase docs](https://supabase.com/docs) - You can also install the CLI via [go modules](https://go.dev/ref/mod#go-install) without the help of package managers. +## Developing - ```sh - go install github.com/supabase/cli@latest - ``` +This repository is a pnpm monorepo. The published package lives in `apps/cli`. - Add a symlink to the binary in `$PATH` for easier access: +```sh +pnpm install +cd apps/cli - ```sh - ln -s "$(go env GOPATH)/bin/cli" /usr/bin/supabase - ``` +pnpm dev:next -- --help +pnpm check:all +pnpm test:core +``` - This works on other non-standard Linux distros. -
+Useful source entry points: -
- Community Maintained Packages +| Path | Purpose | +| ----------------- | -------------------------------------- | +| `apps/cli` | TypeScript/Bun CLI package | +| `apps/cli-go` | Go CLI source used by the legacy shell | +| `packages/stack` | Local Supabase stack runtime | +| `packages/config` | Config schema and generated types | +| `packages/api` | Typed Supabase Management API client | - Available via [pkgx](https://pkgx.sh/). Package script [here](https://github.com/pkgxdev/pantry/blob/main/projects/supabase.com/cli/package.yml). - To install in your working directory: +After a fresh clone, install the reference repositories used for agent and developer inspection: - ```bash - pkgx install supabase - ``` +```sh +pnpm repos:install +``` - Available via [Nixpkgs](https://nixos.org/). Package script [here](https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/supabase-cli/default.nix). -
+## Contributing -### Run the CLI +We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. Before opening a PR, run the checks for the workspace you touched. -```bash -supabase bootstrap +```sh +pnpm check:all +pnpm test ``` -Or using npx: +PR titles must use conventional commits, for example: -```bash -npx supabase bootstrap +```text +fix(cli): handle linked projects without cached service versions ``` -The bootstrap command will guide you through the process of setting up a Supabase project using one of the [starter](https://github.com/supabase-community/supabase-samples/blob/main/samples.json) templates. - -## Docs - -Command & config reference can be found [here](https://supabase.com/docs/reference/cli/about). - -## Breaking changes - -We follow semantic versioning for changes that directly impact CLI commands, flags, and configurations. +## License -However, due to dependencies on other service images, we cannot guarantee that schema migrations, seed.sql, and generated types will always work for the same CLI major version. If you need such guarantees, we encourage you to pin a specific version of CLI in package.json. - -## Developing - -To run from source: - -```sh -# Go >= 1.22 -go run . help -``` +Supabase CLI packages are released under the MIT license. diff --git a/docs/assets/supabase-cli-wordmark-dark.svg b/docs/assets/supabase-cli-wordmark-dark.svg new file mode 100644 index 0000000000..e96ab6721d --- /dev/null +++ b/docs/assets/supabase-cli-wordmark-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + +CLI + + + + + + + + + + + + + + diff --git a/docs/assets/supabase-cli-wordmark-light.svg b/docs/assets/supabase-cli-wordmark-light.svg new file mode 100644 index 0000000000..ca769b2913 --- /dev/null +++ b/docs/assets/supabase-cli-wordmark-light.svg @@ -0,0 +1,24 @@ + + + + + + + + + +CLI + + + + + + + + + + + + + + diff --git a/install b/install new file mode 100755 index 0000000000..98f8105032 --- /dev/null +++ b/install @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="supabase" +REPO="supabase/cli" +INSTALL_DIR="${SUPABASE_INSTALL_DIR:-"$HOME/.supabase/bin"}" +REQUESTED_VERSION="${VERSION:-}" +NO_MODIFY_PATH=false +BINARY_PATH="" + +RED="\033[0;31m" +MUTED="\033[0;2m" +GREEN="\033[0;32m" +NC="\033[0m" + +usage() { + cat < Install a specific version, for example 2.0.0. + -d, --install-dir Install into a custom directory. + -b, --binary Install from a local binary instead of downloading. + --no-modify-path Do not update shell config files. + -h, --help Show this help. + +Environment: + VERSION Install a specific version. + SUPABASE_INSTALL_DIR Install into a custom directory. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + -v | --version) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --version requires a version.${NC}" >&2 + exit 1 + fi + REQUESTED_VERSION="$2" + shift 2 + ;; + -d | --install-dir) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --install-dir requires a path.${NC}" >&2 + exit 1 + fi + INSTALL_DIR="$2" + shift 2 + ;; + -b | --binary) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --binary requires a path.${NC}" >&2 + exit 1 + fi + BINARY_PATH="$2" + shift 2 + ;; + --no-modify-path) + NO_MODIFY_PATH=true + shift + ;; + *) + echo -e "${RED}Error: unknown option '$1'.${NC}" >&2 + usage >&2 + exit 1 + ;; + esac +done + +say() { + echo -e "$1" +} + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo -e "${RED}Error: '$1' is required but was not found.${NC}" >&2 + exit 1 + fi +} + +detect_target() { + local raw_os raw_arch os arch + raw_os="$(uname -s)" + raw_arch="$(uname -m)" + + case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW* | MSYS* | CYGWIN*) os="windows" ;; + *) + echo -e "${RED}Error: unsupported OS '$raw_os'.${NC}" >&2 + exit 1 + ;; + esac + + case "$raw_arch" in + x86_64 | amd64) arch="amd64" ;; + arm64 | aarch64) arch="arm64" ;; + *) + echo -e "${RED}Error: unsupported architecture '$raw_arch'.${NC}" >&2 + exit 1 + ;; + esac + + if [[ "$os" == "darwin" && "$arch" == "amd64" ]]; then + local translated + translated="$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" + if [[ "$translated" == "1" ]]; then + arch="arm64" + fi + fi + + echo "${os}_${arch}" +} + +is_musl_linux() { + [[ -f /etc/alpine-release ]] && return 0 + command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl +} + +latest_version() { + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | + sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | + head -n 1 +} + +check_installed_version() { + local version="$1" + local bin="${INSTALL_DIR}/${APP}" + local installed_version + + if [[ ! -x "$bin" ]]; then + return 0 + fi + + installed_version="$("$bin" --version 2>/dev/null || true)" + if [[ "$installed_version" == "$version" ]]; then + say "${MUTED}Version ${NC}${version}${MUTED} is already installed at ${NC}${bin}${MUTED}.${NC}" + exit 0 + fi +} + +checksum_for() { + local file="$1" + local checksums="$2" + awk -v file="$file" '$2 == file { print $1 }' "$checksums" +} + +verify_checksum() { + local file="$1" + local checksums="$2" + local expected actual + + expected="$(checksum_for "$(basename "$file")" "$checksums")" + if [[ -z "$expected" ]]; then + say "${MUTED}No checksum found for $(basename "$file"); skipping verification.${NC}" + return 0 + fi + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$file" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$file" | awk '{ print $1 }')" + else + say "${MUTED}No sha256 tool found; skipping checksum verification.${NC}" + return 0 + fi + + if [[ "$actual" != "$expected" ]]; then + echo -e "${RED}Error: checksum verification failed for $(basename "$file").${NC}" >&2 + exit 1 + fi +} + +install_from_binary() { + local ext="$1" + + if [[ ! -f "$BINARY_PATH" ]]; then + echo -e "${RED}Error: binary not found at '$BINARY_PATH'.${NC}" >&2 + exit 1 + fi + + mkdir -p "$INSTALL_DIR" + cp "$BINARY_PATH" "${INSTALL_DIR}/${APP}${ext}" + chmod 755 "${INSTALL_DIR}/${APP}${ext}" +} + +download_and_install() { + local target="$1" + local ext="$2" + local version filename base_url tmp_dir archive checksums source_dir companion_ext + + need curl + need tar + need awk + need sed + need head + + version="${REQUESTED_VERSION#v}" + if [[ -z "$version" ]]; then + version="$(latest_version)" + fi + if [[ -z "$version" ]]; then + echo -e "${RED}Error: failed to resolve the latest Supabase CLI version.${NC}" >&2 + exit 1 + fi + check_installed_version "$version" + + filename="supabase_${version}_${target}.tar.gz" + if [[ "$target" == linux_* ]] && is_musl_linux; then + filename="supabase_${version}_${target}.apk" + fi + + base_url="https://github.com/${REPO}/releases/download/v${version}" + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/supabase-install.XXXXXX")" + archive="${tmp_dir}/${filename}" + checksums="${tmp_dir}/checksums.txt" + source_dir="$tmp_dir" + + trap 'rm -rf "$tmp_dir"; trap - RETURN' RETURN + + say "${MUTED}Installing ${NC}${APP}${MUTED} ${version} for ${target}.${NC}" + curl -fL --progress-bar "${base_url}/${filename}" -o "$archive" + if curl -fsSL "${base_url}/checksums.txt" -o "$checksums"; then + verify_checksum "$archive" "$checksums" + else + say "${MUTED}Could not download checksums.txt; skipping verification.${NC}" + fi + + tar -xzf "$archive" -C "$tmp_dir" + if [[ "$filename" == *.apk ]]; then + source_dir="${tmp_dir}/usr/bin" + fi + + if [[ ! -f "${source_dir}/${APP}${ext}" ]]; then + echo -e "${RED}Error: archive did not contain ${APP}${ext}.${NC}" >&2 + exit 1 + fi + + mkdir -p "$INSTALL_DIR" + mv "${source_dir}/${APP}${ext}" "${INSTALL_DIR}/${APP}${ext}" + chmod 755 "${INSTALL_DIR}/${APP}${ext}" + + companion_ext="$ext" + if [[ -f "${source_dir}/${APP}-go${companion_ext}" ]]; then + mv "${source_dir}/${APP}-go${companion_ext}" "${INSTALL_DIR}/${APP}-go${companion_ext}" + chmod 755 "${INSTALL_DIR}/${APP}-go${companion_ext}" + fi +} + +path_command_for_shell() { + local shell_name="$1" + case "$shell_name" in + fish) echo "fish_add_path $INSTALL_DIR" ;; + *) echo "export PATH=\"$INSTALL_DIR:\$PATH\"" ;; + esac +} + +config_file_for_shell() { + local shell_name="$1" + case "$shell_name" in + fish) echo "${XDG_CONFIG_HOME:-"$HOME/.config"}/fish/config.fish" ;; + zsh) echo "${ZDOTDIR:-"$HOME"}/.zshrc" ;; + bash) echo "$HOME/.bashrc" ;; + *) echo "$HOME/.profile" ;; + esac +} + +add_to_path() { + local shell_name config_file command + + if [[ "$NO_MODIFY_PATH" == "true" ]]; then + return 0 + fi + + if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then + return 0 + fi + + shell_name="$(basename "${SHELL:-sh}")" + config_file="$(config_file_for_shell "$shell_name")" + command="$(path_command_for_shell "$shell_name")" + + mkdir -p "$(dirname "$config_file")" + touch "$config_file" + + if grep -Fxq "$command" "$config_file"; then + return 0 + fi + + { + echo "" + echo "# Supabase CLI" + echo "$command" + } >>"$config_file" + + say "${MUTED}Added ${NC}${APP}${MUTED} to PATH in ${NC}${config_file}${MUTED}.${NC}" +} + +target="$(detect_target)" +binary_ext="" +if [[ "$target" == windows_* ]]; then + binary_ext=".exe" +fi + +if [[ -n "$BINARY_PATH" ]]; then + install_from_binary "$binary_ext" +else + download_and_install "$target" "$binary_ext" +fi + +add_to_path + +if [[ "${GITHUB_ACTIONS:-}" == "true" && -n "${GITHUB_PATH:-}" ]]; then + echo "$INSTALL_DIR" >>"$GITHUB_PATH" +fi + +say "" +say "${GREEN}Supabase CLI installed to ${INSTALL_DIR}/${APP}${binary_ext}.${NC}" +say "Run '${APP} --version' to verify the installation." +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + say "${MUTED}Open a new terminal or run: export PATH=\"${INSTALL_DIR}:\$PATH\"${NC}" +fi From cd3e08281251566e5b71e60b62816a8f1819b403 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 16:18:04 +0200 Subject: [PATCH 10/38] fix(docker): check Supabase image updates hourly (#5429) ## What changed Docker Dependabot now checks the CLI template image pins hourly instead of daily. Supabase-owned Docker images are excluded from the 7-day cooldown, so new service image releases can surface quickly while non-Supabase images keep the existing cooldown. ## Why Supabase service image releases, such as Studio updates, need to be available to the CLI promptly after release. The existing cooldown delayed those Dependabot PRs even though they are first-party release inputs. --- .github/dependabot.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0889e6501e..a0d90d1e71 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -42,7 +42,7 @@ updates: directory: "/apps/cli-go/pkg/config/templates" schedule: interval: "cron" - cronjob: "0 0 * * *" + cronjob: "0 * * * *" commit-message: prefix: "fix(docker): " groups: @@ -57,3 +57,5 @@ updates: - dependency-name: "timberio/vector" cooldown: default-days: 7 + exclude: + - "supabase/*" From 250bb9fb6d5bf28296af39b5bc10f4931d29735d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 16:23:34 +0200 Subject: [PATCH 11/38] ci(cli): publish pkg.pr.new previews on pull requests (#5427) ## Summary Simplifies preview publishing to match the pkg.pr.new pull_request model. The preview workflow now runs directly on non-draft PRs to develop, builds the legacy release-shaped CLI artifacts, publishes all platform wrapper packages plus apps/cli to pkg.pr.new with comments disabled, and logs/verifies the PR-scoped npx command. This removes the workflow_run dependency on Test, the same-repo-only fork guard, and the custom github-actions PR comment. Fork PRs can now get previews through GitHub's normal fork workflow approval model without granting issue/comment write permissions to the preview workflow. --- .../publish-preview-cli-packages.yml | 164 ++---------------- 1 file changed, 16 insertions(+), 148 deletions(-) diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 40b6382a4a..35d37403c7 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -1,142 +1,44 @@ name: Publish Preview CLI Packages on: - workflow_run: - workflows: - - Test + pull_request: types: - - completed + - opened + - synchronize + - reopened + - ready_for_review + branches: + - develop permissions: actions: read contents: read - pull-requests: read concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha || github.run_id }} + group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true jobs: - resolve: - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - name: Resolve preview build context - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - outputs: - should_build: ${{ steps.context.outputs.should_build }} - pr_number: ${{ steps.context.outputs.pr_number }} - pr_head_sha: ${{ steps.context.outputs.pr_head_sha }} - preview_version: ${{ steps.context.outputs.preview_version }} - steps: - - name: Resolve PR context - id: context - run: | - set -euo pipefail - - should_build=false - pr_number="$(jq -r '.workflow_run.pull_requests[0].number // ""' "$GITHUB_EVENT_PATH")" - pr_head_sha="$(jq -r '.workflow_run.head_sha // ""' "$GITHUB_EVENT_PATH")" - pr_head_branch="$(jq -r '.workflow_run.head_branch // ""' "$GITHUB_EVENT_PATH")" - - if [[ -z "${pr_head_sha}" ]]; then - echo "Workflow run has no head SHA; skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ -z "${pr_number}" && -n "${pr_head_branch}" ]]; then - pr_number="$( - gh pr list \ - --repo "${REPOSITORY}" \ - --head "${pr_head_branch}" \ - --state open \ - --json number,headRefOid \ - --jq 'map(select(.headRefOid == "'"${pr_head_sha}"'")) | .[0].number // ""' - )" - fi - - if [[ -z "${pr_number}" ]]; then - echo "Test run is not associated with an open pull request; skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - pr_json="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")" - current_head_sha="$(jq -r '.head.sha' <<< "${pr_json}")" - state="$(jq -r '.state' <<< "${pr_json}")" - draft="$(jq -r '.draft' <<< "${pr_json}")" - head_repo="$(jq -r '.head.repo.full_name' <<< "${pr_json}")" - base_repo="$(jq -r '.base.repo.full_name' <<< "${pr_json}")" - - if [[ "${state}" != "open" ]]; then - echo "PR #${pr_number} is ${state}; skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${draft}" == "true" ]]; then - echo "PR #${pr_number} is draft; skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${head_repo}" != "${base_repo}" ]]; then - echo "PR #${pr_number} comes from fork ${head_repo}; skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${pr_head_sha}" != "${current_head_sha}" ]]; then - echo "Test SHA ${pr_head_sha} is stale; current PR head is ${current_head_sha}. Skipping." - echo "should_build=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - preview_version="0.0.0-pr.${pr_number}" - should_build=true - - { - echo "should_build=${should_build}" - echo "pr_number=${pr_number}" - echo "pr_head_sha=${pr_head_sha}" - echo "preview_version=${preview_version}" - } >> "$GITHUB_OUTPUT" - build: - needs: resolve - if: needs.resolve.outputs.should_build == 'true' + if: github.event.pull_request.draft == false name: Build preview CLI packages uses: ./.github/workflows/build-cli-artifacts.yml with: - version: ${{ needs.resolve.outputs.preview_version }} + version: 0.0.0-pr.${{ github.event.pull_request.number }} shell: legacy - ref: ${{ needs.resolve.outputs.pr_head_sha }} publish: - needs: [resolve, build] - if: needs.resolve.outputs.should_build == 'true' && needs.build.result == 'success' + needs: build + if: github.event.pull_request.draft == false && needs.build.result == 'success' name: Publish preview package runs-on: ubuntu-latest - permissions: - actions: read - contents: read - issues: write - pull-requests: read env: - GH_TOKEN: ${{ github.token }} - PREVIEW_VERSION: ${{ needs.resolve.outputs.preview_version }} - PR_HEAD_SHA: ${{ needs.resolve.outputs.pr_head_sha }} - PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} - REPOSITORY: ${{ github.repository }} + PREVIEW_VERSION: 0.0.0-pr.${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ needs.resolve.outputs.pr_head_sha }} persist-credentials: false - name: Setup @@ -145,7 +47,7 @@ jobs: - name: Download preview build artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: cli-build-legacy-${{ needs.resolve.outputs.preview_version }} + name: cli-build-legacy-${{ env.PREVIEW_VERSION }} - name: Prepare package files run: | @@ -176,39 +78,5 @@ jobs: run: | set -euo pipefail preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + echo "Preview command: npx ${preview_url}" npx --yes "${preview_url}" --version - - - name: Update PR comment - run: | - set -euo pipefail - preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" - short_sha="${PR_HEAD_SHA:0:7}" - marker="" - cat > comment.md < comment.json - comment_id="$( - gh api "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --paginate \ - --jq '.[] | select(.body | contains("'"${marker}"'")) | .id' \ - | head -n1 - )" - - if [[ -n "${comment_id}" ]]; then - gh api --method PATCH "repos/${REPOSITORY}/issues/comments/${comment_id}" --input comment.json >/dev/null - else - gh api --method POST "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" --input comment.json >/dev/null - fi From c96e6c3e4b89a985331e48cde34515cbb2dcf902 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 2 Jun 2026 17:06:22 +0200 Subject: [PATCH 12/38] fix(docker): restore daily Dependabot schedule (#5430) ## What changed Restores the Docker Dependabot cron expression to the known-good once-daily schedule. The `supabase/*` cooldown exclusion remains in place, so first-party Supabase Docker images can still update without the 7-day cooldown. ## Why The hourly cron expression may be rejected by GitHub Dependabot even though cron scheduling is documented. Using the existing daily cron shape avoids breaking Dependabot configuration while preserving the important first-party cooldown exemption. --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0d90d1e71..fb0ec4fcc9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -42,7 +42,7 @@ updates: directory: "/apps/cli-go/pkg/config/templates" schedule: interval: "cron" - cronjob: "0 * * * *" + cronjob: "0 0 * * *" commit-message: prefix: "fix(docker): " groups: From 17c45adf658be7ea2e6cb0ee97bcf3c46a259e05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:16:12 +0000 Subject: [PATCH 13/38] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 5 updates (#5431) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 5 updates: | Package | From | To | | --- | --- | --- | | supabase/studio | `2026.05.25-sha-65c570e` | `2026.06.01-sha-a4334a2` | | supabase/supavisor | `2.9.5` | `2.9.7` | | supabase/realtime | `v2.102.1` | `v2.102.3` | | supabase/storage-api | `v1.60.2` | `v1.60.4` | | supabase/logflare | `1.42.0` | `1.43.1` | Updates `supabase/studio` from 2026.05.25-sha-65c570e to 2026.06.01-sha-a4334a2 Updates `supabase/supavisor` from 2.9.5 to 2.9.7 Updates `supabase/realtime` from v2.102.1 to v2.102.3 Updates `supabase/storage-api` from v1.60.2 to v1.60.4 Updates `supabase/logflare` from 1.42.0 to 1.43.1 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 76c42d1bbd..a2f1f96111 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,15 +5,15 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit FROM postgrest/postgrest:v14.12 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.05.25-sha-65c570e AS studio +FROM supabase/studio:2026.06.01-sha-a4334a2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.5 AS supavisor +FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.102.1 AS realtime -FROM supabase/storage-api:v1.60.2 AS storage -FROM supabase/logflare:1.42.0 AS logflare +FROM supabase/realtime:v2.102.3 AS realtime +FROM supabase/storage-api:v1.60.4 AS storage +FROM supabase/logflare:1.43.1 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra From a1259d6703580104c04470702a60aba3c26dedee Mon Sep 17 00:00:00 2001 From: VERSA SYNC STUDIOS <206948228+Versa-Sync-Studios@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:04:27 +0530 Subject: [PATCH 14/38] fix(cli): read Go Windows credentials via findCredentials (#5423) Fixes #5415. The Windows fallback added for Go-written credentials used `Entry.withTarget(...).getPassword()`. On Windows, that does not read the Go-shaped target credential correctly. `findCredentials(service, target)` can read it, so this uses that path for the Go Windows target while preserving the existing default keyring and file fallback behavior. The legacy credentials unit test now covers the target lookup path. Tested with: ```bash npx bun run .\node_modules\vitest\vitest.mjs run src/legacy/auth/legacy-credentials.layer.unit.test.ts --config vitest.config.ts ``` --------- Co-authored-by: Julien Goux --- .../legacy/auth/legacy-credentials.layer.ts | 127 +++++++++++++++--- .../legacy-credentials.layer.unit.test.ts | 78 ++++++++++- 2 files changed, 182 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 95c77385e4..5cdeab8f9d 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -16,6 +16,7 @@ const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_01 type KeyringModule = typeof import("@napi-rs/keyring"); type KeyringEntry = InstanceType; +type RuntimePlatform = NodeJS.Platform; const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => Effect.gen(function* () { @@ -30,16 +31,21 @@ const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => const tryKeyringRead = ( module: KeyringModule, account: string, + platform: RuntimePlatform, ): Effect.Effect> => Effect.try({ try: () => { - for (const entry of [ - new module.Entry(KEYRING_SERVICE, account), - module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account), - ]) { - const value = readEntryPassword(entry); - if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = readEntryPassword(entry); + if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); + + if (platform === "win32") { + const goWindowsValue = readGoWindowsTarget(module, account); + if (goWindowsValue && goWindowsValue.length > 0) { + return Option.some(normalizeKeyringToken(goWindowsValue)); + } } + return Option.none(); }, catch: () => Option.none(), @@ -49,9 +55,14 @@ const tryKeyringWrite = ( module: KeyringModule, account: string, token: string, + platform: RuntimePlatform, ): Effect.Effect => Effect.try({ try: () => { + if (platform === "win32") { + return writeGoWindowsTarget(module, account, token); + } + const entry = new module.Entry(KEYRING_SERVICE, account); entry.setPassword(token); return true; @@ -59,19 +70,26 @@ const tryKeyringWrite = ( catch: () => false, }).pipe(Effect.orElseSucceed(() => false)); -const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect => +const tryKeyringDelete = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect => Effect.try({ try: () => { let deleted = false; - for (const entry of [ - new module.Entry(KEYRING_SERVICE, account), - module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account), - ]) { - const value = readEntryPassword(entry); - if (!value) continue; + + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = readEntryPassword(entry); + if (value) { entry.deleteCredential(); deleted = true; } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + deleted = deleteGoWindowsTarget(module, account) || deleted; + } + return deleted; }, catch: () => false, @@ -85,6 +103,64 @@ function readEntryPassword(entry: KeyringEntry): string | null { } } +function goWindowsCredentialTarget(account: string): string { + return `${KEYRING_SERVICE}:${account}`; +} + +function readGoWindowsTarget(module: KeyringModule, account: string): string | null { + try { + const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credential = credentials.find((item) => item.account === account); + return credential ? normalizeGoWindowsPassword(credential.password) : null; + } catch { + return null; + } +} + +function normalizeGoWindowsPassword(value: string): string { + const direct = normalizeKeyringToken(value); + if (ACCESS_TOKEN_PATTERN.test(direct)) return direct; + + // Go writes Windows CredentialBlob values as raw UTF-8 bytes. The TS keyring + // search API can surface those bytes packed into UTF-16 code units, so unpack + // each code unit back into the original byte sequence before validation. + const bytes: number[] = []; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + bytes.push(code & 0xff); + const high = (code >> 8) & 0xff; + if (high !== 0) bytes.push(high); + } + return Buffer.from(bytes).toString("utf8"); +} + +function writeGoWindowsTarget(module: KeyringModule, account: string, token: string): boolean { + try { + const entry = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + entry.setSecret(Buffer.from(token, "utf8")); + return true; + } catch { + return false; + } +} + +function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean { + try { + const entry = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + return entry.deleteCredential(); + } catch { + return false; + } +} + const makeLegacyCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -108,9 +184,13 @@ const makeLegacyCredentials = Effect.gen(function* () { const readKeyring = Effect.gen(function* () { if (Option.isNone(keyringModule)) return Option.none(); - const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount); + const profileResult = yield* tryKeyringRead( + keyringModule.value, + profileAccount, + runtimeInfo.platform, + ); if (Option.isSome(profileResult)) return profileResult; - return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT); + return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); }); const readFile = Effect.gen(function* () { @@ -150,7 +230,12 @@ const makeLegacyCredentials = Effect.gen(function* () { Effect.gen(function* () { yield* validate(token); if (Option.isSome(keyringModule)) { - const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token); + const ok = yield* tryKeyringWrite( + keyringModule.value, + profileAccount, + token, + runtimeInfo.platform, + ); if (ok) return; } yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie); @@ -160,8 +245,14 @@ const makeLegacyCredentials = Effect.gen(function* () { deleteAccessToken: Effect.gen(function* () { let anyDeleted = false; if (Option.isSome(keyringModule)) { - if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true; - if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true; + if (yield* tryKeyringDelete(keyringModule.value, profileAccount, runtimeInfo.platform)) { + anyDeleted = true; + } + if ( + yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform) + ) { + anyDeleted = true; + } } const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 80243780b9..8aef37589c 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -20,9 +20,20 @@ import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; const passwords = new Map(); let throwOnSetPassword = false; +let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); +const withTargetCalls: string[] = []; vi.mock("@napi-rs/keyring", () => ({ + findCredentials: (service: string, target?: string) => + Array.from(passwords.entries()) + .filter(([key]) => + target === undefined ? key.startsWith(`${service}/`) : key.startsWith(`${target}/`), + ) + .map(([key, password]) => ({ + account: key.split("/").at(-1)!, + password, + })), Entry: class Entry { service: string; account: string; @@ -33,6 +44,7 @@ vi.mock("@napi-rs/keyring", () => ({ this.target = target; } static withTarget(target: string, service: string, account: string) { + withTargetCalls.push(`${target}/${service}/${account}`); return new this(service, account, target); } key(): string { @@ -51,6 +63,10 @@ vi.mock("@napi-rs/keyring", () => ({ if (throwOnSetPassword) throw new Error("Keyring unavailable"); passwords.set(this.key(), value); } + setSecret(value: Uint8Array): void { + if (throwOnSetSecret) throw new Error("Keyring unavailable"); + passwords.set(this.key(), Buffer.from(value).toString("utf8")); + } deleteCredential(): boolean { const key = this.key(); if (!passwords.has(key)) throw new Error("not found"); @@ -66,10 +82,20 @@ vi.mock("@napi-rs/keyring", () => ({ let tempHome: string; -function makeLayer(opts: { env?: Record; home?: string } = {}) { +function makeLayer( + opts: { + env?: Record; + home?: string; + platform?: NodeJS.Platform; + } = {}, +) { const home = opts.home ?? tempHome; const env = { HOME: home, ...opts.env }; - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const runtimeInfoLayer = mockRuntimeInfo({ + homeDir: home, + cwd: home, + platform: opts.platform, + }); const cliConfigLayer = legacyCliConfigLayer.pipe( Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), @@ -88,7 +114,9 @@ function makeLayer(opts: { env?: Record; home?: stri beforeEach(() => { passwords.clear(); throwOnSetPassword = false; + throwOnSetSecret = false; throwOnGetPasswordAccounts.clear(); + withTargetCalls.length = 0; tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -101,6 +129,14 @@ const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); const encodeGoKeyringBase64 = (token: string) => `go-keyring-base64:${Buffer.from(token).toString("base64")}`; const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; +const encodeGoWindowsPassword = (token: string) => { + const bytes = Buffer.from(token, "utf8"); + let encoded = ""; + for (let index = 0; index < bytes.length; index += 2) { + encoded += String.fromCharCode(bytes[index]! | ((bytes[index + 1] ?? 0) << 8)); + } + return encoded; +}; const expectSomeToken = (token: Option.Option>, expected: string) => { expect(Option.isSome(token)).toBe(true); @@ -138,12 +174,23 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }); it.effect("reads Windows credentials created by Go keyring", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + passwords.set(goWindowsKey("supabase"), encodeGoWindowsPassword(VALID_TOKEN)); return Effect.gen(function* () { const { getAccessToken } = yield* LegacyCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); - }).pipe(Effect.provide(makeLayer())); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("does not search Go Windows targets on other platforms", () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "linux" }))); }); it.effect("falls through to the legacy access-token keyring entry", () => { @@ -222,6 +269,27 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { }).pipe(Effect.provide(makeLayer())), ); + it.effect("writes Windows credentials where Go keyring reads them", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.get(goWindowsKey("supabase"))).toBe(VALID_TOKEN); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))), + ); + + it.effect("falls back to the shared token file when Windows target writes fail", () => { + throwOnSetSecret = true; + return Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + expect(content).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect("falls back to the filesystem when the keyring write throws", () => { throwOnSetPassword = true; return Effect.gen(function* () { @@ -255,7 +323,7 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { expect(passwords.has("Supabase CLI/access-token")).toBe(false); expect(passwords.has(goWindowsKey("supabase"))).toBe(false); expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer())); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); }); From f4e27452c4daa1ef764c1c0a165309110534e49a Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:48:13 +0200 Subject: [PATCH 15/38] chore: sync API types from infrastructure (#5434) This PR was automatically created to sync API types from the infrastructure repository. Changes were detected in the generated API code after syncing with the latest spec from infrastructure. Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> --- apps/cli-go/pkg/api/types.gen.go | 42 +++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index c3287bc286..840f1b82b9 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -3327,9 +3327,24 @@ type PgsodiumConfigResponse struct { // PostgresConfigResponse defines model for PostgresConfigResponse. type PostgresConfigResponse struct { // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` + CronLogStatement *bool `json:"cron.log_statement,omitempty"` + EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` + HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + + // LogAutovacuumMinDuration Default unit: ms + LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` + LogCheckpoints *bool `json:"log_checkpoints,omitempty"` + LogConnections *bool `json:"log_connections,omitempty"` + LogDisconnections *bool `json:"log_disconnections,omitempty"` + LogDuration *bool `json:"log_duration,omitempty"` + LogLockWaits *bool `json:"log_lock_waits,omitempty"` + LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` + LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` + + // LogStartupProgressInterval Default unit: ms + LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` + LogTempFiles *string `json:"log_temp_files,omitempty"` LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` @@ -4229,9 +4244,24 @@ type UpdatePgsodiumConfigBody struct { // UpdatePostgresConfigBody defines model for UpdatePostgresConfigBody. type UpdatePostgresConfigBody struct { // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` + CronLogStatement *bool `json:"cron.log_statement,omitempty"` + EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` + HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + + // LogAutovacuumMinDuration Default unit: ms + LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` + LogCheckpoints *bool `json:"log_checkpoints,omitempty"` + LogConnections *bool `json:"log_connections,omitempty"` + LogDisconnections *bool `json:"log_disconnections,omitempty"` + LogDuration *bool `json:"log_duration,omitempty"` + LogLockWaits *bool `json:"log_lock_waits,omitempty"` + LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` + LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` + + // LogStartupProgressInterval Default unit: ms + LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` + LogTempFiles *string `json:"log_temp_files,omitempty"` LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` From bdca0182874b9f7d9a66eaf17136a2920286fbd3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 10:26:38 +0200 Subject: [PATCH 16/38] chore(ci): update Dependabot Go module paths (#5435) ## What changed Updates the Go Dependabot directories from the old root-level module paths to the current Go module locations under `apps/cli-go`. ## Why The Dependabot Go updater is currently failing with `dependency_file_not_found` because `/` and `/pkg` no longer contain the relevant `go.mod` files after the CLI Go sources moved under `apps/cli-go`. --- .github/dependabot.yml | 4 ++-- AGENTS.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fb0ec4fcc9..f2f390ed8e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,8 +13,8 @@ updates: default-days: 7 - package-ecosystem: "gomod" directories: - - "/" - - "pkg" + - "/apps/cli-go" + - "/apps/cli-go/pkg" schedule: interval: "cron" cronjob: "0 0 * * *" diff --git a/AGENTS.md b/AGENTS.md index 650a18e60d..7207168209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ Use `nx show project --json` to discover available targets before running ## Pull Requests PR titles must follow conventional-commits format because the `Lint Pull Request` workflow runs `amannn/action-semantic-pull-request` against the title. Use `(): ` (e.g. `fix(cli): …`, `test(cli): …`, `feat(api): …`). A bare descriptive title like "Build TypeScript CLI as compiled Bun binaries" will fail the lint. When a PR is created (including by the Claude Code UI or someone else), check the title against this rule and update it if needed. +Avoid semantic-release-triggering types for non-release changes. For CI, docs, tests, tooling, agent instructions, and other repository-maintenance changes, do not use `fix`, `feat`, `perf`, or breaking-change markers just to satisfy the PR title linter. Prefer non-releasing conventional types such as `chore`, `docs`, `test`, or `ci` when the change should not produce a package release. Do not include a validation, test plan, or list of checks in PR descriptions. CI enforces validation for PRs, so PR descriptions should focus on what changed, why it changed, and any reviewer-relevant context that CI cannot infer. ## Refactoring Policy From fac40cfff81ea256b885a2341216af2a46e5fc3a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 10:30:43 +0200 Subject: [PATCH 17/38] ci(release): use app token for release tag pushes (#5432) ## What changed The reusable release workflow now mints a Supabase CLI repository GitHub App token for the publish job before any release refs are pushed. Checkout no longer persists the default Actions token, and release git pushes are configured to use the App token with both contents and workflows write permissions. ## Why The beta release job published npm packages successfully, then failed while pushing the version tag because GitHub rejected the default Actions token for a ref pointing at a commit that included workflow-file changes. Using the repository App token keeps release tag and prerelease note pushes on the same credential path already used for privileged release automation. --- .github/workflows/release-shared.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 4c1f6663d5..d381ccd8b3 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -186,10 +186,19 @@ jobs: contents: write id-token: write steps: + - name: Generate release repository token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - persist-credentials: true + persist-credentials: false - name: Setup uses: ./.github/actions/setup @@ -205,6 +214,14 @@ jobs: - name: Publish to npm run: pnpm exec bun apps/cli/scripts/publish.ts --tag "${NPM_TAG}" + - name: Configure git for release pushes + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh auth setup-git + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Push the version tag to origin as soon as npm has the bytes, before any # downstream step that can fail. Without this, a failure in the GH-release # step (or anything after) leaves origin with no tag for the version that @@ -216,8 +233,6 @@ jobs: run: | set -euo pipefail tag="v${VERSION}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git ls-remote --tags origin "refs/tags/${tag}" | grep -q .; then echo "Tag ${tag} already on origin; skipping push." else From cc95a1784741b442af347736c7d00b4213c42e95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:44:22 +0000 Subject: [PATCH 18/38] fix(deps): bump the go-minor group across 2 directories with 7 updates (#5437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 5 updates in the /apps/cli-go directory: | Package | From | To | | --- | --- | --- | | [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify) | `1.9.0` | `1.10.1` | | [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) | `0.44.1` | `0.46.2` | | [github.com/google/jsonschema-go](https://github.com/google/jsonschema-go) | `0.4.2` | `0.4.3` | | [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go) | `1.11.2` | `1.13.0` | | [golang.org/x/mod](https://github.com/golang/mod) | `0.35.0` | `0.36.0` | Bumps the go-minor group with 3 updates in the /apps/cli-go/pkg directory: [golang.org/x/mod](https://github.com/golang/mod), [google.golang.org/grpc](https://github.com/grpc/grpc-go) and [github.com/oapi-codegen/runtime](https://github.com/oapi-codegen/runtime). Updates `github.com/fsnotify/fsnotify` from 1.9.0 to 1.10.1
Release notes

Sourced from github.com/fsnotify/fsnotify's releases.

v1.10.1

Changes and fixes

  • inotify: don't remove sibling watches sharing a path prefix (#754)

  • inotify, windows: don't rename sibling watches sharing a path prefix (#755)

#754: fsnotify/fsnotify#754 #755: fsnotify/fsnotify#755

v1.10.0

This version of fsnotify needs Go 1.23.

Changes and fixes

  • inotify: improve initialization error message (#731)

  • inotify: send Rename event if recursive watch is renamed (#696)

  • inotify: avoid copying event buffers when reading names (#741)

  • kqueue: skip dangling symlinks (ENOENT) in watchDirectoryFiles, so a bad entry no longer aborts Watcher.Add for the whole directory (#748)

  • kqueue: drop watches directly in Close() to fix a file descriptor leak when recycling watchers (#740)

  • windows: fix nil pointer dereference in remWatch (#736)

  • windows: lock watch field updates against concurrent WatchList to fix a race introduced in v1.9.0 (#709, #749)

#696: fsnotify/fsnotify#696 #709: fsnotify/fsnotify#709 #731: fsnotify/fsnotify#731 #736: fsnotify/fsnotify#736 #740: fsnotify/fsnotify#740 #741: fsnotify/fsnotify#741 #748: fsnotify/fsnotify#748 #749: fsnotify/fsnotify#749

Changelog

Sourced from github.com/fsnotify/fsnotify's changelog.

1.10.1 2026-05-04

Changes and fixes

  • inotify: don't remove sibling watches sharing a path prefix (#754)

  • inotify, windows: don't rename sibling watches sharing a path prefix (#755)

#754: fsnotify/fsnotify#754 #755: fsnotify/fsnotify#755

1.10.0 2026-04-30

This version of fsnotify needs Go 1.23.

Changes and fixes

  • inotify: improve initialization error message (#731)

  • inotify: send Rename event if recursive watch is renamed (#696)

  • inotify: avoid copying event buffers when reading names (#741)

  • kqueue: skip dangling symlinks (ENOENT) in watchDirectoryFiles, so a bad entry no longer aborts Watcher.Add for the whole directory (#748)

  • kqueue: drop watches directly in Close() to fix a file descriptor leak when recycling watchers (#740)

  • windows: fix nil pointer dereference in remWatch (#736)

  • windows: lock watch field updates against concurrent WatchList to fix a race introduced in v1.9.0 (#709, #749)

#696: fsnotify/fsnotify#696 #709: fsnotify/fsnotify#709 #731: fsnotify/fsnotify#731 #736: fsnotify/fsnotify#736 #740: fsnotify/fsnotify#740 #741: fsnotify/fsnotify#741 #748: fsnotify/fsnotify#748 #749: fsnotify/fsnotify#749

Commits
  • 76b01a6 Release 1.10.1
  • fec150b Update changelog
  • 162b421 inotify, windows: don't rename sibling watches sharing a path prefix (#755)
  • 224257f inotify: don't remove sibling watches sharing a path prefix (#754)
  • e0c956c windows: document directory Write events and stabilize tests (#745)
  • 8d01d7b Release 1.10.0
  • 602284e Update changelog
  • 7f03e59 kqueue: skip ENOENT entries in watchDirectoryFiles (#748)
  • dab9dde windows: lock watch field updates against concurrent WatchList (#709) (#749)
  • eadf267 kqueue: drop watches directly in Close() instead of going through remove() (#...
  • Additional commits viewable in compare view

Updates `github.com/getsentry/sentry-go` from 0.44.1 to 0.46.2
Release notes

Sourced from github.com/getsentry/sentry-go's releases.

0.46.2

Bug Fixes 🐛

0.46.1

Bug Fixes 🐛

0.46.0

Breaking Changes 🛠

New Features ✨

  • Add internal_sdk_error client report on serialization fail by @​giortzisg in #1273
  • Add grpc integration support by @​ribice in #938
  • Re-enable Telemetry Processor by default. To disable the behavior use the DisableTelemetryBuffer flag by @​giortzisg in #1254
  • Simplify client DSN storage to internal/protocol.Dsn and make it safe to access by @​giortzisg in #1254

Internal Changes 🔧

Deps

  • Bump github.com/labstack/echo/v5 from 5.0.0 to 5.0.3 in /echo by @​dependabot in #1253
  • Bump github.com/labstack/echo/v5 from 5.0.0 to 5.0.3 in /crosstest by @​dependabot in #1272
  • Bump golangci-lint action from 2.1.1 to 2.11.4 by @​giortzisg in #1265
  • Bump go.opentelemetry.io/otel/sdk from 1.40.0 to 1.43.0 in /otel by @​dependabot in #1256
  • Bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp from 1.40.0 to 1.43.0 in /otel/otlp by @​dependabot in #1255

Other

0.45.1

Bug Fixes 🐛

0.45.0

... (truncated)

Changelog

Sourced from github.com/getsentry/sentry-go's changelog.

0.46.2

Bug Fixes 🐛

0.46.1

Bug Fixes 🐛

0.46.0

Breaking Changes 🛠

New Features ✨

  • Add internal_sdk_error client report on serialization fail by @​giortzisg in #1273
  • Add grpc integration support by @​ribice in #938
  • Re-enable Telemetry Processor by default. To disable the behavior use the DisableTelemetryBuffer flag by @​giortzisg in #1254
  • Simplify client DSN storage to internal/protocol.Dsn and make it safe to access by @​giortzisg in #1254

Internal Changes 🔧

Deps

  • Bump github.com/labstack/echo/v5 from 5.0.0 to 5.0.3 in /echo by @​dependabot in #1253
  • Bump github.com/labstack/echo/v5 from 5.0.0 to 5.0.3 in /crosstest by @​dependabot in #1272
  • Bump golangci-lint action from 2.1.1 to 2.11.4 by @​giortzisg in #1265
  • Bump go.opentelemetry.io/otel/sdk from 1.40.0 to 1.43.0 in /otel by @​dependabot in #1256
  • Bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp from 1.40.0 to 1.43.0 in /otel/otlp by @​dependabot in #1255

Other

0.45.1

Bug Fixes 🐛

... (truncated)

Commits

Updates `github.com/google/jsonschema-go` from 0.4.2 to 0.4.3
Release notes

Sourced from github.com/google/jsonschema-go's releases.

v0.4.3

What's Changed

Full Changelog: https://github.com/google/jsonschema-go/compare/v0.4.2...0.4.3

v0.4.3

What's Changed

Full Changelog: https://github.com/google/jsonschema-go/compare/v0.4.2...v0.4.3

Commits

Updates `github.com/posthog/posthog-go` from 1.11.2 to 1.13.0
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.13.0

Unreleased

1.12.6

Unreleased

1.12.5

Unreleased

1.12.4 - 2026-04-30

1.12.3 - 2026-04-21

1.12.2 - 2026-04-20

v1.12.1

1.12.1 - 2026-04-20

v1.12.0

1.12.0 - 2026-04-20

v1.11.3

1.11.3 - 2026-04-14

  • Full Changelog

  • Added locally_evaluated property to $feature_flag_called events, indicating whether the flag was evaluated locally or via the remote /flags endpoint.

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.13.0

Minor Changes

  • dec8ade: Add opt-in panic capture for request context middleware.
  • dec8ade: Add server-side request context helpers for net/http capture and exception events, plus EvaluateFlagsWithContext for using request-scoped distinct IDs during flag evaluation. Request-context flag evaluation does not generate personless IDs.

1.12.6

Patch Changes

  • 9289d53: Reject semver values with leading zeros in local flag evaluation. Per semver 2.0.0 §2, numeric identifiers must not include leading zeros — values like 1.07.3 are not valid semver and should not match targeting conditions. Both override values and flag values are now validated; invalid inputs surface an InconclusiveMatchError so the condition does not match.

1.12.5

Patch Changes

  • 6d243a6: Return ErrSDKDisabled from no-op clients when the project API key is missing, return ErrNoPersonalAPIKey before making requests for Personal API key dependent methods when no Personal API key is configured, and return ErrNoDistinctID from EvaluateFlags when distinct_id is missing.

New Features

  • EvaluateFlags: New method on Client that returns a FeatureFlagEvaluations snapshot for a user using a single /flags request. The snapshot powers any number of IsEnabled / GetFlag / GetFlagPayload checks, fires deduped $feature_flag_called events with full v4 metadata (id, version, reason, request_id), and can be attached to a Capture event via the new Capture.Flags field to populate $feature/<key> and $active_feature_flags without another network call.
  • Capture.Flags: New optional field on Capture that accepts a *FeatureFlagEvaluations snapshot. Takes precedence over SendFeatureFlags, avoids a hidden /flags request per event, and lets caller-supplied Properties override the auto-generated $feature/<key> values on conflict.

Internal

  • Refactored the $feature_flag_called dedup logic into a shared helper so the existing single-flag path and the new snapshot path use identical semantics against the same per-distinct_id LRU cache.
  • $feature_flag_called events from the snapshot path combine response-level errors (errors_while_computing_flags, quota_limited) with per-flag errors (flag_missing) comma-joined in $feature_flag_error, matching the granularity of the legacy single-flag path.

1.12.4 - 2026-04-30

1.12.3 - 2026-04-21

1.12.2 - 2026-04-20

1.12.1 - 2026-04-20

1.12.0 - 2026-04-20

1.11.3 - 2026-04-14

... (truncated)

Commits
  • 9dbb061 chore: release v1.13.0 [version bump] [skip ci]
  • dec8ade feat: add server-side request context (#201)
  • 89147f5 chore(ci): bump pinned posthog-sdk-test-harness SHA (#204)
  • 9978655 chore(ci): bump pinned PostHog/.github reusable workflow SHA (#203)
  • a99dc57 chore: release v1.12.6 [version bump] [skip ci]
  • 9289d53 fix: reject leading-zero semver values in local evaluation (#200)
  • 4caaa1e chore: pin github actions to commit shas (#202)
  • 22195ff chore: release v1.12.5 [version bump] [skip ci]
  • 6d243a6 fix: revert d2c4dd2 (#199)
  • d2c4dd2 chore: release v1.12.4 [version bump] [skip ci]
  • Additional commits viewable in compare view

Updates `golang.org/x/mod` from 0.35.0 to 0.36.0
Commits
  • 643da9b go.mod: update golang.org/x dependencies
  • ccc3cdf zip: include 'but content has correct sum' note in TestVCS
  • ab30318 zip: update zip hashes for new flate compression
  • See full diff in compare view

Updates `golang.org/x/mod` from 0.34.0 to 0.36.0
Commits
  • 643da9b go.mod: update golang.org/x dependencies
  • ccc3cdf zip: include 'but content has correct sum' note in TestVCS
  • ab30318 zip: update zip hashes for new flate compression
  • See full diff in compare view

Updates `google.golang.org/grpc` from 1.80.0 to 1.81.1
Release notes

Sourced from google.golang.org/grpc's releases.

Release 1.81.1

Security

  • xds/rbac: Fix a potential authorization bypass caused by incorrectly falling through URI/DNS SANs to Subject Distinguished Name (DN) when matching the authenticated principal name. With this fix, only the first non-empty identity source will be used, as per gRFC A41. (#9111)

Bug Fixes

  • otel: Segregate client and server RPC information used for metrics and traces, to avoid one overwriting the other. (#9081)

Release 1.81.0

Behavior Changes

  • balancer/rls: Switch gauge metrics to asynchronous emission (once per collection cycle) to reduce telemetry noise and align with other gRPC language implementations. (#8808)

Dependencies

  • Minimum supported Go version is now 1.25. (#8969)

Bug Fixes

  • xds: Use the leaf cluster's security config for the TLS handshake instead of the aggregate cluster's config. (#8956)
  • transport: Send a RST_STREAM when receiving an END_STREAM when the stream is not already half-closed. (#8832)
  • xds: Fix ADS resource name validation to prevent a panic. (#8970)

New Features

  • grpc/stats: Add support for custom labels in per-call metrics (gRFC A108). (#9008)
  • xds: Add support for Server Name Indication (SNI) and SAN validation (gRFC A101). Disabled by default. To enable, set GRPC_EXPERIMENTAL_XDS_SNI=true environment variable. (#9016)
  • xds: Add support to control which fields get propagated from ORCA backend metric reports to LRS load reports (gRFC A85). Disabled by default. To enable, set GRPC_EXPERIMENTAL_XDS_ORCA_LRS_PROPAGATION=true. (#9005)
  • xds: Add metrics to track xDS client connectivity and cached resource state (gRFC A78). (#8807)
  • stats/otel: Enhance grpc.subchannel.disconnections metric by adding disconnection reason to the grpc.disconnect_error label (gRFC A94). This provides granular insights into why subchannels are closing. (#8973)
  • mem: Add mem.Buffer.Slice() API to slice the buffer like a slice. (#8977)

Performance Improvements

  • alts: Pool read buffers to lower memory utilization when sockets are unreadable. (#8964)
  • transport: Pool HTTP/2 framer read buffers to reduce idle memory consumption. Currently limited to Linux for ALTS and non-encrypted transports (TCP, Unix). To disable, set GRPC_GO_EXPERIMENTAL_HTTP_FRAMER_READ_BUFFER_POOLING=false and report any issues. (#9032)
Commits

Updates `github.com/oapi-codegen/runtime` from 1.3.1 to 1.4.1
Release notes

Sourced from github.com/oapi-codegen/runtime's releases.

Bug fixes

This is a bug fix release.

Changes in v1.4.0, coupled with changes in v2.7.0 of oapi-codegen exposed some new problems. deepObject style marshaling behavior now supports encoding unicode. UTF-8 can't be directly included in parameters, so we need to % escape it.

Form binding now detects maps, which makes binding to a Nullable possible. We can't use generics around Nullable[T], so we handle maps generically, assuming they're a Nullable with its behavior assumptions.

🐛 Bug fixes

📦 Dependency updates

Sponsors

We would like to thank our sponsors for their support during this release.

Parameter handling improvements and fixes

This release fixes some missing edge cases in parameter binding and styling. We now handle all the permutations of style and explode, for the first time. Lots of tests have been added to catch regressions.

🚀 New features and improvements

  • Improve deepobject unmarshalling to support nullable.Nullable and encode.TextUnmarshaler (#45) @​j-waters
  • feat: support spaceDelimited and pipeDelimited query parameter binding (#117) @​mromaszewicz

🐛 Bug fixes

  • Fix form/explode=false incorrectly splitting primitive string values on commas (#119) @​f-kanari

... (truncated)

Commits
  • 2755f15 Fix form binding of Nullables (#133)
  • 17de1dd Percent-encode deepObject parameter wire output (#132)
  • d2b7c4c chore(deps): update oapi-codegen/actions action to v0.7.0
  • 6fd6c25 chore(deps): update github/codeql-action action to v4
  • 19040cc fix(deps): update module github.com/kataras/iris/v12 to v12.2.11
  • e05282e chore(deps): update release-drafter/release-drafter action to v7.2.0 (#122)
  • 247b459 fix(deps): update module github.com/labstack/echo/v4 to v4.15.1 (#105)
  • 1d38dfa fix(deps): update module github.com/labstack/echo/v5 to v5.1.0 (#120)
  • be9ed17 chore(deps): update release-drafter/release-drafter action to v7 (#113)
  • 77570f9 Fix form/explode=false incorrectly splitting primitive string values on comma...
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- apps/cli-go/go.mod | 12 ++++++------ apps/cli-go/go.sum | 24 ++++++++++++------------ apps/cli-go/pkg/go.mod | 14 +++++++------- apps/cli-go/pkg/go.sum | 28 ++++++++++++++-------------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index d208a593b3..7c9ee6dff7 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -19,8 +19,8 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.7.0 github.com/docker/go-units v0.5.0 - github.com/fsnotify/fsnotify v1.9.0 - github.com/getsentry/sentry-go v0.44.1 + github.com/fsnotify/fsnotify v1.10.1 + github.com/getsentry/sentry-go v0.46.2 github.com/go-errors/errors v1.5.1 github.com/go-git/go-git/v5 v5.19.1 github.com/go-playground/validator/v10 v10.30.2 @@ -29,7 +29,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v62 v62.0.0 github.com/google/go-querystring v1.2.0 - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/h2non/gock v1.2.0 github.com/jackc/pgconn v1.14.3 @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.1.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.11.2 + github.com/posthog/posthog-go v1.13.0 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -54,7 +54,7 @@ require ( github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 - golang.org/x/mod v0.35.0 + golang.org/x/mod v0.36.0 golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.43.0 @@ -334,7 +334,7 @@ require ( github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect - github.com/oapi-codegen/runtime v1.3.1 // indirect + github.com/oapi-codegen/runtime v1.4.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 7db471ece1..caa2f47bdb 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -337,8 +337,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -349,8 +349,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE= github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= +github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -527,8 +527,8 @@ github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzU github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= @@ -865,8 +865,8 @@ github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/ github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= -github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= -github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -941,8 +941,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= -github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= -github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/posthog/posthog-go v1.13.0 h1:+i+t6txCczJcGZj7ME2ry4sLhPYvq3q7RYuUZ0z6NpQ= +github.com/posthog/posthog-go v1.13.0/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -1287,8 +1287,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 0c2cc154fc..aa7e9d52a8 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -20,13 +20,13 @@ require ( github.com/jackc/pgx/v4 v4.18.3 github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/nullable v1.1.0 - github.com/oapi-codegen/runtime v1.3.1 + github.com/oapi-codegen/runtime v1.4.1 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.34.0 - google.golang.org/grpc v1.80.0 + golang.org/x/mod v0.36.0 + google.golang.org/grpc v1.81.1 ) require ( @@ -34,7 +34,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/ethereum/go-ethereum v1.17.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index dc8be0f4e4..12a49f10de 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -29,8 +29,8 @@ github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kR github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -132,8 +132,8 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= -github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -217,15 +217,15 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -290,8 +290,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From 2035377c6559bd958afcdb541f7a850c64b980c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:59:17 +0000 Subject: [PATCH 19/38] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 2 updates (#5436) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 2 updates: supabase/realtime and supabase/logflare. Updates `supabase/realtime` from v2.102.3 to v2.103.1 Updates `supabase/logflare` from 1.43.1 to 1.43.3 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- apps/cli-go/pkg/config/templates/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index a2f1f96111..748e2604a0 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,9 +11,9 @@ FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.102.3 AS realtime +FROM supabase/realtime:v2.103.1 AS realtime FROM supabase/storage-api:v1.60.4 AS storage -FROM supabase/logflare:1.43.1 AS logflare +FROM supabase/logflare:1.43.3 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra From 85dfeddf71f1c28a56bf418e6e4acfa7be1e11e2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 3 Jun 2026 10:23:28 +0100 Subject: [PATCH 20/38] feat(cli): port link and unlink commands to native TypeScript (#5426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Promotes `supabase link` and `supabase unlink` from Phase-0 Go-binary proxy wrappers to native Effect TypeScript in the legacy (stable-channel) shell. Behaviour follows the Go source (`apps/cli-go/internal/{link,unlink}`) verbatim — same flags, stdout/stderr text, `supabase/.temp/*` file writes, API routes, and exit codes. ### `supabase link` - Resolves the project ref `--project-ref` → `SUPABASE_PROJECT_ID` → TTY prompt, **skipping** the on-disk ref file (matching Go's empty-MemMapFs), and emits the cobra `required flag(s) "project-ref" not set` error on a non-TTY. - Checks remote project status: 404 tolerated (branch projects), `INACTIVE` → paused error + dashboard unpause suggestion, non-`ACTIVE_HEALTHY` → stderr warning; writes `postgres-version`. - Fetches api-keys (`reveal=true`), then best-effort links: `storage-migration`, `pooler-url` (placeholder-stripped, session-mode rewrite; removed on `--skip-pooler`), and `rest`/`gotrue`/`storage` tenant versions. - Writes `project-ref` + `linked-project.json` and fires `cli_project_linked` (org/project `groupIdentify`, groups keyed by org **id**). ### `supabase unlink` - Reads the project ref, removes `supabase/.temp/`, deletes the keyring database-password entry, and surfaces all failures together (Go `errors.Join` parity). - Uses a minimal runtime layer — `unlink` makes no API calls and requires no access token (the management-API layer eagerly requires one). ### Shared / supporting - New `legacy-temp-paths` and `legacy-tenant-versions` helpers in `legacy/shared/` (existing `legacy-project-ref` / `legacy-linked-project-cache` call sites refactored onto the former). - `resolveForLink` on `LegacyProjectRefResolver`; `deleteProjectCredential` on `LegacyCredentials`. - The legacy credentials layer now honours `SUPABASE_NO_KEYRING=1` (matching `next/` and the cli-e2e harness), preventing non-interactive Keychain hangs for keyring-touching commands. ### Reviewer notes — intentional divergences from Go - The cosmetic `WARNING: Local database version differs…` message is omitted (it needs `config.toml [db].major_version` with CLI defaults, not surfaced by the legacy shell); the `postgres-version` file is still written. - The four discarded Go config probes (`/config/database/postgres`, `/postgrest`, `/config/auth`, `/network-restrictions`) are omitted — they only populated in-process config that standalone `link` discards. - The `Finished …` lines render plain (Go's `utils.Aqua` cyan), matching the established legacy-port convention. Both `SIDE_EFFECTS.md` files and `docs/go-cli-porting-status.md` (rows flipped to `ported`) are updated. --- apps/cli-e2e/src/server/placeholder.ts | 27 + apps/cli-e2e/src/server/replay-server.ts | 33 +- .../src/tests/project-lifecycle.e2e.test.ts | 8 +- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/auth/legacy-credentials.layer.ts | 83 ++- .../legacy/auth/legacy-credentials.service.ts | 18 +- apps/cli/src/legacy/auth/legacy-errors.ts | 11 + .../legacy-platform-api.layer.unit.test.ts | 1 + .../src/legacy/commands/link/SIDE_EFFECTS.md | 118 ++-- .../src/legacy/commands/link/link.command.ts | 15 +- .../src/legacy/commands/link/link.e2e.test.ts | 24 + .../src/legacy/commands/link/link.errors.ts | 54 ++ .../src/legacy/commands/link/link.handler.ts | 307 ++++++++++- .../commands/link/link.integration.test.ts | 510 ++++++++++++++++++ .../legacy/commands/unlink/SIDE_EFFECTS.md | 66 +-- .../legacy/commands/unlink/unlink.command.ts | 26 +- .../legacy/commands/unlink/unlink.e2e.test.ts | 56 ++ .../legacy/commands/unlink/unlink.errors.ts | 18 + .../legacy/commands/unlink/unlink.handler.ts | 89 ++- .../unlink/unlink.integration.test.ts | 198 +++++++ .../config/legacy-project-ref.errors.ts | 12 + .../legacy/config/legacy-project-ref.layer.ts | 23 +- .../legacy-project-ref.layer.unit.test.ts | 67 +++ .../config/legacy-project-ref.service.ts | 22 +- .../src/legacy/shared/legacy-temp-paths.ts | 38 ++ .../shared/legacy-temp-paths.unit.test.ts | 37 ++ .../legacy/shared/legacy-tenant-versions.ts | 131 +++++ .../legacy-tenant-versions.unit.test.ts | 53 ++ .../legacy-linked-project-cache.layer.ts | 8 +- apps/cli/tests/helpers/legacy-mocks.ts | 38 ++ 30 files changed, 1976 insertions(+), 119 deletions(-) create mode 100644 apps/cli/src/legacy/commands/link/link.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/link/link.errors.ts create mode 100644 apps/cli/src/legacy/commands/link/link.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/unlink/unlink.errors.ts create mode 100644 apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-temp-paths.ts create mode 100644 apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-tenant-versions.ts create mode 100644 apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index d51e58365e..37dd711e00 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -61,6 +61,33 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } +// The project-ref placeholder (`__PROJECT_REF__`) is 15 characters, but the +// Management API schema constrains project refs to `^[a-z]{20}$` (minLength 20). +// The Go CLI doesn't validate response bodies, so it tolerates the short +// placeholder; the TS port decodes responses against the generated schema and +// rejects it (e.g. `link` calling `getProject`). When serving a recorded +// response we therefore substitute any field whose value is *exactly* the +// placeholder back to a valid 20-char ref. Substring occurrences such as +// `db.__PROJECT_REF__.supabase.red` are left untouched so tests that assert on +// the literal placeholder host keep matching. +const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; +const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; + +/** Extract the 20-char project ref from a `/v1/projects/` request path, + * falling back to a stable placeholder ref for refless endpoints (e.g. the + * project list). */ +export function projectRefFromPath(urlPath: string): string { + const match = urlPath.match(/\/projects\/([a-z]{20})(?:\/|$)/); + return match?.[1] ?? PLACEHOLDER_PROJECT_REF; +} + +/** Replace exact-match `__PROJECT_REF__` string values in a serialized JSON + * body with a schema-valid 20-char ref. Operates on the JSON string so only + * full quoted values are rewritten, never substrings. */ +export function restoreProjectRef(json: string, ref: string): string { + return json.replace(PROJECT_REF_VALUE, `"${ref}"`); +} + /** Normalize dynamic segments in a URL path to stable unnumbered placeholders. * Apply this to both the stored fixture path and the incoming request path so * both sides of a scenario comparison transform identically. */ diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 4194d31d88..7b0491e2bb 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -9,7 +9,13 @@ import type { FixtureStore, } from "./fixture-loader.ts"; import { loadFixtures, loadScenario } from "./fixture-loader.ts"; -import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; +import { + applyPlaceholders, + fixtureKey, + normalizeUrlPath, + projectRefFromPath, + restoreProjectRef, +} from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -470,10 +476,15 @@ async function proxyAndRecord( scenario, }); - return buildApiResponse(responseBody, upstreamStatus, { - ...responseHeaders, - "content-type": responseContentType, - }); + return buildApiResponse( + responseBody, + upstreamStatus, + { + ...responseHeaders, + "content-type": responseContentType, + }, + projectRefFromPath(pathname), + ); } /** Record a Docker interaction once its streamed body has fully drained. Errors @@ -750,11 +761,14 @@ function nextFixtureIndex(keyDir: string): number { return max + 1; } -/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). */ +/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). + * `projectRef` is the ref from the request path, used to restore short + * `__PROJECT_REF__` placeholders to schema-valid 20-char refs in JSON bodies. */ function buildApiResponse( body: unknown, status: number, headers: Record, + projectRef: string, ): Response { if (status === 204 || status === 304 || status === 205) { return new Response(null, { status, headers }); @@ -770,7 +784,10 @@ function buildApiResponse( if (body === null) { return new Response(null, { status, headers }); } - return Response.json(body, { status, headers }); + return new Response(restoreProjectRef(JSON.stringify(body), projectRef), { + status, + headers: { "content-type": "application/json", ...headers }, + }); } function serveFromFixtures( @@ -791,6 +808,7 @@ function serveFromFixtures( result.entry.response.body, result.entry.response.status, result.entry.response.headers, + projectRefFromPath(pathname), ); } @@ -882,6 +900,7 @@ function serveFromScenario( expected.response.body, expected.response.status, expected.response.headers, + projectRefFromPath(pathname), ); } diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index cc99e95d14..1fa0431dcb 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -158,10 +158,10 @@ describe("unlink", () => { }); // The success path (pre-populate project-ref → unlink succeeds) is omitted: the - // ts-legacy unlink handler is a Phase 0 proxy to the Go binary, which attempts - // a system keyring delete on exit. On Linux CI (no D-Bus session bus) the - // keyring call returns an unhandled error and the binary exits 1. The error path - // above already gives meaningful coverage for a proxy command. + // unlink handler deletes the database-password keyring entry on success. On + // Linux CI (no D-Bus session bus) the keyring call returns an unhandled error + // and the command exits 1. The not-linked error path above gives meaningful + // coverage; deeper success-path behaviour is covered by unlink.integration.test.ts. testParity(["unlink"]); }); diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index bf0453b113..44f2a099f0 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -263,8 +263,8 @@ Legend: | `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) | -| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | | `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | | `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 5cdeab8f9d..1cc8325d59 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -4,7 +4,7 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; -import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import { LegacyCredentialDeleteError, LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; const KEYRING_SERVICE = "Supabase CLI"; const LEGACY_KEYRING_ACCOUNT = "access-token"; @@ -160,6 +160,64 @@ function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean return false; } } +// Delete the project database-password entry (keyed by project ref), surfacing a +// real failure while ignoring the "nothing to delete" cases — mirroring Go's +// unlink, which ignores both `keyring.ErrNotFound` AND `credentials.ErrNotSupported` +// (backend unavailable) and only surfaces other errors (`unlink.go:36-40`). +// +// The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows +// default. On Windows, Go also writes a separate target-shaped credential; it is +// detected via `findCredentials` (a plain `getPassword` does not read the Go +// target reliably) and deleted through the `withTarget` entry. The `withTarget` +// entry is only constructed on Windows — on macOS its first argument is an +// invalid keychain domain and throws. +// +// Each entry is probed before `deleteCredential()`: on macOS deleting an absent +// entry blocks on a Keychain authorization prompt, and an absent read means +// there is nothing to delete (ignorable, per Go). Only a real delete failure is +// surfaced as `LegacyCredentialDeleteError`. +const deleteKeyringEntryStrict = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect => + Effect.gen(function* () { + let deleted = false; + + const plain = new module.Entry(KEYRING_SERVICE, account); + if (readEntryPassword(plain)) { + yield* Effect.try({ + try: () => { + plain.deleteCredential(); + }, + catch: (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + }); + deleted = true; + } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + const target = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + yield* Effect.try({ + try: () => { + target.deleteCredential(); + }, + catch: (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + }); + deleted = true; + } + + return deleted; + }); const makeLegacyCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -172,10 +230,16 @@ const makeLegacyCredentials = Effect.gen(function* () { const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase"); const fallbackPath = path.join(fallbackDir, "access-token"); + // `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches `next/`'s + // credentials layer and the cli-e2e harness, which sets it). Without this, any + // unconditional keyring access — e.g. `unlink`'s credential delete — blocks on a + // Keychain authorization prompt in non-interactive / CI contexts. + const noKeyring = process.env["SUPABASE_NO_KEYRING"] === "1"; const wsl = yield* detectWsl(fs); - const keyringModule = wsl - ? Option.none() - : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); + const keyringModule = + wsl || noKeyring + ? Option.none() + : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); const validate = (token: string): Effect.Effect => ACCESS_TOKEN_PATTERN.test(token) @@ -261,6 +325,17 @@ const makeLegacyCredentials = Effect.gen(function* () { } return anyDeleted; }), + + deleteProjectCredential: (projectRef: string) => + Effect.gen(function* () { + // WSL / no keyring module: treated as `ErrNotSupported` — a no-op success. + if (Option.isNone(keyringModule)) return false; + return yield* deleteKeyringEntryStrict( + keyringModule.value, + projectRef, + runtimeInfo.platform, + ); + }), }); }); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts index 911b0f07d5..b6198f7627 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.service.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -1,7 +1,10 @@ import type { Effect, Option, Redacted } from "effect"; import { Context } from "effect"; -import type { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import type { + LegacyCredentialDeleteError, + LegacyInvalidAccessTokenError, +} from "./legacy-errors.ts"; interface LegacyCredentialsShape { readonly getAccessToken: Effect.Effect< @@ -10,6 +13,19 @@ interface LegacyCredentialsShape { >; readonly saveAccessToken: (token: string) => Effect.Effect; readonly deleteAccessToken: Effect.Effect; + /** + * Deletes the stored database-password credential for a project from the OS + * keyring (keyring service `"Supabase CLI"`, account = the **project ref** — + * distinct from the access-token entry). Used by `supabase unlink`. + * + * Returns `true` when an entry was removed, `false` when none existed or the + * keyring is unavailable (WSL). Fails with `LegacyCredentialDeleteError` only + * for real keyring errors (e.g. permission denied), mirroring Go's unlink + * which ignores `ErrNotFound` / `ErrNotSupported` but surfaces everything else. + */ + readonly deleteProjectCredential: ( + projectRef: string, + ) => Effect.Effect; } export class LegacyCredentials extends Context.Service()( diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index ab5c936940..e07dd2d648 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -11,3 +11,14 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( )<{ readonly message: string; }> {} + +/** + * Raised by `deleteProjectCredential` when removing a stored database-password + * credential from the OS keyring fails for a reason other than "entry not + * found" (which is ignored). Mirrors `supabase unlink`'s behaviour of collecting + * non-`ErrNotFound` / non-`ErrNotSupported` keyring errors + * (`apps/cli-go/internal/unlink/unlink.go:36-40`). + */ +export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index dca599d975..ef36953252 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -36,6 +36,7 @@ function mockCredentials(token: Option.Option) { getAccessToken: Effect.succeed(Option.map(token, Redacted.make)), saveAccessToken: () => Effect.void, deleteAccessToken: Effect.succeed(false), + deleteProjectCredential: () => Effect.succeed(false), }); } diff --git a/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md index c7d2d33b6d..dab9fddf79 100644 --- a/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md @@ -1,72 +1,96 @@ # `supabase link` +Native TypeScript port of Go's `internal/link`. Writes flat state files under +`/supabase/.temp/` — it does **not** use the `next/` `.supabase/project.json` model. + ## 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 present, to load existing local config | -| `supabase/config.toml` | TOML | to load project configuration for the link operation | +| Path | Format | When | +| -------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML (`project_id`) | for ref resolution when `--project-ref` / `SUPABASE_PROJECT_ID` are unset (via `LegacyCliConfig`) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring is unavailable | + +> The on-disk `supabase/.temp/project-ref` file is **not** read for ref resolution — Go passes an +> empty in-memory FS to `ParseProjectRef` (`cmd/link.go:30`), so `link` never falls back to it. ## Files Written -| Path | Format | When | -| ------------------------ | ------ | ------------------------------------------------------- | -| `.supabase/project.json` | JSON | always on success; stores linked project ref and config | -| `supabase/config.toml` | TOML | when config differs from remote; updated with remote | +All under `/supabase/.temp/` (plain text, created with parent dirs as needed): + +| Path | When | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `project-ref` | always, after services link (mandatory — a write failure fails the command) | +| `postgres-version` | when the project status is 200 and `database.version` is non-empty | +| `storage-migration` | best-effort — storage config `migrationVersion` | +| `pooler-url` | best-effort — processed PRIMARY pooler connection string; **removed** when `--skip-pooler` | +| `rest-version` | best-effort — PostgREST swagger `info.version`, prefixed `v` | +| `gotrue-version` | best-effort — GoTrue `/auth/v1/health` version | +| `storage-version` | best-effort — Storage `/storage/v1/version` body, prefixed `v` | +| `linked-project.json` | best-effort — `{ref,name,organization_id,organization_slug}` (only for a resolvable, non-404 project) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | ------------------------------------------- | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{status, database.host, database.version}` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | none | `[{name, api_key}]` | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | `{max_connections, ...}` | +Management API (base `LegacyCliConfig.apiUrl`, `Authorization: Bearer `): -## Environment Variables +| Method | Path | When | +| ------ | ------------------------------------------- | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}` | always (404 tolerated for branch projects) | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | always | +| `GET` | `/v1/projects/{ref}/config/storage` | best-effort | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | best-effort (unless `--skip-pooler`) | +| `GET` | `/v1/projects` | only when prompting on a TTY | -| 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`) | -| `PROJECT_ID` | override `--project-ref` flag | no | -| `DB_PASSWORD` | database password (bound from `--password` flag) | no | +Tenant service gateway (`https://.`, `apikey: ` + `Authorization: Bearer `): -## Exit Codes +| Method | Path | When | +| ------ | --------------------- | ----------- | +| `GET` | `/rest/v1/` | best-effort | +| `GET` | `/auth/v1/health` | best-effort | +| `GET` | `/storage/v1/version` | best-effort | -| Code | Condition | -| ---- | ---------------------------------------------------------------- | -| `0` | success — project linked, prints "Finished supabase link." | -| `1` | authentication error — no valid token found | -| `1` | project not found — API returns 404 | -| `1` | project not active or unhealthy | -| `1` | missing `--project-ref` in non-TTY mode without `PROJECT_ID` env | -| `1` | network / connection failure | +> The discarded Go config probes (`/config/database/postgres`, `/postgrest`, `/config/auth`, +> `/network-restrictions`) are **omitted**: they only populated in-process config that standalone +> `link` discards, and they emit nothing observable. -## Output +## Environment Variables -### `--output-format text` (Go CLI compatible) +| Variable | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project-ref resolution (flag → env → TTY prompt) | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer auth (env → keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | bound to `--password`; **accepted but a no-op** for `link` (the DB-connection path that would consume it is dead code in Go) | -On success, prints a confirmation message: +## Exit Codes -``` -Finished supabase link. -``` +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------- | +| `0` | success — project linked (incl. the 404 branch path); prints `Finished supabase link.` | +| `1` | non-TTY with no `--project-ref` / `SUPABASE_PROJECT_ID` (`required flag(s) "project-ref" not set`) | +| `1` | malformed project ref | +| `1` | project paused (`INACTIVE`) | +| `1` | project status non-200/404 | +| `1` | api-keys auth failure / missing key | +| `1` | `project-ref` file write failure | -Interactive mode may prompt for project selection and database password. +> Best-effort service-link and telemetry errors never affect the exit code. + +## Output -### `--output-format json` +### `--output-format text` (Go-compatible) -Not applicable — link is an interactive command. +- stderr: `Selected project: ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. +- stdout: `Finished supabase link.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable — link is an interactive command. +Emits a structured success (`{ project_ref }`) and suppresses the human `Finished` line. Warnings still go to stderr. -## Notes +## Known divergence -- In non-TTY mode without `PROJECT_ID` env, `--project-ref` is required. -- The `--skip-pooler` flag uses a direct database connection instead of the connection pooler. -- The `--password` flag sets the database password, bound to `DB_PASSWORD` viper key. -- After linking, the project ref is written to `.supabase/project.json` (and legacy `.supabase/` state). -- The `PostRun` hook always prints "Finished supabase link." to stdout on success. +- The cosmetic `WARNING: Local database version differs from the linked project.` message (Go's + `linkPostgresVersion`) is **not** reproduced: it requires loading the local `config.toml` + `[db].major_version` with CLI defaults, which the legacy shell does not surface. The + `postgres-version` file (the meaningful side effect) is still written. +- The `Finished supabase link.` line is emitted as **plain text**; Go renders `supabase link` in + ANSI cyan via `utils.Aqua`. This matches the established legacy-port convention (color helpers are + rendered plain); ANSI-stripping scripts are unaffected. diff --git a/apps/cli/src/legacy/commands/link/link.command.ts b/apps/cli/src/legacy/commands/link/link.command.ts index 650e8b40bc..0353f4d79d 100644 --- a/apps/cli/src/legacy/commands/link/link.command.ts +++ b/apps/cli/src/legacy/commands/link/link.command.ts @@ -1,5 +1,9 @@ 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 { legacyLink } from "./link.handler.ts"; const config = { @@ -22,5 +26,14 @@ export type LegacyLinkFlags = CliCommand.Command.Config.Infer; export const legacyLinkCommand = Command.make("link", config).pipe( Command.withDescription("Link to a Supabase project."), Command.withShortDescription("Link to a Supabase project"), - Command.withHandler((flags) => legacyLink(flags)), + Command.withHandler((flags) => + legacyLink(flags).pipe( + // Only `--project-ref` is `markFlagTelemetrySafe` in Go (cmd/link.go:52). + // The boolean `--skip-pooler` is logged verbatim regardless; `--password` + // stays redacted. + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["link"])), ); diff --git a/apps/cli/src/legacy/commands/link/link.e2e.test.ts b/apps/cli/src/legacy/commands/link/link.e2e.test.ts new file mode 100644 index 0000000000..63e6e72c08 --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.e2e.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase link (legacy)", () => { + // Golden-path surface test: in a real subprocess with no TTY, no --project-ref + // and no SUPABASE_PROJECT_ID, ref resolution fails before any API call with the + // cobra-style required-flag error. Validates dispatch + ref-resolution wiring + // without needing a network fixture. + test( + "without a resolvable project ref exits 1 with the required-flag error", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["link"], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain(`required flag(s) "project-ref" not set`); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts new file mode 100644 index 0000000000..504965d46a --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -0,0 +1,54 @@ +import { Data } from "effect"; + +/** Transport failure while fetching `GET /v1/projects/{ref}`. */ +export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( + "LegacyLinkProjectStatusNetworkError", +)<{ + readonly message: string; +}> {} + +/** + * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's + * `"Unexpected error retrieving remote project status: " + body` (`link.go:252`). + */ +export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkProjectStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` + * with the dashboard unpause suggestion attached, mirroring Go's `errProjectPaused` + * + `utils.CmdSuggestion` (`link.go:256-258`). + */ +export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPausedError")<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** Transport failure while fetching `GET /v1/projects/{ref}/api-keys`. */ +export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( + "LegacyLinkApiKeysNetworkError", +)<{ + readonly message: string; +}> {} + +/** + * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's + * `ErrAuthToken` (`"Authorization failed for the access token and project ref pair"`) + * formatted with the response body (`client.go:78`). + */ +export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTokenError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * The api-keys response contained no usable anon/service-role key. Byte-matches + * Go's `errMissingKey` (`"Anon key not found."`, `client.go:15`). + */ +export class LegacyLinkMissingKeyError extends Data.TaggedError("LegacyLinkMissingKeyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index 65784f65b8..1a81005f7e 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -1,12 +1,303 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import type { ApiClient } from "@supabase/api/effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +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 { 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 { Output } from "../../../shared/output/output.service.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { + EventProjectLinked, + GroupOrganization, + GroupProject, +} from "../../../shared/telemetry/event-catalog.ts"; +import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; +import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { + legacyFetchGotrueVersion, + legacyFetchPostgrestVersion, + legacyFetchStorageVersion, +} from "../../shared/legacy-tenant-versions.ts"; +import { + LegacyLinkApiKeysNetworkError, + LegacyLinkAuthTokenError, + LegacyLinkMissingKeyError, + LegacyLinkProjectStatusError, + LegacyLinkProjectStatusNetworkError, + LegacyProjectPausedError, +} from "./link.errors.ts"; import type { LegacyLinkFlags } from "./link.command.ts"; +type LegacyLinkProject = Effect.Success>; + +// Classify a `getProject` failure: a 404 means the project is a branch (resolve +// to `None`, link continues); any other status surfaces the body; transport +// failures surface a network error. Mirrors `checkRemoteProjectStatus` +// (`link.go:240-253`). +const classifyProjectError = ( + cause: unknown, +): Effect.Effect< + Option.Option, + LegacyLinkProjectStatusError | LegacyLinkProjectStatusNetworkError +> => { + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const status = cause.response.status; + if (status === 404) { + return Effect.succeedNone; + } + return cause.response.text.pipe( + Effect.orElseSucceed(() => ""), + // Cap + strip control chars, matching `mapLegacyHttpError`'s defence-in-depth + // so an oversized / control-char body can't bloat JSON output or inject ANSI. + Effect.map(sanitizeLegacyErrorBody), + Effect.flatMap((body) => + Effect.fail( + new LegacyLinkProjectStatusError({ + status, + body, + message: `Unexpected error retrieving remote project status: ${body}`, + }), + ), + ), + ); + } + return Effect.fail( + new LegacyLinkProjectStatusNetworkError({ + message: `failed to retrieve remote project status: ${String(cause)}`, + }), + ); +}; + +interface ApiKeyEntry { + readonly api_key?: string | null; + readonly type?: string | null; + readonly name: string; + readonly secret_jwt_template?: Record | null; +} + +type WriteTempFile = (filePath: string, content: string) => Effect.Effect; + +// Mirrors `tenant.NewApiKey` (`apps/cli-go/internal/utils/tenant/client.go:28-57`): +// publishable -> anon, secret w/ role=service_role -> service_role, else legacy +// name-based fallback (`anon` / `service_role`). +function extractServiceKeys(keys: ReadonlyArray): { + anon: string; + serviceRole: string; +} { + let anon = ""; + let serviceRole = ""; + for (const key of keys) { + const value = key.api_key; + if (value === undefined || value === null) continue; + if (key.type === "publishable") { + anon = value; + continue; + } + if (key.type === "secret") { + const role = key.secret_jwt_template?.["role"]; + if (typeof role === "string" && role.toLowerCase() === "service_role") { + serviceRole = value; + } + continue; + } + if (key.name === "anon" && anon.length === 0) { + anon = value; + } else if (key.name === "service_role" && serviceRole.length === 0) { + serviceRole = value; + } + } + return { anon, serviceRole }; +} + +const mapApiKeysError = mapLegacyHttpError({ + networkError: LegacyLinkApiKeysNetworkError, + statusError: LegacyLinkAuthTokenError, + networkMessage: (cause) => `failed to get api keys: ${cause}`, + statusMessage: (_status, body) => + `Authorization failed for the access token and project ref pair: ${body}`, +}); + export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["link"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - if (flags.skipPooler) args.push("--skip-pooler"); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const analytics = yield* Analytics; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const ref = yield* resolver.resolveForLink(flags.projectRef); + const paths = legacyTempPaths(path, cliConfig.workdir); + + const writeTempFile: WriteTempFile = (filePath, content) => + fs + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe(Effect.andThen(() => fs.writeFileString(filePath, content))); + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): persist the + // linked-project cache and telemetry state whether the link succeeds or fails. + // `link` itself writes `linked-project.json` on success (below), so `cache` + // only fires for the failure / 404 paths. + yield* Effect.gen(function* () { + // 1. Check remote project status (404 tolerated for branch projects). + const project = yield* api.v1 + .getProject({ ref }) + .pipe(Effect.asSome, Effect.catch(classifyProjectError)); + + if (Option.isSome(project)) { + const status = project.value.status; + if (status === "INACTIVE") { + return yield* Effect.fail( + new LegacyProjectPausedError({ + message: "project is paused", + suggestion: `An admin must unpause it from the Supabase dashboard at ${legacyDashboardUrl( + cliConfig.profile, + )}/project/${ref}`, + }), + ); + } + if (status !== "ACTIVE_HEALTHY") { + yield* output.raw( + `WARNING: Project status is ${status} instead of Active Healthy. Some operations might fail.\n`, + "stderr", + ); + } + // Update postgres image version to match the remote project (link.go:269). + const version = project.value.database.version; + if (version.length > 0) { + yield* writeTempFile(paths.postgresVersion, version); + } + } + + // 2. Resolve service keys (auth check). + const keys = yield* api.v1 + .getProjectApiKeys({ ref, reveal: true }) + .pipe(Effect.catch(mapApiKeysError)); + const { anon, serviceRole } = extractServiceKeys(keys); + if (anon.length === 0 && serviceRole.length === 0) { + return yield* Effect.fail(new LegacyLinkMissingKeyError({ message: "Anon key not found." })); + } + + // 3. Link services — best-effort. Every error is swallowed so a single + // unreachable service never fails the link (link.go:91-100). + yield* linkStorageMigration(api, ref, paths.storageMigration, writeTempFile); + yield* linkPooler({ + api, + ref, + skipPooler: flags.skipPooler, + fs, + poolerUrlPath: paths.poolerUrl, + writeTempFile, + }); + const tenantOpts = { + ref, + projectHost: cliConfig.projectHost, + serviceKey: serviceRole, + userAgent: cliConfig.userAgent, + }; + yield* legacyFetchPostgrestVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.restVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + yield* legacyFetchGotrueVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.gotrueVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + yield* legacyFetchStorageVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.storageVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + + // 4. Save project ref (mandatory — a write failure fails the command). + yield* writeTempFile(paths.projectRef, ref); + + // 5. Telemetry + linked-project cache (only for resolvable projects, i.e. + // not the 404 branch path). `link.go:40-67`. + if (Option.isSome(project)) { + const p = project.value; + // SaveLinkedProject — best-effort (debug-logged in Go, never fatal). + yield* writeTempFile( + paths.linkedProjectCache, + JSON.stringify({ + ref: p.ref, + name: p.name, + organization_id: p.organization_id, + organization_slug: p.organization_slug, + }), + ).pipe(Effect.ignore); + + const groups = { organization: p.organization_id, project: p.ref } as const; + if (p.organization_id.length > 0) { + yield* analytics.groupIdentify(GroupOrganization, p.organization_id, { + organization_slug: p.organization_slug, + }); + } + if (p.ref.length > 0) { + yield* analytics.groupIdentify(GroupProject, p.ref, { + name: p.name, + organization_slug: p.organization_slug, + }); + } + yield* analytics.capture(EventProjectLinked, {}).pipe(withAnalyticsContext({ groups })); + } + + // 6. PostRun: `Finished supabase link.` to stdout (text), structured success + // otherwise. + if (output.format === "text") { + yield* output.raw("Finished supabase link.\n"); + } else { + yield* output.success("", { project_ref: ref }); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); + +const linkStorageMigration = ( + api: ApiClient, + ref: string, + storageMigrationPath: string, + writeTempFile: WriteTempFile, +) => + api.v1.getStorageConfig({ ref }).pipe( + Effect.flatMap((config) => writeTempFile(storageMigrationPath, config.migrationVersion)), + Effect.ignore, + ); + +const linkPooler = (opts: { + api: ApiClient; + ref: string; + skipPooler: boolean; + fs: FileSystem.FileSystem; + poolerUrlPath: string; + writeTempFile: WriteTempFile; +}) => + Effect.gen(function* () { + if (opts.skipPooler) { + // Use direct connection: drop any cached pooler URL (link.go:81-84). + yield* opts.fs.remove(opts.poolerUrlPath, { recursive: true }).pipe(Effect.ignore); + return; + } + const configs = yield* opts.api.v1.getPoolerConfig({ ref: opts.ref }); + const primary = configs.find((c) => c.database_type === "PRIMARY"); + if (primary === undefined) return; + // Strip the [YOUR-PASSWORD] placeholder; force session mode 5432 unless the + // pooler already reports session mode (link.go:221-229). + let connectionString = primary.connection_string.replaceAll(":[YOUR-PASSWORD]", ""); + if (primary.pool_mode !== "session") { + connectionString = connectionString.replaceAll(":6543/", ":5432/"); + } + yield* opts.writeTempFile(opts.poolerUrlPath, connectionString); + }).pipe(Effect.ignore); diff --git a/apps/cli/src/legacy/commands/link/link.integration.test.ts b/apps/cli/src/legacy/commands/link/link.integration.test.ts new file mode 100644 index 0000000000..870adb758c --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.integration.test.ts @@ -0,0 +1,510 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + legacyStatusCodeFailure, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyLink } from "./link.handler.ts"; +import type { LegacyLinkFlags } from "./link.command.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const HEALTHY_PROJECT = { + id: LEGACY_VALID_REF, + ref: LEGACY_VALID_REF, + name: "My Project", + organization_id: "org_123", + organization_slug: "acme", + status: "ACTIVE_HEALTHY", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + database: { + host: "db.example.co", + version: "15.1.0.117", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const SERVICE_KEYS = [ + { + name: "service_role", + api_key: "service-role-key", + type: "secret", + secret_jwt_template: { role: "service_role" }, + }, + { name: "anon", api_key: "anon-key", type: "publishable" }, +]; + +const POOLER_PRIMARY = [ + { + identifier: "primary", + database_type: "PRIMARY", + db_user: "postgres", + db_host: "pooler.example.co", + db_port: 6543, + db_name: "postgres", + connection_string: "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.co:6543/postgres", + connectionString: "", + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, +]; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +interface V1StubResult { + readonly ok?: unknown; + readonly fail?: unknown; +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + project?: V1StubResult; + apiKeys?: V1StubResult; + storageConfig?: V1StubResult; + poolerConfig?: V1StubResult; + tenant?: "ok" | "fail"; + restVersion?: string; + gotrueVersion?: string; + storageVersion?: string; +} + +const tempRoot = useLegacyTempWorkdir("supabase-link-int-"); + +function stub(result: V1StubResult | undefined, defaultOk: unknown) { + if (result?.fail !== undefined) return () => Effect.fail(result.fail); + return () => Effect.succeed(result?.ok ?? defaultOk); +} + +function tenantHttpLayer(opts: SetupOpts): Layer.Layer { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + if (opts.tenant === "fail") { + return yield* Effect.fail(legacyTransportFailure(request)); + } + const url = request.url; + if (url.includes("/rest/v1/")) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ info: { version: opts.restVersion ?? "11.1.0" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url.includes("/auth/v1/health")) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version: opts.gotrueVersion ?? "v2.74.2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url.includes("/storage/v1/version")) { + return HttpClientResponse.fromWeb( + request, + new Response(opts.storageVersion ?? "1.28.0", { status: 200 }), + ); + } + return HttpClientResponse.fromWeb(request, new Response("", { status: 404 })); + }), + ), + ); +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: stub(opts.project, HEALTHY_PROJECT), + getProjectApiKeys: stub(opts.apiKeys, SERVICE_KEYS), + getStorageConfig: stub(opts.storageConfig, { migrationVersion: "2026-01-01-000000" }), + getPoolerConfig: stub(opts.poolerConfig, POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, + cliConfig, + analytics, + telemetry: telemetry.layer, + linkedProjectCache: linkedCache.layer, + }); + return { layer, out, analytics, telemetry, linkedCache, apiMock, workdir: tempRoot.current }; +} + +const flags = (overrides: Partial = {}): LegacyLinkFlags => ({ + projectRef: Option.some(LEGACY_VALID_REF), + password: Option.none(), + skipPooler: false, + ...overrides, +}); + +function tempFile(workdir: string, name: string): string { + return join(workdir, "supabase", ".temp", name); +} + +function readTemp(workdir: string, name: string): string { + return readFileSync(tempFile(workdir, name), "utf8"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy link integration", () => { + it.live("links a project, writing the project-ref and version files", () => { + const { layer, out, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(readTemp(workdir, "postgres-version")).toBe("15.1.0.117"); + expect(readTemp(workdir, "storage-migration")).toBe("2026-01-01-000000"); + expect(readTemp(workdir, "rest-version")).toBe("v11.1.0"); + expect(readTemp(workdir, "gotrue-version")).toBe("v2.74.2"); + expect(readTemp(workdir, "storage-version")).toBe("v1.28.0"); + // [YOUR-PASSWORD] stripped + transaction-mode port rewritten to 5432. + expect(readTemp(workdir, "pooler-url")).toBe( + "postgresql://postgres.ref@pooler.example.co:5432/postgres", + ); + expect(out.stdoutText).toContain("Finished supabase link."); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project.json with ref/name/org metadata", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const linked = JSON.parse(readTemp(workdir, "linked-project.json")); + expect(linked).toEqual({ + ref: LEGACY_VALID_REF, + name: "My Project", + organization_id: "org_123", + organization_slug: "acme", + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits cli_project_linked + org/project groupIdentify keyed by org id", () => { + const { layer, analytics } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(analytics.captured.map((c) => c.event)).toContain("cli_project_linked"); + expect(analytics.groupIdentified).toEqual([ + { + groupType: "organization", + groupKey: "org_123", + properties: { organization_slug: "acme" }, + }, + { + groupType: "project", + groupKey: LEGACY_VALID_REF, + properties: { name: "My Project", organization_slug: "acme" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the ref from SUPABASE_PROJECT_ID when no flag is given", () => { + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: () => Effect.succeed(HEALTHY_PROJECT), + getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), + getStorageConfig: () => Effect.succeed({ migrationVersion: "m" }), + getPoolerConfig: () => Effect.succeed(POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.some(LEGACY_VALID_REF), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, + cliConfig, + }); + return Effect.gen(function* () { + yield* legacyLink(flags({ projectRef: Option.none() })); + expect(readTemp(tempRoot.current, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails in non-TTY with no --project-ref and no PROJECT_ID", () => { + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ v1: {} }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, + cliConfig, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.none() }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectRefRequiredError"); + expect(json).toContain(`required flag(s) \\"project-ref\\" not set`); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyInvalidProjectRefError for a malformed ref", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags({ projectRef: 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("tolerates a 404 project (branch linking): writes project-ref, skips telemetry", () => { + const { layer, workdir, analytics } = setup({ + project: { fail: legacyStatusCodeFailure(404) }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + // No postgres-version / linked-project.json and no telemetry for a 404. + expect(existsSync(tempFile(workdir, "postgres-version"))).toBe(false); + expect(existsSync(tempFile(workdir, "linked-project.json"))).toBe(false); + expect(analytics.captured.map((c) => c.event)).not.toContain("cli_project_linked"); + expect(analytics.groupIdentified).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with project-paused error + dashboard suggestion when INACTIVE", () => { + const { layer } = setup({ + project: { ok: { ...HEALTHY_PROJECT, status: "INACTIVE" } }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectPausedError"); + expect(json).toContain("project is paused"); + expect(json).toContain( + `An admin must unpause it from the Supabase dashboard at https://supabase.com/dashboard/project/${LEGACY_VALID_REF}`, + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("warns to stderr when status is not ACTIVE_HEALTHY but still links", () => { + const { layer, out, workdir } = setup({ + project: { ok: { ...HEALTHY_PROJECT, status: "COMING_UP" } }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(out.stderrText).toContain( + "WARNING: Project status is COMING_UP instead of Active Healthy. Some operations might fail.", + ); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyLinkProjectStatusError on an unexpected status", () => { + const { layer } = setup({ project: { fail: legacyStatusCodeFailure(500) } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkProjectStatusError"); + expect(json).toContain("Unexpected error retrieving remote project status"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with auth error when api-keys returns non-200", () => { + const { layer } = setup({ apiKeys: { fail: legacyStatusCodeFailure(401) } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkAuthTokenError"); + expect(json).toContain("Authorization failed for the access token and project ref pair"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with missing-key error when api-keys are empty", () => { + const { layer } = setup({ apiKeys: { ok: [] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkMissingKeyError"); + expect(json).toContain("Anon key not found."); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves keys by legacy name when no type field is present", () => { + // Untyped keys exercise the `name`-based fallback in extractServiceKeys. + const { layer, out, workdir } = setup({ + apiKeys: { + ok: [ + { name: "anon", api_key: "anon-key" }, + { name: "service_role", api_key: "service-role-key" }, + ], + }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(out.stdoutText).toContain("Finished supabase link."); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with missing-key error when the only secret key is not service_role", () => { + // A `secret` key whose JWT role is not `service_role` is skipped, leaving no + // usable key — exercises the secret-branch `continue` + missing-key path. + const { layer } = setup({ + apiKeys: { + ok: [ + { + name: "other", + api_key: "other-key", + type: "secret", + secret_jwt_template: { role: "authenticated" }, + }, + ], + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLinkMissingKeyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores best-effort service errors without failing the link", () => { + const { layer, out, workdir } = setup({ + storageConfig: { fail: legacyStatusCodeFailure(500) }, + poolerConfig: { fail: legacyStatusCodeFailure(503) }, + tenant: "fail", + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + // Link still succeeds and writes the project-ref. + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(out.stdoutText).toContain("Finished supabase link."); + // The best-effort files are absent because their services errored. + expect(existsSync(tempFile(workdir, "storage-migration"))).toBe(false); + expect(existsSync(tempFile(workdir, "rest-version"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("removes pooler-url and skips the pooler fetch when --skip-pooler is set", () => { + const { layer, workdir, apiMock } = setup(); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + writeFileSync(tempFile(workdir, "pooler-url"), "stale-pooler-url"); + return Effect.gen(function* () { + yield* legacyLink(flags({ skipPooler: true })); + expect(existsSync(tempFile(workdir, "pooler-url"))).toBe(false); + expect(apiMock.requests.map((r) => r.method)).not.toContain("getPoolerConfig"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when writing the project-ref file errors", () => { + // Make `/supabase` a file so creating supabase/.temp fails for every + // temp write. The project status carries no version, so the first mandatory + // write to hit the broken path is project-ref (mirrors Go's read-only FS test). + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: () => + Effect.succeed({ + ...HEALTHY_PROJECT, + database: { ...HEALTHY_PROJECT.database, version: "" }, + }), + getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), + getStorageConfig: () => Effect.succeed({ migrationVersion: "m" }), + getPoolerConfig: () => Effect.succeed(POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({ tenant: "fail" }) }, + cliConfig, + }); + writeFileSync(join(tempRoot.current, "supabase"), "not-a-dir"); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(existsSync(tempFile(tempRoot.current, "project-ref"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and runs the linked-project cache via ensuring", () => { + const { layer, telemetry, linkedCache } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(telemetry.flushed).toBe(true); + expect(linkedCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("json output: emits a structured success and suppresses the Finished line", () => { + const { layer, out, workdir } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + expect(out.stdoutText).not.toContain("Finished supabase link."); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("stream-json output: emits a structured success", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md index 293ae28c13..11d9e23724 100644 --- a/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md @@ -1,55 +1,57 @@ # `supabase unlink` +Native TypeScript port of Go's `internal/unlink`. Operates entirely on local state under +`/supabase/.temp/` and the OS keyring — no API calls. + ## Files Read -| Path | Format | When | -| ------------------------ | ------ | ---------------------------------------------- | -| `.supabase/project.json` | JSON | to retrieve stored project ref before deletion | +| Path | Format | When | +| ---------------------------- | ---------- | -------------------------------------- | +| `supabase/.temp/project-ref` | plain text | always, to find the linked project ref | + +The ref bytes are read **without trimming** — `link` writes the ref with no trailing newline, so the +value round-trips exactly and is reused verbatim for both the stderr message and the keyring key. + +## Files Written / Deleted -## Files Written +| Path | Action | When | +| ----------------- | ------------------- | ------------------------------ | +| `supabase/.temp/` | removed recursively | always (after reading the ref) | -| Path | Format | When | -| ------------------------ | ------ | ------------------ | -| `.supabase/project.json` | — | deleted on success | +Also deletes the stored **database-password** credential from the OS keyring (service `"Supabase CLI"`, +account = the **project ref**). A missing entry is ignored; the access-token credential is left untouched. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +None. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | not consumed by unlink | no | +None beyond `--workdir` / `SUPABASE_WORKDIR` resolution. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------- | -| `0` | success — project unlinked | -| `0` | project unlinked without stored database credentials (keyring empty) | -| `1` | not linked — no `.supabase/project.json` found (`ErrNotLinked`) | -| `1` | permission denied removing the project state file | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------- | +| `0` | success — project unlinked; prints `Finished supabase unlink.` | +| `1` | not linked — `supabase/.temp/project-ref` absent (`Cannot find project ref. Have you run supabase link?`) | +| `1` | project-ref read error | +| `1` | temp-dir removal error | +| `1` | keyring delete error other than not-found (e.g. permission denied) | ## Output -### `--output-format text` (Go CLI compatible) - -No output on success (exits 0 silently). Error messages go to stderr. - -### `--output-format json` +### `--output-format text` (Go-compatible) -Not applicable — unlink produces no JSON output. +- stderr: `Unlinking project: ` +- stdout: `Finished supabase unlink.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable — unlink produces no structured output. +Emits a structured success (`{ project_ref }`) and suppresses the human `Finished` line. -## Notes +## Known divergence -- Removes the project ref from `.supabase/project.json` (and the legacy `supabase/.temp/project-ref` path). -- Also removes any stored database password for the project from the OS keyring. -- If the keyring entry does not exist (`ErrNotFound`), the command still succeeds. -- No API calls are made; this is a purely local operation. +The `Finished supabase unlink.` line is emitted as **plain text**; Go renders `supabase unlink` in +ANSI cyan via `utils.Aqua`. This matches the established legacy-port convention (color helpers are +rendered plain); ANSI-stripping scripts are unaffected. diff --git a/apps/cli/src/legacy/commands/unlink/unlink.command.ts b/apps/cli/src/legacy/commands/unlink/unlink.command.ts index 0dd6fa5556..eaaf1737e0 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.command.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.command.ts @@ -1,8 +1,32 @@ +import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyUnlink } from "./unlink.handler.ts"; +// `unlink` makes no Management API calls (Go's unlink needs no access token), so it +// deliberately avoids `legacyManagementApiRuntimeLayer` — that layer eagerly resolves +// an access token and would fail with "Access token not provided" for a token-less +// `unlink`. It provides only the services the handler + instrumentation consume. +// `legacyCliConfigLayer` is provided to credentials AND exposed at the top level +// (Layer.provide does not share to siblings inside a merge — legacy CLAUDE.md item 5). +const legacyUnlinkRuntimeLayer = Layer.mergeAll( + legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)), + legacyCliConfigLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["unlink"]), +); + export const legacyUnlinkCommand = Command.make("unlink").pipe( Command.withDescription("Unlink a Supabase project."), Command.withShortDescription("Unlink a Supabase project"), - Command.withHandler(() => legacyUnlink()), + Command.withHandler(() => + legacyUnlink().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyUnlinkRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts new file mode 100644 index 0000000000..f7406d8b41 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts @@ -0,0 +1,56 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_PROJECT_REF = "abcdefghijklmnopqrst"; + +describe("supabase unlink (legacy)", () => { + // Golden path: with a seeded `supabase/.temp/project-ref`, a real subprocess + // removes the temp dir and prints the Finished line. No network is involved. + test( + "removes supabase/.temp and prints Finished when linked", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "sb-unlink-e2e-")); + try { + mkdirSync(join(projectDir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(projectDir, "supabase", ".temp", "project-ref"), TEST_PROJECT_REF); + + const { exitCode, stdout, stderr } = await runSupabase(["unlink"], { + entrypoint: "legacy", + cwd: projectDir, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Finished supabase unlink."); + expect(stderr).toContain(`Unlinking project: ${TEST_PROJECT_REF}`); + expect(existsSync(join(projectDir, "supabase", ".temp"))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); + + // The not-linked path exits non-zero with Go's `ErrNotLinked` message. + test( + "without a linked project exits 1 with the not-linked message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "sb-unlink-e2e-")); + try { + const { exitCode, stdout, stderr } = await runSupabase(["unlink"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts new file mode 100644 index 0000000000..8c1e9155f1 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -0,0 +1,18 @@ +import { Data } from "effect"; + +/** + * Reading `supabase/.temp/project-ref` failed for a reason other than the file + * being absent (which maps to `LegacyProjectNotLinkedError`). Byte-matches Go's + * `"failed to load project ref: " + err` (`apps/cli-go/internal/unlink/unlink.go:19`). + */ +export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefReadError")<{ + readonly message: string; +}> {} + +/** + * Removing the `supabase/.temp` directory failed. Byte-matches Go's + * `"failed to remove temp directory: " + err` (`unlink.go:32`). + */ +export class LegacyUnlinkTempRemovalError extends Data.TaggedError("LegacyUnlinkTempRemovalError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts index 0ac063ede0..d49cbabe11 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts @@ -1,7 +1,88 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Path, Result } from "effect"; + +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { LegacyCredentialDeleteError } from "../../auth/legacy-errors.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyProjectNotLinkedError } from "../../config/legacy-project-ref.errors.ts"; +import { PROJECT_NOT_LINKED_MESSAGE } from "../../config/legacy-project-ref.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { LegacyUnlinkRefReadError, LegacyUnlinkTempRemovalError } from "./unlink.errors.ts"; export const legacyUnlink = Effect.fn("legacy.unlink")(function* () { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["unlink"]); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const paths = legacyTempPaths(path, cliConfig.workdir); + + yield* Effect.gen(function* () { + // 1. Load the linked project ref. An absent file is `ErrNotLinked`; any other + // read failure surfaces verbatim (unlink.go:16-19). + const exists = yield* fs.exists(paths.projectRef).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* Effect.fail( + new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), + ); + } + // Go reads the raw bytes without trimming — `link` writes the ref with no + // trailing newline, so the value round-trips exactly (used for both the + // stderr message and the keyring key). + const projectRef = yield* fs.readFileString(paths.projectRef).pipe( + Effect.mapError( + (cause) => + new LegacyUnlinkRefReadError({ + message: `failed to load project ref: ${String(cause)}`, + }), + ), + ); + + yield* output.raw(`Unlinking project: ${projectRef}\n`, "stderr"); + + // 2. Best-effort: remove the temp dir and delete the stored db-password + // credential. Both are attempted; non-ignored errors are joined (unlink.go:29-41). + const collected: Array = []; + + const removed = yield* fs.remove(paths.tempDir, { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new LegacyUnlinkTempRemovalError({ + message: `failed to remove temp directory: ${String(cause)}`, + }), + ), + Effect.result, + ); + if (Result.isFailure(removed)) collected.push(removed.failure); + + const deleted = yield* credentials.deleteProjectCredential(projectRef).pipe(Effect.result); + if (Result.isFailure(deleted)) collected.push(deleted.failure); + + const [first, ...rest] = collected; + if (first !== undefined) { + // Mirror Go's `errors.Join(allErrors...)` (unlink.go:41): surface every + // collected message, not just the first. Keep the leading failure's tag + // (temp removal precedes the credential delete, matching Go's order). + if (rest.length === 0) { + return yield* Effect.fail(first); + } + const message = collected.map((e) => e.message).join("\n"); + return yield* Effect.fail( + first._tag === "LegacyUnlinkTempRemovalError" + ? new LegacyUnlinkTempRemovalError({ message }) + : new LegacyCredentialDeleteError({ message }), + ); + } + + // 3. PostRun: `Finished supabase unlink.` to stdout (text), structured success + // otherwise. + if (output.format === "text") { + yield* output.raw("Finished supabase unlink.\n"); + } else { + yield* output.success("", { project_ref: projectRef }); + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts new file mode 100644 index 0000000000..5841e78098 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { badArgument } from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyCredentialsTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyUnlink } from "./unlink.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-unlink-int-"); + +const noopHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HttpClient.execute in unlink test")), +); + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + deleteFails?: boolean; + removeFails?: boolean; +} + +// Wraps the real Bun FileSystem but forces `remove` to fail, so the +// temp-dir-removal error branch can be exercised deterministically (cross-platform, +// independent of filesystem permissions). +const failingRemoveFsLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + remove: () => + Effect.fail( + badArgument({ + module: "FileSystem", + method: "remove", + description: "permission denied", + }), + ), + }); + }), +).pipe(Layer.provide(BunServices.layer)); + +function seedProjectRef(workdir: string, ref: string) { + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(workdir, "supabase", ".temp", "project-ref"), ref); +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ deleteFails: opts.deleteFails }); + const apiMock = mockLegacyPlatformApiService({ v1: {} }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: noopHttpClient }, + cliConfig, + analytics: mockAnalytics(), + telemetry: telemetry.layer, + }), + credentials.layer, + ...(opts.removeFails === true ? [failingRemoveFsLayer] : []), + ); + return { layer, out, telemetry, credentials, workdir: tempRoot.current }; +} + +describe("legacy unlink integration", () => { + it.live("unlinks: removes the temp dir, deletes the keyring entry, prints Finished", () => { + const { layer, out, credentials, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + expect(credentials.deletedRefs).toEqual([LEGACY_VALID_REF]); + expect(out.stdoutText).toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes 'Unlinking project: ' to stderr", () => { + const { layer, out, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(out.stderrText).toContain(`Unlinking project: ${LEGACY_VALID_REF}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("succeeds when no credential is stored (keyring not-found ignored)", () => { + // The tracked credentials mock returns `true`; a real not-found returns + // `false` without erroring — either way unlink succeeds. + const { layer, out, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(out.stdoutText).toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectNotLinkedError when the project-ref file is absent", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectNotLinkedError"); + expect(json).toContain("Cannot find project ref"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the keyring delete errors (permission denied)", () => { + const { layer, workdir } = setup({ deleteFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + } + // The temp dir is still removed before the credential delete is attempted. + expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyUnlinkTempRemovalError when the temp dir cannot be removed", () => { + const { layer, workdir } = setup({ removeFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyUnlinkTempRemovalError"); + expect(json).toContain("failed to remove temp directory"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces both messages when temp removal and keyring delete both fail", () => { + const { layer, workdir } = setup({ removeFails: true, deleteFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + // errors.Join parity — both failure messages are surfaced, not just the first. + expect(json).toContain("failed to remove temp directory"); + expect(json).toContain("failed to delete project credential"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring", () => { + const { layer, telemetry, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("json output: emits a structured success and suppresses the Finished line", () => { + const { layer, out, workdir } = setup({ format: "json" }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + expect(out.stdoutText).not.toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("stream-json output: emits a structured success", () => { + const { layer, out, workdir } = setup({ format: "stream-json" }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts index 9b672d3251..a8dab2f7dc 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -8,3 +8,15 @@ export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvali readonly ref: string; readonly message: string; }> {} + +/** + * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor + * `SUPABASE_PROJECT_ID` is set. Byte-matches cobra's required-flag error string + * (`required flag(s) "project-ref" not set`) that `supabase link`'s `PreRunE` + * produces via `cmd.MarkFlagRequired("project-ref")`. + */ +export class LegacyProjectRefRequiredError extends Data.TaggedError( + "LegacyProjectRefRequiredError", +)<{ + readonly message: string; +}> {} 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 39e50d835f..107714986a 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -3,10 +3,12 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; +import { legacyTempPaths } from "../shared/legacy-temp-paths.ts"; import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; import { LegacyInvalidProjectRefError, LegacyProjectNotLinkedError, + LegacyProjectRefRequiredError, } from "./legacy-project-ref.errors.ts"; import { INVALID_PROJECT_REF_MESSAGE, @@ -34,7 +36,7 @@ export const legacyProjectRefLayer = Layer.effect( const output = yield* Output; const api = yield* LegacyPlatformApi; - const refPath = path.join(cliConfig.workdir, "supabase", ".temp", "project-ref"); + const refPath = legacyTempPaths(path, cliConfig.workdir).projectRef; const readRefFile = Effect.gen(function* () { const exists = yield* fs.exists(refPath).pipe(Effect.orElseSucceed(() => false)); @@ -95,6 +97,25 @@ export const legacyProjectRefLayer = Layer.effect( new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), ); }), + resolveForLink: (flagValue) => + Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return yield* assertValid(flagValue.value); + } + if (Option.isSome(cliConfig.projectId)) { + return yield* assertValid(cliConfig.projectId.value); + } + // Go skips the ref-file fallback for link (MemMapFs at link.go:30). + if (tty.stdinIsTty && output.interactive) { + const chosen = yield* promptForProjectRef("Select a project:"); + return yield* assertValid(chosen); + } + return yield* Effect.fail( + new LegacyProjectRefRequiredError({ + message: `required flag(s) "project-ref" not set`, + }), + ); + }), resolveOptional: (flagValue) => Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { 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 2499b05f6f..459ffe9f30 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 @@ -263,6 +263,73 @@ describe("legacyProjectRefLayer", () => { }); }); + describe("resolveForLink", () => { + it.effect("prefers the --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.some(VALID_REF)); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses SUPABASE_PROJECT_ID when the flag is unset", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the ref file (Go MemMapFs) and fails off-TTY with no flag/projectId", () => { + // A ref file is present, but link must ignore it and fail like cobra's + // required-flag check would. + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolveForLink(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyProjectRefRequiredError"); + expect(errorJson).toContain(`required flag(s) \\"project-ref\\" not set`); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("prompts via Output.promptSelect on a TTY with no other source", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [VALID_REF], + }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.none()); + expect(ref).toBe(VALID_REF); + expect(out.promptSelectCalls[0]?.message).toBe("Select a project:"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects an invalid --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolveForLink(Option.some("BADREF"))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + }); + describe("promptProjectRef", () => { it.effect("prompts with the given title, returns the choice, and echoes it", () => { const projects = [ 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 cd2e32d53c..e8b156e030 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.service.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -4,12 +4,32 @@ import { Context } from "effect"; import type { LegacyInvalidProjectRefError, LegacyProjectNotLinkedError, + LegacyProjectRefRequiredError, } from "./legacy-project-ref.errors.ts"; interface LegacyProjectRefResolverShape { readonly resolve: ( flagValue: Option.Option, ) => Effect.Effect; + /** + * Resolution chain used by `supabase link` (`apps/cli-go/cmd/link.go:30` calls + * `flags.ParseProjectRef` with an **empty in-memory FS**, so the on-disk + * `project-ref` file is deliberately skipped): + * + * flag → `cliConfig.projectId` (env `SUPABASE_PROJECT_ID`) → (TTY) prompt. + * + * On a non-TTY with neither the flag nor `PROJECT_ID` set, fails with + * `LegacyProjectRefRequiredError`, reproducing the cobra + * `required flag(s) "project-ref" not set` error that link's `PreRunE` + * triggers via `cmd.MarkFlagRequired("project-ref")` (`link.go:23-27`). + */ + readonly resolveForLink: ( + flagValue: Option.Option, + ) => Effect.Effect< + string, + LegacyProjectNotLinkedError | LegacyInvalidProjectRefError | LegacyProjectRefRequiredError, + never + >; /** * Soft resolution chain (flag -> `cliConfig.projectId` -> ref file) with **no * prompt and no failure**. Mirrors Go's `flags.LoadProjectRef` as used by @@ -43,7 +63,7 @@ export class LegacyProjectRefResolver extends Context.Service< export const PROJECT_REF_PATTERN = /^[a-z]{20}$/; -export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run `supabase link`?"; +export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run supabase link?"; export const INVALID_PROJECT_REF_MESSAGE = "Invalid project ref format. Must be like `abcdefghijklmnopqrst`."; diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts new file mode 100644 index 0000000000..5803200a84 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -0,0 +1,38 @@ +import type { Path } from "effect"; + +/** + * Absolute paths to the files the Go CLI writes under `/supabase/.temp/`. + * Mirrors the `utils.*Path` constants in `apps/cli-go/internal/utils/misc.go:84-98`. + * + * `supabase link` / `supabase unlink` are the authoritative writers and remover + * of this directory, but several layers (`legacy-project-ref.layer.ts`, + * `legacy-linked-project-cache.layer.ts`) also read from it. Centralising the + * joins here keeps the path layout in one place instead of re-inlining + * `path.join(workdir, "supabase", ".temp", …)` at every call site. + */ +export interface LegacyTempPaths { + readonly tempDir: string; + readonly projectRef: string; + readonly poolerUrl: string; + readonly postgresVersion: string; + readonly restVersion: string; + readonly gotrueVersion: string; + readonly storageVersion: string; + readonly storageMigration: string; + readonly linkedProjectCache: string; +} + +export function legacyTempPaths(path: Path.Path, workdir: string): LegacyTempPaths { + const tempDir = path.join(workdir, "supabase", ".temp"); + return { + tempDir, + projectRef: path.join(tempDir, "project-ref"), + poolerUrl: path.join(tempDir, "pooler-url"), + postgresVersion: path.join(tempDir, "postgres-version"), + restVersion: path.join(tempDir, "rest-version"), + gotrueVersion: path.join(tempDir, "gotrue-version"), + storageVersion: path.join(tempDir, "storage-version"), + storageMigration: path.join(tempDir, "storage-migration"), + linkedProjectCache: path.join(tempDir, "linked-project.json"), + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts new file mode 100644 index 0000000000..a2ca927e05 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts @@ -0,0 +1,37 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path } from "effect"; + +import { legacyTempPaths } from "./legacy-temp-paths.ts"; + +describe("legacyTempPaths", () => { + it.effect("maps a workdir to the supabase/.temp/* layout", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const paths = legacyTempPaths(path, "/home/user/project"); + + expect(paths.tempDir).toBe("/home/user/project/supabase/.temp"); + expect(paths.projectRef).toBe("/home/user/project/supabase/.temp/project-ref"); + expect(paths.poolerUrl).toBe("/home/user/project/supabase/.temp/pooler-url"); + expect(paths.postgresVersion).toBe("/home/user/project/supabase/.temp/postgres-version"); + expect(paths.restVersion).toBe("/home/user/project/supabase/.temp/rest-version"); + expect(paths.gotrueVersion).toBe("/home/user/project/supabase/.temp/gotrue-version"); + expect(paths.storageVersion).toBe("/home/user/project/supabase/.temp/storage-version"); + expect(paths.storageMigration).toBe("/home/user/project/supabase/.temp/storage-migration"); + expect(paths.linkedProjectCache).toBe( + "/home/user/project/supabase/.temp/linked-project.json", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("every temp path is nested under tempDir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const paths = legacyTempPaths(path, "/tmp/wd"); + const { tempDir, ...rest } = paths; + for (const value of Object.values(rest)) { + expect(value.startsWith(`${tempDir}/`)).toBe(true); + } + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-tenant-versions.ts b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts new file mode 100644 index 0000000000..cd18a29768 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts @@ -0,0 +1,131 @@ +import { Effect, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +/** + * Best-effort probes for the deployed versions of a project's REST (PostgREST), + * Auth (GoTrue) and Storage services. Mirrors `apps/cli-go/internal/utils/tenant/ + * {postgrest,gotrue,storage}.go`, which `supabase link`'s `LinkServices` calls to + * write `rest-version` / `gotrue-version` / `storage-version` under + * `supabase/.temp/`. + * + * Requests go directly to the project's service gateway + * (`https://.`) using the service-role key, replicating Go's + * `fetcher.NewServiceGateway` auth headers (`apps/cli-go/pkg/fetcher/gateway.go:25-31`): + * - always send `apikey: `; + * - additionally send `Authorization: Bearer ` unless the key is a + * new-style `sb_…` key (which carries auth in the `apikey` header alone). + * + * Every probe is best-effort: any transport error, non-200 status, parse failure, + * or empty/sentinel version resolves to `Option.none()` so the caller skips the + * corresponding file write without failing the link. This matches Go, where each + * job's error is only logged to the debug logger. + */ + +interface TenantVersionOptions { + readonly ref: string; + readonly projectHost: string; + readonly serviceKey: string; + readonly userAgent: string; +} + +// --------------------------------------------------------------------------- +// Pure parsers — exported for focused unit coverage. +// --------------------------------------------------------------------------- + +/** + * PostgREST advertises its version in the OpenAPI/Swagger `info.version` field at + * `GET /rest/v1/`. Go takes the first whitespace-delimited token and prefixes it + * with `v` (`postgrest.go:37-40`). + */ +export function parseLegacyPostgrestVersion(body: unknown): Option.Option { + if (typeof body !== "object" || body === null) return Option.none(); + const info = (body as { info?: unknown }).info; + if (typeof info !== "object" || info === null) return Option.none(); + const version = (info as { version?: unknown }).version; + if (typeof version !== "string" || version.trim().length === 0) return Option.none(); + const first = version.trim().split(/\s+/)[0]; + if (first === undefined || first.length === 0) return Option.none(); + return Option.some(`v${first}`); +} + +/** + * GoTrue reports its version in the `version` field of `GET /auth/v1/health` + * (`gotrue.go:28-31`). Returned verbatim (no `v` prefix). + */ +export function parseLegacyGotrueVersion(body: unknown): Option.Option { + if (typeof body !== "object" || body === null) return Option.none(); + const version = (body as { version?: unknown }).version; + if (typeof version !== "string" || version.length === 0) return Option.none(); + return Option.some(version); +} + +/** + * Storage returns its bare version string at `GET /storage/v1/version`. Go treats + * an empty body or the `0.0.0` sentinel as "not found" and otherwise prefixes the + * body with `v` (`storage.go:25-28`). + */ +export function parseLegacyStorageVersion(body: string): Option.Option { + if (body.length === 0 || body === "0.0.0") return Option.none(); + return Option.some(`v${body}`); +} + +// --------------------------------------------------------------------------- +// Effectful probes. +// --------------------------------------------------------------------------- + +function tenantRequest(opts: TenantVersionOptions, pathName: string) { + let request = HttpClientRequest.get(`https://${opts.ref}.${opts.projectHost}${pathName}`).pipe( + HttpClientRequest.setHeader("apikey", opts.serviceKey), + HttpClientRequest.setHeader("User-Agent", opts.userAgent), + ); + if (!opts.serviceKey.startsWith("sb_")) { + request = request.pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${opts.serviceKey}`), + ); + } + return request; +} + +const fetchJson = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + return Option.some(yield* response.json); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + +const fetchText = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + return Option.some(yield* response.text); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + +export const legacyFetchPostgrestVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchJson(tenantRequest(opts, "/rest/v1/")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyPostgrestVersion(body.value), + ), + ); + +export const legacyFetchGotrueVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchJson(tenantRequest(opts, "/auth/v1/health")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyGotrueVersion(body.value), + ), + ); + +export const legacyFetchStorageVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchText(tenantRequest(opts, "/storage/v1/version")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyStorageVersion(body.value), + ), + ); diff --git a/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts b/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts new file mode 100644 index 0000000000..5e0db61189 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { Option } from "effect"; + +import { + parseLegacyGotrueVersion, + parseLegacyPostgrestVersion, + parseLegacyStorageVersion, +} from "./legacy-tenant-versions.ts"; + +describe("parseLegacyPostgrestVersion", () => { + it("prefixes the first token of info.version with v", () => { + expect(parseLegacyPostgrestVersion({ info: { version: "12.2.0" } })).toEqual( + Option.some("v12.2.0"), + ); + }); + + it("uses only the first whitespace-delimited field (Go strings.Fields)", () => { + expect(parseLegacyPostgrestVersion({ info: { version: "12.2.0 (abc123)" } })).toEqual( + Option.some("v12.2.0"), + ); + }); + + it("returns None when info.version is empty or missing", () => { + expect(Option.isNone(parseLegacyPostgrestVersion({ info: { version: "" } }))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion({ info: {} }))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion({}))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion(null))).toBe(true); + }); +}); + +describe("parseLegacyGotrueVersion", () => { + it("returns the version verbatim (no v prefix)", () => { + expect(parseLegacyGotrueVersion({ version: "v2.151.0" })).toEqual(Option.some("v2.151.0")); + expect(parseLegacyGotrueVersion({ version: "2.151.0" })).toEqual(Option.some("2.151.0")); + }); + + it("returns None when version is empty or missing", () => { + expect(Option.isNone(parseLegacyGotrueVersion({ version: "" }))).toBe(true); + expect(Option.isNone(parseLegacyGotrueVersion({}))).toBe(true); + expect(Option.isNone(parseLegacyGotrueVersion(null))).toBe(true); + }); +}); + +describe("parseLegacyStorageVersion", () => { + it("prefixes the body with v", () => { + expect(parseLegacyStorageVersion("1.19.3")).toEqual(Option.some("v1.19.3")); + }); + + it("treats empty body and 0.0.0 sentinel as not found", () => { + expect(Option.isNone(parseLegacyStorageVersion(""))).toBe(true); + expect(Option.isNone(parseLegacyStorageVersion("0.0.0"))).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts index 4b0de6378c..06c3e474e3 100644 --- a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts @@ -4,6 +4,7 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyTempPaths } from "../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; function readString(obj: unknown, key: string): string { @@ -42,12 +43,7 @@ export const legacyLinkedProjectCacheLayer = Layer.effect( return LegacyLinkedProjectCache.of({ cache: (ref: string) => Effect.gen(function* () { - const cachePath = path.join( - cliConfig.workdir, - "supabase", - ".temp", - "linked-project.json", - ); + const cachePath = legacyTempPaths(path, cliConfig.workdir).linkedProjectCache; const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); if (exists) return; diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index eebc95ef92..a3ae051cea 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -14,6 +14,7 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; +import { LegacyCredentialDeleteError } from "../../src/legacy/auth/legacy-errors.ts"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../src/legacy/config/legacy-project-ref.layer.ts"; @@ -59,8 +60,45 @@ export const mockLegacyCredentialsLayer = Layer.succeed(LegacyCredentials, { getAccessToken: Effect.sync(() => Option.none()), saveAccessToken: () => Effect.die("unexpected legacy credentials write in test"), deleteAccessToken: Effect.die("unexpected legacy credentials delete in test"), + deleteProjectCredential: () => Effect.die("unexpected legacy project-credential delete in test"), }); +/** + * Tracked `LegacyCredentials` mock for `supabase unlink` tests. Records the + * project refs passed to `deleteProjectCredential` and lets the test choose the + * outcome: `true`/`false` (deleted / not found) or a `LegacyCredentialDeleteError` + * (e.g. permission-denied keyring failure). + */ +export function mockLegacyCredentialsTracked(opts: { readonly deleteFails?: boolean } = {}): { + readonly layer: Layer.Layer; + readonly deletedRefs: ReadonlyArray; +} { + const deletedRefs: string[] = []; + const layer = Layer.succeed(LegacyCredentials, { + getAccessToken: Effect.sync(() => Option.none()), + saveAccessToken: () => Effect.die("unexpected legacy credentials write in test"), + deleteAccessToken: Effect.die("unexpected legacy credentials delete in test"), + deleteProjectCredential: (projectRef: string) => + Effect.gen(function* () { + deletedRefs.push(projectRef); + if (opts.deleteFails === true) { + return yield* Effect.fail( + new LegacyCredentialDeleteError({ + message: "failed to delete project credential: permission denied", + }), + ); + } + return true; + }), + }); + return { + layer, + get deletedRefs() { + return deletedRefs; + }, + }; +} + // --------------------------------------------------------------------------- // State-tracking factories — for PersistentPostRun-parity assertions // (telemetry must flush, linked-project cache fires after ref resolution). From 933f262777694b8d68b340483571f404a9a94d8a Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:39:10 +0530 Subject: [PATCH 21/38] feat(cli): port init (#5433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR `supabase init` now runs natively in ts ## What’s introduced adds a shared native `init` implementation the command now creates the local project directly in TypeScript, including `supabase/config.toml`, `supabase/.gitignore`, and the optional VS Code and IntelliJ settings files, while preserving existing behavior around `--force`, `--interactive`, legacy hidden IDE flags, and the `--experimental` requirement for `--use-orioledb`..... & coverage all around this! ## Ref - closes CLI-1303 --------- Co-authored-by: Colum Ferry --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../src/legacy/commands/init/SIDE_EFFECTS.md | 59 ++- .../src/legacy/commands/init/init.command.ts | 8 +- .../src/legacy/commands/init/init.handler.ts | 54 +- .../commands/init/init.integration.test.ts | 159 ++++++ .../src/next/commands/init/init.command.ts | 39 +- .../src/next/commands/init/init.e2e.test.ts | 31 +- .../src/next/commands/init/init.handler.ts | 60 +-- .../commands/init/init.integration.test.ts | 461 +++++++++++++++-- .../src/shared/cli/hidden-flag.unit.test.ts | 7 - .../src/shared/init/project-init.errors.ts | 30 ++ .../src/shared/init/project-init.templates.ts | 472 ++++++++++++++++++ .../init/project-init.templates.unit.test.ts | 56 +++ apps/cli/src/shared/init/project-init.ts | 300 +++++++++++ apps/cli/src/shared/output/output.layer.ts | 9 +- apps/cli/src/shared/output/output.service.ts | 5 +- apps/cli/tests/helpers/mocks.ts | 2 +- 17 files changed, 1597 insertions(+), 159 deletions(-) create mode 100644 apps/cli/src/legacy/commands/init/init.integration.test.ts create mode 100644 apps/cli/src/shared/init/project-init.errors.ts create mode 100644 apps/cli/src/shared/init/project-init.templates.ts create mode 100644 apps/cli/src/shared/init/project-init.templates.unit.test.ts create mode 100644 apps/cli/src/shared/init/project-init.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 44f2a099f0..b096e74a17 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -65,7 +65,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | | ----------- | --------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `init` | `partial` | [`../src/next/commands/init/init.command.ts`](../src/next/commands/init/init.command.ts) | `--force`, `--interactive`, `--use-orioledb` | `-` | TS init creates a minimal `supabase/config.json` with only a `"$schema"` reference and ensures repo-local `.supabase/` state can stay gitignored, but it does not yet expose the old Go flag surface. | +| `init` | `ported` | [`../src/next/commands/init/init.command.ts`](../src/next/commands/init/init.command.ts) | `-` | `-` | TS init now writes the Go-style `supabase/config.toml` scaffold, updates `supabase/.gitignore` only when invoked inside a git repo, supports `--force`, `--interactive`, and `--use-orioledb`, and shares the same native implementation with the legacy shell command. | | `link` | `partial` | [`../src/next/commands/link/link.command.ts`](../src/next/commands/link/link.command.ts) | `--password`, `--skip-pooler` | `-` | TS link supports `--project-ref`, interactive project selection, and zero-config linking. It stores linked remote metadata in repo-local `.supabase/project.json`, but it does not yet manage direct database-password or pooler-specific link flows. | | `unlink` | `ported` | [`../src/next/commands/unlink/unlink.command.ts`](../src/next/commands/unlink/unlink.command.ts) | `-` | `-` | TS unlink matches the current Go surface and removes the repo-local linked project metadata for the active checkout. | | `login` | `ported` | [`../src/next/commands/login/login.command.ts`](../src/next/commands/login/login.command.ts) | `-` | `-` | Flag surface matches the old CLI: `--token`, `--name`, `--no-browser`. TS also supports env-var and piped-stdin token input without adding new flags. | @@ -266,7 +266,7 @@ Legend: | `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | | `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | | `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | -| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | | `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | diff --git a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md index bf948c36f4..32f8b64e6b 100644 --- a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md @@ -2,43 +2,46 @@ ## Files Read -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------- | ---------- | ------------------------------------------------------------------------------------------------ | +| `supabase/config.toml` | TOML | checked first to fail fast unless `--force` is set | +| `.git/` | directory | checked upward from the invocation cwd to decide whether `supabase/.gitignore` should be managed | +| `supabase/.gitignore` | text | only when inside a git repo and the file already exists | +| `.vscode/settings.json` | JSONC/JSON | when VS Code settings are generated and the file already exists | +| `.vscode/extensions.json` | JSONC/JSON | when VS Code settings are generated and the file already exists | ## Files Written -| Path | Format | When | -| ------------------------- | ------ | -------------------------------------------------------- | -| `supabase/config.toml` | TOML | always on success; created from default template | -| `supabase/.gitignore` | text | always on success; gitignores runtime state | -| `.vscode/settings.json` | JSON | when `--with-vscode-settings` flag is set (deprecated) | -| `.vscode/extensions.json` | JSON | when `--with-vscode-workspace` flag is set (deprecated) | -| `.idea/deno.xml` | XML | when `--with-intellij-settings` flag is set (deprecated) | +| Path | Format | When | +| ------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML | always on success; created from default template | +| `supabase/.gitignore` | text | when inside a git repo and the template is not already present | +| `.vscode/settings.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.vscode/extensions.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.idea/deno.xml` | XML | when interactive IntelliJ setup is accepted, or when `--with-intellij-settings` is set | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| - | - | - | - | - | ## Environment Variables -| Variable | Purpose | Required? | -| --------- | ------------------------------------------ | --------- | -| `WORKDIR` | overrides working directory (set to `"."`) | no | +None. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------- | -| `0` | success — prints "Finished supabase init." | -| `1` | `supabase/config.toml` already exists and `--force` was not provided | -| `1` | permission denied writing config file | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success - prints "Finished supabase init." | +| `1` | `supabase/config.toml` already exists and `--force` was not provided | +| `1` | permission denied writing config file | +| `1` | an existing `.vscode/settings.json` / `.vscode/extensions.json` is not valid JSON(C) | ## Output -### `--output-format text` (Go CLI compatible) +### Legacy Output On success: @@ -48,19 +51,15 @@ Finished supabase init. In interactive mode (`-i`/`--interactive`), may prompt for IDE settings preferences. -### `--output-format json` - -Not applicable — init produces no machine-readable output. - -### `--output-format stream-json` - -Not applicable — init produces no structured output. +Success is emitted as raw text even when the legacy shell is invoked with non-text output modes. ## Notes -- Sets `WORKDIR` to `"."` in `PersistentPreRunE` to prevent recursing to parent directories. +- Uses the invocation cwd directly and does not recurse upward looking for an existing project. - The `--force` flag overwrites an existing `supabase/config.toml`. - The `--use-orioledb` flag sets `UseOrioleDB` in init params; requires `--experimental` flag. - The `--interactive` / `-i` flag enables IDE settings prompts (only effective in TTY). -- The `--with-vscode-settings`, `--with-vscode-workspace`, and `--with-intellij-settings` flags are hidden backward-compat aliases. -- No authentication required — purely local file creation. +- The `--with-vscode-settings` and `--with-vscode-workspace` flags are hidden backward-compat aliases for the same VS Code helper and both write `.vscode/settings.json` and `.vscode/extensions.json`. +- The `--with-intellij-settings` flag is a hidden backward-compat alias for generating `.idea/deno.xml`. +- An existing `.vscode/settings.json` / `.vscode/extensions.json` is parsed tolerantly through a JSONC boundary that strips line/block comments and trailing commas (matching Go's `jsonc.ToJSONInPlace`), then the template is merged on top (template keys win). An empty file is treated as absent and the template is written verbatim. A non-empty file that is not valid JSON(C) aborts the command with `InitParseSettingsError` and is left untouched rather than being overwritten. +- No authentication required - purely local file creation. diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 8c80348b1f..32e8661d4c 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.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 { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyInit } from "./init.handler.ts"; const config = { @@ -32,5 +35,8 @@ export type LegacyInitFlags = CliCommand.Command.Config.Infer; export const legacyInitCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), - Command.withHandler((flags) => legacyInit(flags)), + Command.withHandler((flags) => + legacyInit(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["init"])), ); diff --git a/apps/cli/src/legacy/commands/init/init.handler.ts b/apps/cli/src/legacy/commands/init/init.handler.ts index 03211123b9..aaa984d397 100644 --- a/apps/cli/src/legacy/commands/init/init.handler.ts +++ b/apps/cli/src/legacy/commands/init/init.handler.ts @@ -1,15 +1,47 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { resolve } from "node:path"; +import { Effect, Option } from "effect"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { initProject } from "../../../shared/init/project-init.ts"; +import { + InitAlreadyExistsError, + InitExperimentalRequiredError, +} from "../../../shared/init/project-init.errors.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyExperimentalFlag, LegacyWorkdirFlag } from "../../../shared/legacy/global-flags.ts"; import type { LegacyInitFlags } from "./init.command.ts"; export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["init"]; - if (flags.interactive) args.push("--interactive"); - if (flags.useOrioledb) args.push("--use-orioledb"); - if (flags.force) args.push("--force"); - if (flags.withVscodeWorkspace) args.push("--with-vscode-workspace"); - if (flags.withVscodeSettings) args.push("--with-vscode-settings"); - if (flags.withIntellijSettings) args.push("--with-intellij-settings"); - yield* proxy.exec(args); + const output = yield* Output; + const runtimeInfo = yield* RuntimeInfo; + const experimental = yield* LegacyExperimentalFlag; + const workdir = yield* LegacyWorkdirFlag; + + if (flags.useOrioledb && !experimental) { + return yield* Effect.fail( + new InitExperimentalRequiredError({ + detail: "--use-orioledb is only available when experimental features are enabled.", + suggestion: "Rerun the command with `--experimental --use-orioledb`.", + }), + ); + } + + const result = yield* initProject({ + cwd: Option.isSome(workdir) ? resolve(runtimeInfo.cwd, workdir.value) : runtimeInfo.cwd, + force: flags.force, + useOrioledb: flags.useOrioledb, + interactive: flags.interactive, + withVscodeSettings: flags.withVscodeWorkspace || flags.withVscodeSettings, + withIntellijSettings: flags.withIntellijSettings, + }); + + if (!result.created) { + return yield* Effect.fail( + new InitAlreadyExistsError({ + detail: `Config already exists at ${result.configPath}.`, + suggestion: "Run `supabase init --force` to overwrite the existing config.", + }), + ); + } + + yield* output.raw("Finished supabase init.\n"); }); diff --git a/apps/cli/src/legacy/commands/init/init.integration.test.ts b/apps/cli/src/legacy/commands/init/init.integration.test.ts new file mode 100644 index 0000000000..45b6911baf --- /dev/null +++ b/apps/cli/src/legacy/commands/init/init.integration.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { LegacyExperimentalFlag, LegacyWorkdirFlag } from "../../../shared/legacy/global-flags.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { legacyInit } from "./init.handler.ts"; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "supabase-legacy-init-")); +} + +function setup( + cwd: string, + opts: { + experimental?: boolean; + workdir?: Option.Option; + interactive?: boolean; + stdinIsTty?: boolean; + } = {}, +) { + const out = mockOutput({ format: "text", interactive: opts.interactive ?? false }); + return { + out, + layer: Layer.mergeAll( + BunServices.layer, + out.layer, + mockRuntimeInfo({ cwd }), + mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.interactive ?? false, + }), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyWorkdirFlag, opts.workdir ?? Option.none()), + ), + }; +} + +function expectFailureTag(exit: Exit.Exit, tag: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return; + } + + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe(tag); + } +} + +describe("legacy init", () => { + it.live("creates config.toml natively without the Go proxy", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain("major_version = 17"); + expect(out.stdoutText).toBe("Finished supabase init.\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("requires --experimental when --use-orioledb is set", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { experimental: false }); + + const exit = yield* legacyInit({ + interactive: false, + useOrioledb: true, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer), Effect.exit); + + expectFailureTag(exit, "InitExperimentalRequiredError"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("supports the hidden IDE flags natively", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: true, + withVscodeSettings: false, + withIntellijSettings: true, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, ".vscode", "extensions.json"), "utf8"), + ), + ).toContain('"recommendations"'); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".idea", "deno.xml"), "utf8")), + ).toContain(''); + expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); + expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("respects the legacy --workdir global flag", () => { + const tempDir = makeTempDir(); + const workdir = join(tempDir, "nested"); + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { workdir: Option.some("nested") }); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain("major_version = 17"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/next/commands/init/init.command.ts b/apps/cli/src/next/commands/init/init.command.ts index 0633cc0976..33e881ef89 100644 --- a/apps/cli/src/next/commands/init/init.command.ts +++ b/apps/cli/src/next/commands/init/init.command.ts @@ -1,29 +1,42 @@ -import { projectConfigStoreLayer } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; -import { Layer } from "effect"; -import { Command } from "effect/unstable/cli"; +import { Command, Flag } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { init } from "./init.handler.ts"; -export const initCommand = Command.make("init").pipe( +const config = { + interactive: Flag.boolean("interactive").pipe( + Flag.withDescription("Enables interactive mode to configure IDE settings."), + Flag.withAlias("i"), + ), + experimental: Flag.boolean("experimental").pipe(Flag.withHidden), + useOrioledb: Flag.boolean("use-orioledb").pipe( + Flag.withDescription("Use OrioleDB storage engine for Postgres."), + ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Overwrite existing supabase/config.toml."), + ), +} as const; + +export const initCommand = Command.make("init", config).pipe( Command.withDescription( - "Initialize a local Supabase project.\n\nCreates supabase/config.json with a minimal $schema reference so editor autocomplete works immediately.", + "Initialize a local Supabase project.\n\nCreates supabase/config.toml, supabase/.gitignore, and optionally IDE settings for local development.", ), Command.withShortDescription("Initialize local Supabase project"), Command.withExamples([ { command: "supabase init", - description: "Create a minimal supabase/config.json in the current directory", + description: "Create a Supabase project scaffold in the current directory", + }, + { + command: "supabase init --force", + description: "Overwrite an existing local Supabase config", }, ]), - Command.withHandler(() => init().pipe(withCommandInstrumentation(), withJsonErrorHandling)), - Command.provide(commandRuntimeLayer(["init"])), - Command.provide( - Layer.mergeAll( - BunServices.layer, - projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)), - ), + Command.withHandler((flags) => + init(flags).pipe(withCommandInstrumentation({ flags }), withJsonErrorHandling), ), + Command.provide(commandRuntimeLayer(["init"])), + Command.provide(BunServices.layer), ); diff --git a/apps/cli/src/next/commands/init/init.e2e.test.ts b/apps/cli/src/next/commands/init/init.e2e.test.ts index 27d191c53e..9b9c45f88a 100644 --- a/apps/cli/src/next/commands/init/init.e2e.test.ts +++ b/apps/cli/src/next/commands/init/init.e2e.test.ts @@ -2,31 +2,24 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; const INIT_TIMEOUT_MS = 5_000; describe("supabase init", () => { - test( - "creates a minimal config.json in the current directory", - { timeout: INIT_TIMEOUT_MS }, - async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-init-e2e-")); + test("creates config.toml in the current directory", { timeout: INIT_TIMEOUT_MS }, async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-init-e2e-")); - try { - const { stdout, exitCode } = await runSupabase(["init"], { cwd: tempDir }); + try { + const { stdout, exitCode } = await runSupabase(["init"], { cwd: tempDir }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Initialized Supabase project."); + expect(exitCode).toBe(0); + expect(stdout).toContain("Initialized Supabase project."); - const content = await readFile(join(tempDir, "supabase", "config.json"), "utf8"); - expect(JSON.parse(content)).toEqual({ - $schema: PROJECT_CONFIG_SCHEMA_URL, - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }, - ); + const content = await readFile(join(tempDir, "supabase", "config.toml"), "utf8"); + expect(content).toContain("major_version = 17"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/src/next/commands/init/init.handler.ts b/apps/cli/src/next/commands/init/init.handler.ts index cf42a217e3..a7c9e523b9 100644 --- a/apps/cli/src/next/commands/init/init.handler.ts +++ b/apps/cli/src/next/commands/init/init.handler.ts @@ -1,49 +1,49 @@ -import { dirname } from "node:path"; -import { - PROJECT_CONFIG_SCHEMA_URL, - ProjectConfigSchema, - ProjectConfigStore, -} from "@supabase/config"; import { Effect } from "effect"; -import { Schema } from "effect"; -import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { initProject, type ProjectInitOptions } from "../../../shared/init/project-init.ts"; +import { InitExperimentalRequiredError } from "../../../shared/init/project-init.errors.ts"; -const emptyConfig = Schema.decodeUnknownSync(ProjectConfigSchema)({}); -const projectRootForConfigPath = (configPath: string): string => dirname(dirname(configPath)); - -export const init = Effect.fnUntraced(function* () { +export const init = Effect.fnUntraced(function* ( + flags: Omit & { + readonly experimental: boolean; + }, +) { const output = yield* Output; const runtimeInfo = yield* RuntimeInfo; - const projectConfigStore = yield* ProjectConfigStore; + + if (flags.useOrioledb && !flags.experimental) { + return yield* Effect.fail( + new InitExperimentalRequiredError({ + detail: "--use-orioledb is only available when experimental features are enabled.", + suggestion: "Rerun the command with `supabase init --experimental --use-orioledb`.", + }), + ); + } yield* output.intro("Initialize local Supabase project"); - const existingConfig = yield* projectConfigStore.load(runtimeInfo.cwd); - if (existingConfig !== null) { - yield* ensureProjectStateIgnored(projectRootForConfigPath(existingConfig.path)); + // The next shell does not expose the hidden IDE compat flags; editor settings + // are only generated when the user opts in through interactive mode. + const result = yield* initProject({ + cwd: runtimeInfo.cwd, + ...flags, + withVscodeSettings: false, + withIntellijSettings: false, + }); + + if (!result.created) { yield* output.success("Supabase project already initialized.", { - config_path: existingConfig.path, - schema_ref: existingConfig.schemaRef, + config_path: result.configPath, created: false, }); - yield* output.outro(`Using existing config at ${existingConfig.path}.`); + yield* output.outro(`Using existing config at ${result.configPath}.`); return; } - const saved = yield* projectConfigStore.save({ - cwd: runtimeInfo.cwd, - config: emptyConfig, - format: "json", - schemaRef: PROJECT_CONFIG_SCHEMA_URL, - }); - yield* ensureProjectStateIgnored(projectRootForConfigPath(saved.path)); - yield* output.success("Initialized Supabase project.", { - config_path: saved.path, - schema_ref: saved.schemaRef, + config_path: result.configPath, created: true, }); - yield* output.outro(`Created ${saved.path}.`); + yield* output.outro(`Created ${result.configPath}.`); }); diff --git a/apps/cli/src/next/commands/init/init.integration.test.ts b/apps/cli/src/next/commands/init/init.integration.test.ts index c2a06bda1e..17c513892b 100644 --- a/apps/cli/src/next/commands/init/init.integration.test.ts +++ b/apps/cli/src/next/commands/init/init.integration.test.ts @@ -1,38 +1,52 @@ import { describe, expect, it } from "@effect/vitest"; -import { projectConfigStoreLayer } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Stdio } from "effect"; +import { basename, join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; import { Command } from "effect/unstable/cli"; -import { PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; -import { initCommand } from "./init.command.ts"; +import { INIT_GITIGNORE_TEMPLATE } from "../../../shared/init/project-init.templates.ts"; import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { mockOutput, mockProcessControl, mockRuntimeInfo, + mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { initCommand } from "./init.command.ts"; import { init } from "./init.handler.ts"; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), "supabase-init-command-")); } -function buildLayer(cwd: string) { +function buildLayer( + cwd: string, + opts: { + interactive?: boolean; + stdinIsTty?: boolean; + promptConfirmResponses?: ReadonlyArray; + } = {}, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd }); - const out = mockOutput({ format: "text", interactive: false }); + const out = mockOutput({ + format: "text", + interactive: opts.interactive ?? false, + promptConfirmResponses: opts.promptConfirmResponses, + }); return { out, layer: Layer.mergeAll( out.layer, runtimeInfoLayer, + mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.interactive ?? false, + }), BunServices.layer, - projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)), ), }; } @@ -66,80 +80,413 @@ function mockContextualAnalytics() { return { layer, captured }; } +function expectFailureTag(exit: Exit.Exit, tag: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return; + } + + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe(tag); + } +} + describe("init handler", () => { - it.live("creates a minimal config.json with the hosted $schema", () => { + it.live("creates config.toml and supabase/.gitignore", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); const { layer, out } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - const configPath = join(tempDir, "supabase", "config.json"); + const configPath = join(tempDir, "supabase", "config.toml"); const content = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - expect(JSON.parse(content)).toEqual({ - $schema: PROJECT_CONFIG_SCHEMA_URL, - }); + expect(content).toContain(`project_id = "${basename(tempDir)}"`); + expect(content).toContain("major_version = 17"); + expect(content).toContain('orioledb_version = ""'); expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".gitignore"), "utf8")), - ).toContain(".supabase/"); + yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", ".gitignore"), "utf8")), + ).toBe(INIT_GITIGNORE_TEMPLATE); expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + expect.objectContaining({ + type: "success", + message: "Initialized Supabase project.", + data: expect.objectContaining({ config_path: configPath, created: true }), + }), ); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("does not overwrite an existing config", () => { + it.live("reports an already-initialized project without overwriting it", () => { const tempDir = makeTempDir(); - const configPath = join(tempDir, "supabase", "config.json"); - const initialConfig = JSON.stringify( - { - $schema: "./node_modules/@supabase/config/schema.json", - db: { major_version: 16 }, - }, - null, - 2, - ); + const configPath = join(tempDir, "supabase", "config.toml"); return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(configPath, `${initialConfig}\n`)); - + yield* Effect.tryPromise(() => writeFile(configPath, 'project_id = "existing"\n')); const { layer, out } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - expect(content).toBe(`${initialConfig}\n`); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".gitignore"), "utf8")), - ).toContain(".supabase/"); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Supabase project already initialized.", + data: expect.objectContaining({ config_path: configPath, created: false }), }), ); + expect(yield* Effect.tryPromise(() => readFile(configPath, "utf8"))).toBe( + 'project_id = "existing"\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("ignores a legacy config.json when creating config.toml", () => { + const tempDir = makeTempDir(); + const jsonPath = join(tempDir, "supabase", "config.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", "config.toml"), "utf8")), + ).toContain(`project_id = "${basename(tempDir)}"`); + expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( + '{ "$schema": "./schema.json" }\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("does not remove a legacy config.json when force is set", () => { + const tempDir = makeTempDir(); + const jsonPath = join(tempDir, "supabase", "config.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: true, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain(`project_id = "${basename(tempDir)}"`); + expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( + '{ "$schema": "./schema.json" }\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("writes the OrioleDB version when requested", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ interactive: false, experimental: true, useOrioledb: true, force: false }).pipe( + Effect.provide(layer), + ); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("prompts for IDE settings in interactive mode", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("overwrites nested VS Code formatter settings the same way as the old init flow", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + join(tempDir, ".vscode", "settings.json"), + JSON.stringify( + { + custom: true, + "[typescript]": { + "editor.tabSize": 4, + }, + }, + null, + 2, + ), + ), + ); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + const settings = JSON.parse( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ) as Record; + + expect(settings.custom).toBe(true); + expect(settings["[typescript]"]).toEqual({ + "editor.defaultFormatter": "denoland.vscode-deno", + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("merges into a JSONC settings file with comments and trailing commas", () => { + const tempDir = makeTempDir(); + const settingsPath = join(tempDir, ".vscode", "settings.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + settingsPath, + [ + "{", + " // editor preferences", + ' "editor.tabSize": 4, // keep four spaces', + " /* a block comment */", + ' "files.eol": "\\n",', + "}", + ].join("\n"), + ), + ); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + const settings = JSON.parse( + yield* Effect.tryPromise(() => readFile(settingsPath, "utf8")), + ) as Record; + + expect(settings["editor.tabSize"]).toBe(4); + expect(settings["files.eol"]).toBe("\n"); + expect(settings["deno.enablePaths"]).toBeDefined(); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live( + "fails with InitParseSettingsError on a malformed settings file without clobbering it", + () => { + const tempDir = makeTempDir(); + const settingsPath = join(tempDir, ".vscode", "settings.json"); + const malformed = '{ "editor.tabSize": '; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(settingsPath, malformed)); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + const exit = yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); + + expectFailureTag(exit, "InitParseSettingsError"); + expect(yield* Effect.tryPromise(() => readFile(settingsPath, "utf8"))).toBe(malformed); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("does not prompt for IDE settings when stdin is not a TTY", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: false, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + expect(out.stdoutText).not.toContain("Generated VS Code settings"); + expect( + yield* Effect.tryPromise(async () => { + try { + await readFile(join(tempDir, ".vscode", "settings.json"), "utf8"); + return true; + } catch { + return false; + } + }), + ).toBe(false); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("does not create local link metadata", () => { + it.live("only writes supabase/.gitignore inside a git repo", () => { const tempDir = makeTempDir(); + return Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(async () => { + try { + await readFile(join(tempDir, "supabase", ".gitignore"), "utf8"); + return true; + } catch { + return false; + } + }), + ).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("appends to an existing supabase/.gitignore without clobbering it", () => { + const tempDir = makeTempDir(); + const gitignorePath = join(tempDir, "supabase", ".gitignore"); + return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(gitignorePath, "existing-entry\n")); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* Effect.tryPromise(() => readFile(gitignorePath, "utf8"))).toBe( + `existing-entry\n\n${INIT_GITIGNORE_TEMPLATE}`, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("requires --experimental when --use-orioledb is set", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { const { layer } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + const exit = yield* init({ + interactive: false, + experimental: false, + useOrioledb: true, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); - expect(existsSync(join(tempDir, ".supabase", "project.json"))).toBe(false); + expectFailureTag(exit, "InitExperimentalRequiredError"); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); @@ -157,14 +504,13 @@ describe("init handler", () => { analytics.layer, runtimeInfoLayer, processControl.layer, + mockTty(), Stdio.layerTest({ args: Effect.succeed(["init"]), }), ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Command.runWith(initCommand, { version: "0.1.0" })(["init"]).pipe( Effect.provide(layer), ); @@ -183,4 +529,35 @@ describe("init handler", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + it.live("wires command flags through the parser", () => { + const tempDir = makeTempDir(); + const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + analytics.layer, + runtimeInfoLayer, + mockTty(), + processControl.layer, + ); + + return Effect.gen(function* () { + yield* Command.runWith(initCommand, { version: "0.1.0" })([ + "init", + "--experimental", + "--use-orioledb", + ]).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index f0a1f60b56..9df5738d74 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -115,12 +115,6 @@ describe("native hidden flags", () => { "stop", "--backup=false", ]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "init", - "--with-vscode-workspace", - "--with-vscode-settings", - "--with-intellij-settings", - ]); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", @@ -148,7 +142,6 @@ describe("native hidden flags", () => { expect(proxy.calls).toEqual([ ["start", "--preview"], ["stop", "--backup=false"], - ["init", "--with-vscode-workspace", "--with-vscode-settings", "--with-intellij-settings"], ["functions", "download", "hello", "--use-docker", "--legacy-bundle"], ["functions", "deploy", "hello", "--use-docker", "--legacy-bundle"], ["functions", "serve", "--all=false"], diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts new file mode 100644 index 0000000000..edcd4e069c --- /dev/null +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -0,0 +1,30 @@ +import { Data } from "effect"; + +export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "A Supabase project is already initialized in this directory."; + } +} + +export class InitExperimentalRequiredError extends Data.TaggedError( + "InitExperimentalRequiredError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "The --use-orioledb flag requires --experimental."; + } +} + +export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "Failed to parse existing IDE settings file."; + } +} diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts new file mode 100644 index 0000000000..65213ce06c --- /dev/null +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -0,0 +1,472 @@ +const CONFIG_TEMPLATE_RAW = `# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running \`supabase init\`. +project_id = "__PROJECT_ID__" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. \`public\` and \`graphql_public\` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the \`public\` schema by +# \`postgres\` are reachable through the Data API roles (\`anon\`, \`authenticated\`, \`service_role\`) +# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default +# flips to \`false\` on 2026-05-30 to match the new cloud default, and the field is removed in +# 2026-10-30 once the always-revoked behaviour is permanent. Set to \`false\` to opt in early. +# auto_expose_new_tables = false + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run \`SHOW +# server_version;\` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: \`transaction\`, \`session\`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with \`/auth/v1\` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: \`letters_digits\`, \`lower_upper_letters_digits\`, \`lower_upper_letters_digits_symbols\` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: \`hcaptcha\`, \`turnstile\`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ \`{{ .Code }}\` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: \`twilio\`, \`twilio_verify\`, \`messagebird\`, \`textlocal\`, \`vonage\`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ \`{{ .Code }}\` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: \`apple\`, \`azure\`, \`bitbucket\`, +# \`discord\`, \`facebook\`, \`github\`, \`gitlab\`, \`google\`, \`keycloak\`, \`linkedin_oidc\`, \`notion\`, \`twitch\`, +# \`twitter\`, \`x\`, \`slack\`, \`spotify\`, \`workos\`, \`zoom\`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: \`oneshot\`, \`per_worker\`. +# \`per_worker\` (default) — enables hot reload during local development. +# \`oneshot\` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: \`postgres\`, \`bigquery\`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "__ORIOLEDB_VERSION__" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under \`supabase/\` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\\"keywordCase\\":\\"upper\\",\\"indent\\":2,\\"maxWidth\\":80,\\"commaStyle\\":\\"trailing\\"}" +`; + +export const INIT_GITIGNORE_TEMPLATE = `# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local +`; + +export const VSCODE_EXTENSIONS_TEMPLATE = `{ + "recommendations": ["denoland.vscode-deno"] +} +`; + +export const VSCODE_SETTINGS_TEMPLATE = `{ + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} +`; + +export const INTELLIJ_DENO_TEMPLATE = ` + + + + +`; + +const ORIOLE_DB_VERSION = "15.1.0.150"; + +export function renderProjectConfigTemplate(projectId: string, useOrioledb: boolean): string { + return CONFIG_TEMPLATE_RAW.replace("__PROJECT_ID__", projectId).replace( + "__ORIOLEDB_VERSION__", + useOrioledb ? ORIOLE_DB_VERSION : "", + ); +} diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts new file mode 100644 index 0000000000..163acf3b5b --- /dev/null +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + INIT_GITIGNORE_TEMPLATE, + INTELLIJ_DENO_TEMPLATE, + VSCODE_EXTENSIONS_TEMPLATE, + VSCODE_SETTINGS_TEMPLATE, + renderProjectConfigTemplate, +} from "./project-init.templates.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const goCliRoot = join(here, "../../../../cli-go"); + +function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n"); +} + +function readGoTemplate(...segments: ReadonlyArray): string { + return normalizeNewlines(readFileSync(join(goCliRoot, ...segments), "utf8")); +} + +describe("project init templates", () => { + it("renders config.toml with the same content as the Go scaffold", () => { + const expected = readGoTemplate("pkg", "config", "templates", "config.toml") + .replace("{{ .ProjectId }}", "demo-project") + .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150"); + + expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe(expected); + }); + + it("matches the Go .gitignore scaffold", () => { + expect(INIT_GITIGNORE_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".gitignore"), + ); + }); + + it("matches the Go VS Code extensions scaffold", () => { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".vscode", "extensions.json"), + ); + }); + + it("matches the Go VS Code settings scaffold", () => { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".vscode", "settings.json"), + ); + }); + + it("matches the Go IntelliJ scaffold", () => { + expect(INTELLIJ_DENO_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".idea", "deno.xml"), + ); + }); +}); diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts new file mode 100644 index 0000000000..4805a41f13 --- /dev/null +++ b/apps/cli/src/shared/init/project-init.ts @@ -0,0 +1,300 @@ +import { Effect, FileSystem, Path, Schema } from "effect"; +import { Output } from "../output/output.service.ts"; +import { Tty } from "../runtime/tty.service.ts"; +import { + INIT_GITIGNORE_TEMPLATE, + INTELLIJ_DENO_TEMPLATE, + VSCODE_EXTENSIONS_TEMPLATE, + VSCODE_SETTINGS_TEMPLATE, + renderProjectConfigTemplate, +} from "./project-init.templates.ts"; +import { InitParseSettingsError } from "./project-init.errors.ts"; + +const invalidProjectId = /[^a-zA-Z0-9_.-]+/g; +const maxProjectIdLength = 40; + +function truncateText(text: string, maxLength: number): string { + return text.length > maxLength ? text.slice(0, maxLength) : text; +} + +function sanitizeProjectId(src: string): string { + const sanitized = src.replaceAll(invalidProjectId, "_").replace(/^[_.-]+/, ""); + return truncateText(sanitized, maxProjectIdLength); +} + +// Mirrors Go's `jsonc.ToJSONInPlace` (github.com/tidwall/jsonc): strips line and +// block comments and trailing commas while preserving string contents, so an +// existing JSONC settings file parses exactly as it does in the Go CLI. +function stripJsonComments(contents: string): string { + const src = contents.replace(/^\uFEFF/, ""); + const out: Array = []; + let pendingCommaIndex = -1; + let i = 0; + while (i < src.length) { + const char = src.charAt(i); + + // String literal \u2014 copy verbatim, honoring escape sequences. + if (char === '"') { + pendingCommaIndex = -1; + out.push(char); + i++; + while (i < src.length) { + const stringChar = src.charAt(i); + out.push(stringChar); + i++; + if (stringChar === "\\") { + if (i < src.length) { + out.push(src.charAt(i)); + i++; + } + } else if (stringChar === '"') { + break; + } + } + continue; + } + + // Line comment. + if (char === "/" && src.charAt(i + 1) === "/") { + i += 2; + while (i < src.length && src.charAt(i) !== "\n") { + i++; + } + continue; + } + + // Block comment. + if (char === "/" && src.charAt(i + 1) === "*") { + i += 2; + while (i < src.length && !(src.charAt(i) === "*" && src.charAt(i + 1) === "/")) { + i++; + } + i += 2; + continue; + } + + // A comma is "trailing" if the next significant token is a closing brace or + // bracket; drop it in that case to match jsonc's trailing-comma handling. + if (char === ",") { + pendingCommaIndex = out.length; + out.push(char); + i++; + continue; + } + + if (char === "}" || char === "]") { + if (pendingCommaIndex >= 0) { + out[pendingCommaIndex] = ""; + pendingCommaIndex = -1; + } + out.push(char); + i++; + continue; + } + + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + out.push(char); + i++; + continue; + } + + pendingCommaIndex = -1; + out.push(char); + i++; + } + return out.join(""); +} + +const decodeJsonObject = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +// Parses a settings file through a Schema boundary so malformed JSON surfaces as +// a typed `InitParseSettingsError` (recoverable, never a fiber defect) and a +// non-object document is rejected \u2014 matching Go's `json.Decoder` into a map. +function parseJsonObject(pathname: string, contents: string) { + return decodeJsonObject(stripJsonComments(contents)).pipe( + Effect.mapError( + (error) => + new InitParseSettingsError({ + detail: `Could not parse JSON in ${pathname}: ${error.message}`, + suggestion: `Fix or remove ${pathname}, then rerun \`supabase init\`.`, + }), + ), + ); +} + +export interface ProjectInitOptions { + readonly cwd: string; + readonly force: boolean; + readonly useOrioledb: boolean; + readonly interactive: boolean; + readonly withVscodeSettings: boolean; + readonly withIntellijSettings: boolean; +} + +function writeJsonFile(pathname: string, contents: Record) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`); + }); +} + +function updateJsonFile(pathname: string, template: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + if (!(yield* fs.exists(pathname))) { + yield* fs.writeFileString(pathname, template); + return; + } + + const existing = yield* fs.readFileString(pathname); + if (existing.trim().length === 0) { + yield* fs.writeFileString(pathname, template); + return; + } + + const merged = { + ...(yield* parseJsonObject(pathname, existing)), + ...(yield* parseJsonObject(pathname, template)), + }; + yield* writeJsonFile(pathname, merged); + }); +} + +const writeVscodeConfig = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + + const vscodeDir = path.join(cwd, ".vscode"); + const extensionsPath = path.join(vscodeDir, "extensions.json"); + const settingsPath = path.join(vscodeDir, "settings.json"); + + yield* fs.makeDirectory(vscodeDir, { recursive: true }); + yield* updateJsonFile(extensionsPath, VSCODE_EXTENSIONS_TEMPLATE); + yield* updateJsonFile(settingsPath, VSCODE_SETTINGS_TEMPLATE); + + yield* output.raw("Generated VS Code settings in .vscode/settings.json.\n"); + yield* output.raw( + "Please install the Deno extension for VS Code: https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno\n", + ); +}); + +const writeIntelliJConfig = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + + const intellijDir = path.join(cwd, ".idea"); + const denoPath = path.join(intellijDir, "deno.xml"); + + yield* fs.makeDirectory(intellijDir, { recursive: true }); + yield* fs.writeFileString(denoPath, INTELLIJ_DENO_TEMPLATE); + + yield* output.raw("Generated IntelliJ settings in .idea/deno.xml.\n"); + yield* output.raw( + "Please install the Deno plugin for IntelliJ: https://plugins.jetbrains.com/plugin/14382-deno\n", + ); +}); + +const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string) { + const output = yield* Output; + + if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { + yield* writeVscodeConfig(cwd); + return; + } + + if ( + yield* output.promptConfirm("Generate IntelliJ IDEA settings for Deno?", { + defaultValue: false, + }) + ) { + yield* writeIntelliJConfig(cwd); + } +}); + +const isInGitRepo = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (let current = cwd; ; current = path.dirname(current)) { + if (yield* fs.exists(path.join(current, ".git"))) { + return true; + } + const parent = path.dirname(current); + if (parent === current) { + return false; + } + } +}); + +const ensureSupabaseGitignore = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + if (!(yield* isInGitRepo(cwd))) { + return; + } + + const gitignorePath = path.join(cwd, "supabase", ".gitignore"); + + if (yield* fs.exists(gitignorePath)) { + const existing = yield* fs.readFileString(gitignorePath); + if (existing.includes(INIT_GITIGNORE_TEMPLATE)) { + return; + } + const prefix = existing.length > 0 ? "\n" : ""; + yield* fs.writeFileString(gitignorePath, `${existing}${prefix}${INIT_GITIGNORE_TEMPLATE}`); + return; + } + + yield* fs.writeFileString(gitignorePath, INIT_GITIGNORE_TEMPLATE); +}); + +/** + * Scaffolds the local project files (config.toml, .gitignore, optional IDE + * settings). This owns the mechanical filesystem work only — it does not decide + * how an already-initialized project is reported. When `config.toml` already + * exists and `force` is not set it short-circuits with `created: false` and + * writes nothing, leaving each shell free to treat that as a hard error (legacy + * Go parity) or a graceful no-op (next). + */ +export const initProject = Effect.fnUntraced(function* (options: ProjectInitOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tty = yield* Tty; + const output = yield* Output; + + const supabaseDir = path.join(options.cwd, "supabase"); + const configTomlPath = path.join(supabaseDir, "config.toml"); + const existingToml = yield* fs.exists(configTomlPath); + + if (existingToml && !options.force) { + return { created: false, configPath: configTomlPath }; + } + + const projectId = sanitizeProjectId(path.basename(options.cwd)) || "supabase"; + + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + configTomlPath, + renderProjectConfigTemplate(projectId, options.useOrioledb), + ); + yield* ensureSupabaseGitignore(options.cwd); + + const effectiveInteractive = options.interactive && tty.stdinIsTty && output.interactive; + if (effectiveInteractive) { + yield* promptForIdeSettings(options.cwd); + } + if (options.withVscodeSettings) { + yield* writeVscodeConfig(options.cwd); + } + if (options.withIntellijSettings) { + yield* writeIntelliJConfig(options.cwd); + } + + return { created: true, configPath: configTomlPath }; +}); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 1b16c86ed7..65c18177d3 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -307,9 +307,14 @@ export const textOutputLayer = Layer.effect( } return value.trim(); }), - promptConfirm: (message: string) => + promptConfirm: (message: string, opts?: { defaultValue?: boolean }) => Effect.gen(function* () { - const value = yield* Effect.promise(() => confirm({ message })); + const value = yield* Effect.promise(() => + confirm({ + message, + initialValue: opts?.defaultValue, + }), + ); if (isCancel(value)) { cancel("Operation cancelled."); return yield* Effect.interrupt; diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index 5f394b6188..36b911740f 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -47,7 +47,10 @@ interface OutputShape { opts?: { validate?: (v: string) => string | undefined; defaultValue?: string }, ) => Effect.Effect; readonly promptPassword: (message: string) => Effect.Effect; - readonly promptConfirm: (message: string) => Effect.Effect; + readonly promptConfirm: ( + message: string, + opts?: { defaultValue?: boolean }, + ) => Effect.Effect; readonly promptSelect: ( message: string, options: ReadonlyArray, diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index cbab509eef..1689ca1086 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -374,7 +374,7 @@ export function mockOutput( }; })(), promptPassword: () => Effect.succeed(promptPasswordResponses.shift() ?? ""), - promptConfirm: () => + promptConfirm: (_message, _opts) => Effect.succeed( promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, ), From 05380b0fd2c6051bf8e71e94a5016762ddbda6ab Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 12:15:10 +0200 Subject: [PATCH 22/38] ci(release): use app token checkout for release pushes (#5439) ## What changed The release publish job now checks out the repository with the minted GitHub App token instead of the default Actions token. ## Why Release tag and semantic-release note pushes are plain git operations. The default Actions token cannot push refs to commits that include workflow-file changes, while `gh auth setup-git` does not configure credentials for these non-interactive git pushes in this workflow. This matches the existing release fast-forward job pattern: mint the repository App token, pass it to checkout, let checkout persist those credentials, then run normal git commands. --- .github/workflows/release-shared.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index d381ccd8b3..105747b74f 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -198,7 +198,8 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - persist-credentials: false + persist-credentials: true + token: ${{ steps.app-token.outputs.token }} - name: Setup uses: ./.github/actions/setup @@ -215,10 +216,7 @@ jobs: run: pnpm exec bun apps/cli/scripts/publish.ts --tag "${NPM_TAG}" - name: Configure git for release pushes - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: | - gh auth setup-git git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From db543b4f507601f6995d1dbfc55585810ec131c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:12:34 +0200 Subject: [PATCH 23/38] fix(deps): bump the actions-major group across 1 directory with 14 updates (#5342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.1.1` | `3.2.0` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4.6.2` | `7.0.1` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4.3.0` | `8.0.1` | | [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `9.2.0` | `9.2.1` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.35.2` | `4.36.0` | | [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) | `6.1.0` | `6.1.1` | | [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` | | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3.7.0` | `4.0.0` | | [actions/cache](https://github.com/actions/cache) | `4.3.0` | `5.0.5` | | [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2.6.1` | `3.0.0` | | [supabase/setup-cli](https://github.com/supabase/setup-cli) | `1.7.1` | `2.1.1` | | [nrwl/nx-set-shas](https://github.com/nrwl/nx-set-shas) | `4.4.0` | `5.0.1` | Updates `actions/create-github-app-token` from 3.1.1 to 3.2.0
Release notes

Sourced from actions/create-github-app-token's releases.

v3.2.0

3.2.0 (2026-05-12)

Features

  • add support for enterprise-level GitHub Apps (#263) (952a2a7)
  • support full repository names in repositories input (#372) (85eb8dd)

Bug Fixes

  • deps: bump @​actions/core from 3.0.0 to 3.0.1 in the production-dependencies group (#364) (43e5c34)
  • validate private-key input (#376) (f24bbd8)
Changelog

Sourced from actions/create-github-app-token's changelog.

Changelog

3.2.0 (2026-05-12)

Features

  • add support for enterprise-level GitHub Apps (#263) (952a2a7)
  • support full repository names in repositories input (#372) (85eb8dd)

Bug Fixes

  • deps: bump @​actions/core from 3.0.0 to 3.0.1 in the production-dependencies group (#364) (43e5c34)
  • validate private-key input (#376) (f24bbd8)
Commits
  • bcd2ba4 chore(main): release 3.2.0 (#370)
  • f24bbd8 fix: validate private-key input (#376)
  • 363531b docs: capitalize Git as a proper noun in README (#374)
  • fd28011 docs: update procedure to configure Git (#287)
  • 85eb8dd feat: support full repository names in repositories input (#372)
  • c9aabb8 build(deps-dev): bump yaml from 2.8.3 to 2.8.4 in the development-dependencie...
  • e02e816 build(deps-dev): bump undici from 7.24.6 to 8.2.0 (#366)
  • 8d835bf build(deps-dev): bump esbuild from 0.27.4 to 0.28.0 in the development-depend...
  • 952a2a7 feat: add support for enterprise-level GitHub Apps (#263)
  • 43e5c34 fix(deps): bump @​actions/core from 3.0.0 to 3.0.1 in the production-dependenc...
  • Additional commits viewable in compare view

Updates `actions/upload-artifact` from 4.6.2 to 7.0.1
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v7...v7.0.1

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0

v5.0.0

What's Changed

... (truncated)

Commits
  • 043fb46 Merge pull request #797 from actions/yacaovsnc/update-dependency
  • 634250c Include changes in typespec/ts-http-runtime 0.3.5
  • e454baa Readme: bump all the example versions to v7 (#796)
  • 74fad66 Update the readme with direct upload details (#795)
  • bbbca2d Support direct file uploads (#764)
  • 589182c Upgrade the module to ESM and bump dependencies (#762)
  • 47309c9 Merge pull request #754 from actions/Link-/add-proxy-integration-tests
  • 02a8460 Add proxy integration test
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • Additional commits viewable in compare view

Updates `actions/download-artifact` from 4.3.0 to 8.0.1
Release notes

Sourced from actions/download-artifact's releases.

v8.0.1

What's Changed

Full Changelog: https://github.com/actions/download-artifact/compare/v8...v8.0.1

v8.0.0

v8 - What's new

[!IMPORTANT] actions/download-artifact@v8 has been migrated to an ESM module. This should be transparent to the caller but forks might need to make significant changes.

[!IMPORTANT] Hash mismatches will now error by default. Users can override this behavior with a setting change (see below).

Direct downloads

To support direct uploads in actions/upload-artifact, the action will no longer attempt to unzip all downloaded files. Instead, the action checks the Content-Type header ahead of unzipping and skips non-zipped files. Callers wishing to download a zipped file as-is can also set the new skip-decompress parameter to true.

Enforced checks (breaking)

A previous release introduced digest checks on the download. If a download hash didn't match the expected hash from the server, the action would log a warning. Callers can now configure the behavior on mismatch with the digest-mismatch parameter. To be secure by default, we are now defaulting the behavior to error which will fail the workflow run.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

Full Changelog: https://github.com/actions/download-artifact/compare/v7...v8.0.0

v7.0.0

v7 - What's new

[!IMPORTANT] actions/download-artifact@v7 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v6 had preliminary support for Node 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

... (truncated)

Commits
  • 3e5f45b Add regression tests for CJK characters (#471)
  • e6d03f6 Add a regression test for artifact name + content-type mismatches (#472)
  • 70fc10c Merge pull request #461 from actions/danwkennedy/digest-mismatch-behavior
  • f258da9 Add change docs
  • ccc058e Fix linting issues
  • bd7976b Add a setting to specify what to do on hash mismatch and default it to error
  • ac21fcf Merge pull request #460 from actions/danwkennedy/download-no-unzip
  • 15999bf Add note about package bumps
  • 974686e Bump the version to v8 and add release notes
  • fbe48b1 Update test names to make it clearer what they do
  • Additional commits viewable in compare view

Updates `golangci/golangci-lint-action` from 9.2.0 to 9.2.1
Release notes

Sourced from golangci/golangci-lint-action's releases.

v9.2.1

What's Changed

IMPORTANT: this is the first immutable release.

Changes

Dependencies

Full Changelog: https://github.com/golangci/golangci-lint-action/compare/v9.2.0...v9.2.1

Commits
  • 82606bf chore: prepare release v9.2.1
  • 97c8387 chore: improve workflows (#1394)
  • 28d0a19 build(deps): bump the dependencies group across 1 directory with 2 updates
  • 633fbc7 build(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#1391)
  • 59f43e2 build(deps): bump github/codeql-action from 4.35.2 to 4.35.3 (#1389)
  • 9eb174e build(deps): bump fast-xml-builder from 1.1.5 to 1.2.0 (#1386)
  • 4f52504 build(deps): bump github/codeql-action from 4 to 4.35.2 (#1384)
  • 6f87dfd docs: update examples
  • c9500d7 chore: improve workflows
  • 03b1faa chore: improve issue templates
  • Additional commits viewable in compare view

Updates `github/codeql-action` from 4.35.2 to 4.36.0
Release notes

Sourced from github/codeql-action's releases.

v4.36.0

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

v4.35.5

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

v4.35.4

  • Update default CodeQL bundle version to 2.25.4. #3881

v4.35.3

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Update default CodeQL bundle version to 2.25.3. #3865
Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Update default CodeQL bundle version to 2.25.3. #3865

4.35.2 - 15 Apr 2026

  • The undocumented TRAP cache cleanup feature that could be enabled using the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment variable is deprecated and will be removed in May 2026. If you are affected by this, we recommend disabling TRAP caching by passing the trap-caching: false input to the init Action. #3795
  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789
  • Python analysis on GHES no longer extracts the standard library, relying instead on models of the standard library. This should result in significantly faster extraction and analysis times, while the effect on alerts should be minimal. #3794
  • Fixed a bug in the validation of OIDC configurations for private registries that was added in CodeQL Action 4.33.0 / 3.33.0. #3807
  • Update default CodeQL bundle version to 2.25.2. #3823

4.35.1 - 27 Mar 2026

4.35.0 - 27 Mar 2026

... (truncated)

Commits
  • 7211b7c Merge pull request #3927 from github/update-v4.36.0-ebc2d9e2b
  • 7740f2f Update changelog for v4.36.0
  • ebc2d9e Merge pull request #3926 from github/update-bundle/codeql-bundle-v2.25.5
  • d1f74b7 Add changelog note
  • 2dc40ce Update default bundle to codeql-bundle-v2.25.5
  • 8449852 Merge pull request #3910 from github/henrymercer/repo-size-diff-check
  • 72ac23c Update excluded required check list
  • c5297a2 Merge pull request #3919 from github/henrymercer/workflow-concurrency
  • 8ffeae7 CI: Automatically cancel non-generated workflows
  • f3f52bf Revert getErrorMessage import
  • Additional commits viewable in compare view

Updates `aws-actions/configure-aws-credentials` from 6.1.0 to 6.1.1
Release notes

Sourced from aws-actions/configure-aws-credentials's releases.

v6.1.1

What's Changed

Full Changelog: https://github.com/aws-actions/configure-aws-credentials/compare/v6...v6.1.1

Changelog

Sourced from aws-actions/configure-aws-credentials's changelog.

Changelog

All notable changes to this project will be documented in this file. See standard-version for commit guidelines.

6.1.2 (2026-05-26)

Features

Bug Fixes

  • additional filesystem checks (#1799) (c39f282)
  • skip credential check on output-env-credentials: false (#1778) (58e7c47)

6.1.1 (2026-05-05)

Miscellaneous Chores

  • various dependency updates

6.1.0 (2026-04-06)

Features

6.0.0 (2026-02-04)

⚠ BREAKING CHANGES

Features

Bug Fixes

... (truncated)

Commits
  • d979d5b chore: release 6.1.1 (#1757)
  • d4a9acd chore: Update dist
  • fc44f4a chore(deps): bump @​aws-sdk/client-sts from 3.1033.0 to 3.1038.0 (#1749)
  • 0b8336f chore: Update dist
  • 8c5bf33 chore(deps-dev): bump @​aws-sdk/credential-provider-env (#1751)
  • 53df0c1 chore: Update dist
  • c2c5582 chore(deps): bump @​smithy/node-http-handler from 4.6.0 to 4.6.1 (#1750)
  • bd0031d chore(deps): bump postcss from 8.5.6 to 8.5.12 (#1752)
  • 6ab499a chore(deps-dev): bump @​biomejs/biome from 2.4.12 to 2.4.13 (#1747)
  • bc94895 chore(deps-dev): bump @​biomejs/biome from 2.4.11 to 2.4.12 (#1739)
  • Additional commits viewable in compare view

Updates `docker/login-action` from 4.1.0 to 4.2.0
Release notes

Sourced from docker/login-action's releases.

v4.2.0

Full Changelog: https://github.com/docker/login-action/compare/v4.1.0...v4.2.0

Commits
  • 650006c Merge pull request #960 from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...
  • 99df1a3 chore: update generated content
  • 3ab375f build(deps): bump the aws-sdk-dependencies group across 1 directory with 2 up...
  • 39d8580 Merge pull request #970 f... _Description has been truncated_ Co-authored-by: Julien Goux --- .github/dependabot.yml | 4 ++++ .github/workflows/automerge.yml | 2 +- .github/workflows/build-cli-artifacts.yml | 2 +- .github/workflows/cli-go-api-sync.yml | 2 +- .github/workflows/cli-go-ci.yml | 2 +- .github/workflows/cli-go-codeql.yml | 4 ++-- .github/workflows/cli-go-mirror-image.yml | 6 +++--- .github/workflows/cli-go-pg-prove.yml | 14 ++++++------- .github/workflows/cli-go-publish-migra.yml | 14 ++++++------- .github/workflows/deploy.yml | 2 +- .../publish-preview-cli-packages.yml | 2 +- .github/workflows/release-shared.yml | 20 +++++++++---------- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 2 +- 14 files changed, 42 insertions(+), 38 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f2f390ed8e..1b4bb74c8c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,10 @@ updates: actions-major: patterns: - "*" + ignore: + - dependency-name: "supabase/setup-cli" + update-types: + - "version-update:semver-major" cooldown: default-days: 7 - package-ecosystem: "gomod" diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index cafc5eedca..0089bccc5e 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -24,7 +24,7 @@ jobs: - name: Generate token id: app-token if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 2b01361340..db2812719b 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -80,7 +80,7 @@ jobs: ls -la dist/ - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} path: | diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 7bd7f4bf87..28dc0ee79a 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -39,7 +39,7 @@ jobs: - name: Generate token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 58482c2e39..0be482bcb5 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -70,7 +70,7 @@ jobs: # Linter requires no cache cache: false - - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: args: --timeout 5m --verbose version: latest diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 9b5f9168e7..57222ed09b 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -69,7 +69,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -97,7 +97,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" defaults: diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 541f89cc3d..d248648788 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,14 +34,14 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: public.ecr.aws - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 1dff403bfc..d94a47448f 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -13,8 +13,8 @@ jobs: outputs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -44,15 +44,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 with: endpoint: builders - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: true context: https://github.com/horrendo/pg_prove.git @@ -68,8 +68,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index d62bc19ebc..37f5ca2ad5 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -13,8 +13,8 @@ jobs: outputs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -44,15 +44,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 with: endpoint: builders - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: true context: https://github.com/djrobstep/migra.git @@ -68,8 +68,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94d46b7686..af52b854fd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 persist-credentials: false - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 35d37403c7..18e72bf375 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -45,7 +45,7 @@ jobs: uses: ./.github/actions/setup - name: Download preview build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-legacy-${{ env.PREVIEW_VERSION }} diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 105747b74f..ab338a4708 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -88,7 +88,7 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -108,7 +108,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 # Cache the smoke-test base images across runs. Without this, eight # parallel `docker run` calls in smoke-test-linux.ts race on first-time @@ -118,7 +118,7 @@ jobs: - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/smoke-docker-images.tar key: smoke-docker-images-debian-bookworm-slim-amazonlinux-2023-alpine-3.21-v1 @@ -188,7 +188,7 @@ jobs: steps: - name: Generate release repository token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -205,7 +205,7 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -277,7 +277,7 @@ jobs: done - name: Create draft GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ inputs.version }} name: v${{ inputs.version }} @@ -340,13 +340,13 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - name: Generate Homebrew tap token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -385,13 +385,13 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - name: Generate Scoop bucket token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 505118e4eb..107424383c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: contents: write steps: - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} @@ -90,7 +90,7 @@ jobs: # same App used for fast-forward + brew/scoop pushes. - id: app-token if: github.event_name == 'push' - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 850bc66c3a..770a7242d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,7 +90,7 @@ jobs: - name: Set base and head SHAs for affected if: github.event_name == 'pull_request' - uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 + uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 - name: Setup uses: ./.github/actions/setup From 4013b728559dac34f555a7a3fc4a2f6031eaceb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:18:16 +0000 Subject: [PATCH 24/38] fix(docker): bump supabase/studio from 2026.06.01-sha-a4334a2 to 2026.06.03-sha-0bca601 in /apps/cli-go/pkg/config/templates in the docker-minor group (#5441) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 1 update: supabase/studio. Updates `supabase/studio` from 2026.06.01-sha-a4334a2 to 2026.06.03-sha-0bca601 [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/studio&package-manager=docker&previous-version=2026.06.01-sha-a4334a2&new-version=2026.06.03-sha-0bca601)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 748e2604a0..6c3c9107b5 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,7 +5,7 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit FROM postgrest/postgrest:v14.12 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.06.01-sha-a4334a2 AS studio +FROM supabase/studio:2026.06.03-sha-0bca601 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector From 0ada7aa8c342781b1a183dc43abed5af8f6074df Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 13:29:24 +0200 Subject: [PATCH 25/38] chore(ci): add CLI preview PR comment (#5440) Adds a dedicated follow-up job to the preview CLI package workflow that posts or updates a sticky PR comment with the copy-pasteable `npx --yes https://pkg.pr.new/supabase@` command. The pkg.pr.new publish step stays in `--comment=off` mode so reviewers see the CLI command without the platform wrapper package list, and the comment write permission is isolated from the job that checks out and runs PR code. --- .../publish-preview-cli-packages.yml | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 18e72bf375..d2e25d36f1 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -80,3 +80,65 @@ jobs: preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" echo "Preview command: npx ${preview_url}" npx --yes "${preview_url}" --version + + comment: + needs: publish + if: github.event.pull_request.draft == false && needs.publish.result == 'success' + name: Post preview command comment + runs-on: ubuntu-latest + permissions: + issues: write + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Post preview command comment + run: | + set -euo pipefail + + marker="" + preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + short_sha="${HEAD_SHA:0:7}" + comment_file="$(mktemp)" + + cat > "${comment_file}" </dev/null; then + echo "::warning::Unable to update the preview package PR comment." + exit 0 + fi + else + if ! gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --field "body=@${comment_file}" \ + >/dev/null; then + echo "::warning::Unable to create the preview package PR comment." + exit 0 + fi + fi From 86d0f02377f54170de85b8cb7dfbee4e8cd58e49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:38:46 +0000 Subject: [PATCH 26/38] chore(ci): bump the actions-major group with 2 updates (#5443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 2 updates: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) and [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `aws-actions/configure-aws-credentials` from 6.1.1 to 6.1.2
    Release notes

    Sourced from aws-actions/configure-aws-credentials's releases.

    v6.1.2

    6.1.2 (2026-05-26)

    Bug Fixes

    Changelog

    Sourced from aws-actions/configure-aws-credentials's changelog.

    Changelog

    All notable changes to this project will be documented in this file. See standard-version for commit guidelines.

    6.2.0 (2026-06-01)

    Features

    Bug Fixes

    • skip credential check on output-env-credentials: false (#1778) (58e7c47)
    • assumeRole failing from session tag size too large (#1808) (d6f5dc3)

    6.1.3 (2026-05-28)

    Bug Fixes

    • fix: allow kubelet token symlink in #1805

    6.1.2 (2026-05-26)

    Bug Fixes

    6.1.1 (2026-05-05)

    Miscellaneous Chores

    • various dependency updates

    6.1.0 (2026-04-06)

    Features

    6.0.0 (2026-02-04)

    ... (truncated)

    Commits
    • acca2b1 chore(main): release 6.1.2 (#1761)
    • c329d24 chore: Update dist
    • c39f282 fix: additional filesystem checks (#1799)
    • 8188bee chore(deps-dev): bump @​types/node from 25.6.0 to 25.9.1 (#1795)
    • 477988d chore(deps-dev): bump @​smithy/property-provider from 4.2.14 to 4.3.4 (#1798)
    • 9a5ab5b chore: Update dist
    • baa1fdf chore(deps): bump @​aws-sdk/client-sts from 3.1038.0 to 3.1053.0 (#1793)
    • 4be0a3c chore(deps-dev): bump generate-license-file from 4.1.1 to 4.2.1 (#1794)
    • f85f964 chore: Update dist
    • 6fddd0c chore(deps-dev): bump @​aws-sdk/credential-provider-env (#1791)
    • Additional commits viewable in compare view

    Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
    Release notes

    Sourced from docker/setup-qemu-action's releases.

    v4.1.0

    Full Changelog: https://github.com/docker/setup-qemu-action/compare/v4.0.0...v4.1.0

    Commits
    • 0611638 Merge pull request #21 from crazy-max/uninst
    • ce59c81 chore: update generated content
    • 2ddad44 uninstall current emulators
    • 8c37cd6 Merge pull request #250 from docker/dependabot/npm_and_yarn/docker/actions-to...
    • d1a0ff3 chore: update generated content
    • 0a8f3dc build(deps): bump @​docker/actions-toolkit from 0.79.0 to 0.91.0
    • 9430f61 Merge pull request #291 from docker/dependabot/npm_and_yarn/tmp-0.2.6
    • 978bd77 chore: update generated content
    • 3479feb build(deps): bump tmp from 0.2.5 to 0.2.6
    • b113c26 Merge pull request #255 from docker/dependabot/npm_and_yarn/fast-xml-parser-5...
    • Additional commits viewable in compare view

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- .github/workflows/cli-go-mirror-image.yml | 2 +- .github/workflows/release-shared.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index d248648788..c608212502 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,7 +34,7 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 + uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index ab338a4708..710d37b0dc 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -108,7 +108,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 # Cache the smoke-test base images across runs. Without this, eight # parallel `docker run` calls in smoke-test-linux.ts race on first-time From abe75d88415ce96a9b1e6739b9f72e1a2295a8f1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 13:51:25 +0200 Subject: [PATCH 27/38] chore(ci): use non-releasing actions dependabot prefix (#5442) Updates the GitHub Actions Dependabot group to use a `chore(ci):` commit-message prefix. This keeps future grouped workflow-only dependency updates from using `fix(deps):`, which would be release-significant when the PR is squash-merged into `develop`. --- .github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1b4bb74c8c..ee905b6558 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,8 @@ updates: schedule: interval: "cron" cronjob: "0 0 * * *" + commit-message: + prefix: "chore(ci): " groups: actions-major: patterns: From 92638ef46d7fd9b377ffbf6c0c6a7a96e8375ab7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 14:24:34 +0200 Subject: [PATCH 28/38] fix(cli): inject S3 and sb key env variables into Studio (#5438) ## What changed This wires Studio with the local S3 protocol credentials and the newer opaque Supabase key environment variables in both the Go CLI stack and the TypeScript stack implementation. The change keeps the existing legacy Studio key variables intact while adding `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`, `S3_PROTOCOL_ACCESS_KEY_ID`, and `S3_PROTOCOL_ACCESS_KEY_SECRET`. ## Why Studio expects these values for local S3 key access and newer key handling, but the CLI-managed Studio container only received the legacy key variables before this change. --- apps/cli-go/internal/start/start.go | 54 +++++++++++-------- apps/cli-go/internal/start/start_test.go | 44 +++++++++++++++ packages/stack/src/StackBuilder.ts | 8 ++- .../stack/src/services/services.unit.test.ts | 35 ++++++++++++ packages/stack/src/services/storage.ts | 7 ++- packages/stack/src/services/studio.ts | 6 +++ 6 files changed, 128 insertions(+), 26 deletions(-) diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 881f8dab73..6ce6a4434d 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -1107,29 +1107,7 @@ EOF ctx, container.Config{ Image: utils.Config.Studio.Image, - Env: []string{ - "CURRENT_CLI_VERSION=" + utils.Version, - "STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080", - "POSTGRES_PASSWORD=" + dbConfig.Password, - "SUPABASE_URL=http://" + utils.KongId + ":8000", - "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, - "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, - "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, - "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, - "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, - "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), - "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), - fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), - fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId), - fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), - fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), - "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), - "SNIPPETS_MANAGEMENT_FOLDER=" + containerSnippetsPath, - // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 - "HOSTNAME=0.0.0.0", - "POSTGRES_USER_READ_WRITE=postgres", - }, + Env: buildStudioEnv(dbConfig, workdir, containerSnippetsPath), Healthcheck: &container.HealthConfig{ Test: []string{"CMD-SHELL", `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`}, Interval: 10 * time.Second, @@ -1280,6 +1258,36 @@ func formatMapForEnvConfig(input map[string]string, output *bytes.Buffer) { } } +func buildStudioEnv(dbConfig pgconn.Config, workdir, containerSnippetsPath string) []string { + return []string{ + "CURRENT_CLI_VERSION=" + utils.Version, + "STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080", + "POSTGRES_PASSWORD=" + dbConfig.Password, + "SUPABASE_URL=http://" + utils.KongId + ":8000", + "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, + "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, + "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, + "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, + "SUPABASE_PUBLISHABLE_KEY=" + utils.Config.Auth.PublishableKey.Value, + "SUPABASE_SECRET_KEY=" + utils.Config.Auth.SecretKey.Value, + "S3_PROTOCOL_ACCESS_KEY_ID=" + utils.Config.Storage.S3Credentials.AccessKeyId, + "S3_PROTOCOL_ACCESS_KEY_SECRET=" + utils.Config.Storage.S3Credentials.SecretAccessKey, + "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, + "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, + "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), + "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), + fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), + fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId), + fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), + fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), + "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), + "SNIPPETS_MANAGEMENT_FOLDER=" + containerSnippetsPath, + // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 + "HOSTNAME=0.0.0.0", + "POSTGRES_USER_READ_WRITE=postgres", + } +} + func buildGotrueEnv(dbConfig pgconn.Config) []string { var testOTP bytes.Buffer if len(utils.Config.Auth.Sms.TestOTP) > 0 { diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index ee468f562c..afafbd815e 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -387,6 +387,50 @@ func TestBuildGotrueEnv(t *testing.T) { }) } +func TestBuildStudioEnv(t *testing.T) { + originalConfig := utils.Config + originalKongId := utils.KongId + originalPgmetaId := utils.PgmetaId + originalLogflareId := utils.LogflareId + originalVersion := utils.Version + t.Cleanup(func() { + utils.Config = originalConfig + utils.KongId = originalKongId + utils.PgmetaId = originalPgmetaId + utils.LogflareId = originalLogflareId + utils.Version = originalVersion + }) + + utils.Config = config.NewConfig() + utils.Config.Studio.ApiUrl = "http://127.0.0.1:54321" + utils.Config.Auth.JwtSecret.Value = "jwt-secret" + utils.Config.Auth.AnonKey.Value = "anon-key" + utils.Config.Auth.ServiceRoleKey.Value = "service-role-key" + utils.Config.Auth.PublishableKey.Value = "sb_publishable_test" + utils.Config.Auth.SecretKey.Value = "sb_secret_test" + utils.Config.Storage.S3Credentials.AccessKeyId = "s3-access-key" + utils.Config.Storage.S3Credentials.SecretAccessKey = "s3-secret-key" + utils.KongId = "test-kong" + utils.PgmetaId = "test-pgmeta" + utils.LogflareId = "test-logflare" + utils.Version = "test-version" + + env := envToMap(buildStudioEnv( + pgconn.Config{Password: "postgres"}, + "/project", + "/project/supabase/.temp/snippets", + )) + + assert.Equal(t, "anon-key", env["SUPABASE_ANON_KEY"]) + assert.Equal(t, "service-role-key", env["SUPABASE_SERVICE_KEY"]) + assert.Equal(t, "sb_publishable_test", env["SUPABASE_PUBLISHABLE_KEY"]) + assert.Equal(t, "sb_secret_test", env["SUPABASE_SECRET_KEY"]) + assert.Equal(t, "s3-access-key", env["S3_PROTOCOL_ACCESS_KEY_ID"]) + assert.Equal(t, "s3-secret-key", env["S3_PROTOCOL_ACCESS_KEY_SECRET"]) + assert.Equal(t, "http://test-kong:8000", env["SUPABASE_URL"]) + assert.Equal(t, "http://test-pgmeta:8080", env["STUDIO_PG_META_URL"]) +} + func TestFormatMapForEnvConfig(t *testing.T) { t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) { testcases := []struct { diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 504f8d57d6..9975ca4fb0 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -27,7 +27,11 @@ import { makePostgresService, makePostgresServiceDocker } from "./services/postg import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; import { makeRealtimeServiceDocker } from "./services/realtime.ts"; import { type ServiceDependency } from "./services/service-utils.ts"; -import { makeStorageServiceDocker } from "./services/storage.ts"; +import { + LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceDocker, +} from "./services/storage.ts"; import { makeStudioServiceDocker } from "./services/studio.ts"; import { makeVectorServiceDocker } from "./services/vector.ts"; import type { PreparedStackArtifacts } from "./StackPreparation.ts"; @@ -879,6 +883,8 @@ export class StackBuilder extends Context.Service< pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, publishableKey: config.publishableKey, secretKey: config.secretKey, + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, jwtSecret: config.jwtSecret, analyticsEnabled: config.analytics !== false, analyticsBackend: config.analytics !== false ? config.analytics.backend : "postgres", diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 6ddb1354c7..2db96bb9af 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -14,6 +14,8 @@ import { import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService } from "./postgrest.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; +import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET } from "./storage.ts"; +import { makeStudioServiceDocker } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -81,6 +83,39 @@ describe("analyticsDockerRuntimeNetwork", () => { }); }); +describe("makeStudioServiceDocker", () => { + it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { + const def = makeStudioServiceDocker({ + image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), + apiPort: API_PORT, + port: 54323, + apiUrl: "http://host.docker.internal:54321", + publicApiUrl: "http://127.0.0.1:54321", + pgmetaUrl: "http://host.docker.internal:54322", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + jwtSecret: JWT_SECRET, + analyticsEnabled: true, + analyticsBackend: "postgres", + analyticsUrl: "http://host.docker.internal:54327", + analyticsApiKey: "test-api-key", + networkArgs: ["-p", "54323:54323"], + dependencies: [{ service: "pgmeta", condition: "healthy" }], + }); + + expect(def.args).toContain("SUPABASE_ANON_KEY=sb_publishable_test"); + expect(def.args).toContain("SUPABASE_SERVICE_KEY=sb_secret_test"); + expect(def.args).toContain("SUPABASE_PUBLISHABLE_KEY=sb_publishable_test"); + expect(def.args).toContain("SUPABASE_SECRET_KEY=sb_secret_test"); + expect(def.args).toContain(`S3_PROTOCOL_ACCESS_KEY_ID=${LOCAL_S3_PROTOCOL_ACCESS_KEY_ID}`); + expect(def.args).toContain( + `S3_PROTOCOL_ACCESS_KEY_SECRET=${LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET}`, + ); + }); +}); + describe("makePostgresService (dockerAccessible)", () => { it("creates per-run pg_hba.conf instead of mutating shared cache", () => { const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index beb4f3b88b..056a27605e 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -24,6 +24,9 @@ interface DockerStorageOptions { const STORAGE_DATA_DIR = "/var/lib/storage"; +export const LOCAL_S3_PROTOCOL_ACCESS_KEY_ID = "local"; +export const LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET = "local-secret"; + const orphanCleanup = (opts: DockerStorageOptions) => opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir, { recursive: true }) : []; @@ -66,8 +69,8 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef IMGPROXY_URL: opts.imgproxyUrl, TUS_URL_PATH: "/storage/v1/upload/resumable", S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), - S3_PROTOCOL_ACCESS_KEY_ID: "local", - S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", + S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, S3_PROTOCOL_PREFIX: "/storage/v1", UPLOAD_FILE_SIZE_LIMIT: "52428800000", UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index 18bc9275d4..7521fce3c7 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -10,6 +10,8 @@ interface DockerStudioOptions { readonly pgmetaUrl: string; readonly publishableKey: string; readonly secretKey: string; + readonly s3ProtocolAccessKeyId: string; + readonly s3ProtocolAccessKeySecret: string; readonly jwtSecret: string; readonly analyticsEnabled: boolean; readonly analyticsBackend: "postgres" | "bigquery"; @@ -48,6 +50,10 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = AUTH_JWT_SECRET: opts.jwtSecret, SUPABASE_ANON_KEY: opts.publishableKey, SUPABASE_SERVICE_KEY: opts.secretKey, + SUPABASE_PUBLISHABLE_KEY: opts.publishableKey, + SUPABASE_SECRET_KEY: opts.secretKey, + S3_PROTOCOL_ACCESS_KEY_ID: opts.s3ProtocolAccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: opts.s3ProtocolAccessKeySecret, LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.analyticsApiKey, LOGFLARE_URL: opts.analyticsUrl, NEXT_PUBLIC_ENABLE_LOGS: String(opts.analyticsEnabled), From e2064a8d7de8884c4f9a3c83ddcc030e6bcb97cc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 3 Jun 2026 14:47:11 +0200 Subject: [PATCH 29/38] ci(preview): allow preview package PR comment (#5444) ## What changed Changes the preview package comment job permission from `issues: write` to `pull-requests: write`. ## Why The preview package published successfully, but the custom `pkg.pr.new` PR comment failed with `Resource not accessible by integration`. The failing run was for a same-repository maintainer branch, so the issue was not fork trust. The workflow token needs PR write permission for the pull request comment operation in this repo/org context. --- .github/workflows/publish-preview-cli-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index d2e25d36f1..26f6174986 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -87,7 +87,7 @@ jobs: name: Post preview command comment runs-on: ubuntu-latest permissions: - issues: write + pull-requests: write env: GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} From 2329f649afb4dc8dc6b570eeb275c00e8d755727 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 3 Jun 2026 15:58:55 +0200 Subject: [PATCH 30/38] chore(release): add LLM release-notes prompt and approval-based publish pipeline (#5330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds an LLM-driven pipeline to turn semantic-release changelogs into user-centric Supabase CLI release notes, plus the prompt template that drives it. ### Prompt template (`tools/release/release-notes-prompt.md`) A self-contained prompt for generating release notes from a pasted semantic-release block. It covers: - **Input structure** — standardized fields for product name, audience, and tone - **Scope rules specific to the CLI** — filtering `next/` shell changes, handling Go-to-TypeScript port PRs, and identifying user-facing changes - **Investigation process** — step-by-step triage and PR analysis workflow so the model doesn't write from titles alone - **Classification system** — buckets for highlights, features, improvements, bug fixes, breaking changes, and internal work - **Writing guidelines** — voice, structure, and before/after examples showing the transformation from raw PR data to polished notes - **Output format** — markdown template with sections for breaking changes, highlights, features, improvements, bug fixes, and port progress The prompt answers three questions for users: **Should I upgrade?**, **What can I do now?**, and **What gotchas should I know about?** The template uses a `{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}` placeholder so scripts can inject the raw changelog at runtime. ### Automation (new in this PR) The prompt is wired into the release pipeline end-to-end: 1. **`backfill-release-notes.ts`** (existing) — writes the raw semantic-release block to the GitHub Release body after publish. 2. **`propose-release-notes.ts`** (new) — re-derives that block via `backfill-release-notes.ts`, renders the prompt, runs the **Claude Agent SDK** (`WebFetch`, `WebSearch`, `Bash`/`gh`) to investigate PRs per the prompt's step 3, writes `release-notes/v.md`, and opens a PR on branch `release-notes/v` with a `do not merge` label. 3. **`apply-release-notes.ts`** (new) — pushes the approved file's contents to the GitHub Release body via `gh release edit`. 4. **Workflows** - `propose-release-notes.yml` — callable from `release-shared.yml` after backfill (stable releases only; non-blocking so LLM failures never gate publish). Also runnable manually from Actions. - `apply-release-notes.yml` — on **approval** by an active `supabase/cli` team member (not merge): checks out the PR head, applies notes, comments the release URL, closes the PR, deletes the branch. The notes file never lands on `main`. Default model: `claude-haiku-4-5-20251001` at `effort: "low"`. Local iteration flags: `--dry-run`, `--render-only`, `--model`. ### Human review flow Release published → raw semantic-release body backfilled → propose workflow opens release-notes/vX.Y.Z PR → reviewer edits notes on the branch if needed → supabase/cli team member approves (not merges) → apply workflow updates GH Release body, closes PR Prereleases (`-beta.`, `-alpha.`) keep the raw semantic-release body; the proposer short-circuits. (can still generate an AI release not for beta channel by manual workflow dispatch). --- ## Explorations We tried several approaches before settling on the current design: ### Model tier / effort Tested Opus, Sonnet, and Haiku at various effort levels. Higher-tier models did not meaningfully improve output quality for typical release sizes, but cost jumped (~$0.50/generation vs ~$0.10 with Haiku). Worth re-assessing once we see releases with substantially more PRs. ### Local context pre-fetch Tried a "local agent" approach: download all PR/issue/code context upfront via `gh api`, write to local files, then point the agent at those files. This **increased** cost (~$0.30 vs ~$0.10) compared to letting the agent fetch what it needs via WebFetch/Bash, and added scripting overhead for context retrieval. Dropped in favor of the agent-driven investigation model. ### Cost optimizations that worked 1. **Lower model tier** — Haiku at low effort is the sweet spot for cost vs quality so far. 2. **Compress the prompt** — trimmed `release-notes-prompt.md` (detailed multi-page examples → compact quick-reference table) to cut input tokens without losing the classification/scope rules that matter. --- ## Possible follow-ups - **OpenRouter / multi-provider agent** — swap the Claude Agent SDK for an OpenRouter-backed agent to experiment with other models/providers more easily. Likely a separate PR. - **Re-evaluate model tier** on larger releases if Haiku quality degrades with volume. - **Prompt iteration** — use `--render-only` and `--dry-run` locally against past tags to tune scope rules as the Go→TS port accelerates. --- ## Context Initial prompt design session: https://claude.ai/code/session_01FgZaMWUbVrQaQUFRv9KKYn --------- Co-authored-by: Claude --- .github/workflows/apply-release-notes.yml | 98 ++++ .github/workflows/propose-release-notes.yml | 75 +++ .github/workflows/release-shared.yml | 14 + apps/cli/package.json | 8 +- apps/cli/scripts/apply-release-notes.ts | 37 ++ apps/cli/scripts/propose-release-notes.ts | 213 +++++++++ pnpm-lock.yaml | 490 +++++++++++++++++++- tools/release/release-notes-prompt.md | 164 +++++++ 8 files changed, 1091 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/apply-release-notes.yml create mode 100644 .github/workflows/propose-release-notes.yml create mode 100644 apps/cli/scripts/apply-release-notes.ts create mode 100644 apps/cli/scripts/propose-release-notes.ts create mode 100644 tools/release/release-notes-prompt.md diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml new file mode 100644 index 0000000000..823d923456 --- /dev/null +++ b/.github/workflows/apply-release-notes.yml @@ -0,0 +1,98 @@ +name: Apply release notes + +# Approval-based publish. When a member of the supabase/cli team approves a +# release-notes PR (head ref `release-notes/v`), this workflow pushes +# the proposed notes to the GitHub Release body for the corresponding tag, +# comments the release URL on the PR, and closes the PR without merging. The +# release-notes file never lands on `main`. +# +# Mirrors the fast-forward job in release.yml, which already gates on a +# `pull_request_review` + `approved` event. + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + apply: + # `state == 'open'` makes re-approvals on an already-closed PR a no-op + # (a reviewer can re-approve from the GitHub UI even after close). + if: | + github.event.review.state == 'approved' && + startsWith(github.event.pull_request.head.ref, 'release-notes/') && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.state == 'open' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # App token: needs `orgs/.../teams/.../memberships` read (the org-installed + # App has it), repo write to edit the release, and PR write to comment + # and close. Matches release.yml's fast-forward step. + - id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Authorize approver against supabase/cli team + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APPROVER: ${{ github.event.review.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Fail closed: any response other than an active membership means the + # approval is ignored. We post a comment so the reviewer sees why their + # approval didn't apply, then exit 0 so the workflow isn't flagged red. + run: | + set -euo pipefail + status=$(gh api \ + -H "Accept: application/vnd.github+json" \ + "orgs/supabase/teams/cli/memberships/${APPROVER}" \ + --jq '.state' 2>/dev/null || true) + if [ "$status" != "active" ]; then + echo "Approver @${APPROVER} is not an active supabase/cli team member (state='${status:-none}'); ignoring approval." >&2 + gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ + "@${APPROVER} is not an active \`supabase/cli\` team member, so this approval was ignored. Ask a team member to approve to publish the notes." + exit 0 + fi + echo "AUTHORIZED=true" >> "$GITHUB_ENV" + + # Checkout the PR head so any reviewer edits made in the GitHub UI before + # approval are captured. apply-release-notes.ts reads from the working + # tree. + - if: env.AUTHORIZED == 'true' + uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - if: env.AUTHORIZED == 'true' + uses: ./.github/actions/setup + + - name: Apply notes, comment, and close + if: env.AUTHORIZED == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + APPROVER: ${{ github.event.review.user.login }} + # The branch is named `release-notes/v`, so the tag is just + # the basename. apply-release-notes.ts validates the file's existence. + run: | + set -euo pipefail + tag="${HEAD_REF##release-notes/}" + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|alpha)\.[0-9]+)?$ ]]; then + echo "Unexpected head ref '$HEAD_REF'; cannot derive tag." >&2 + exit 1 + fi + echo "==> Applying notes for $tag" + pnpm exec bun apps/cli/scripts/apply-release-notes.ts --tag "$tag" + release_url="https://github.com/${{ github.repository }}/releases/tag/${tag}" + gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ + "Applied to [${tag}](${release_url}) after approval by @${APPROVER}." + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" --delete-branch diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml new file mode 100644 index 0000000000..179e116293 --- /dev/null +++ b/.github/workflows/propose-release-notes.yml @@ -0,0 +1,75 @@ +name: Propose release notes + +# Runs after backfill-release-notes lands the raw semantic-release block in the +# GitHub Release body. Re-derives that block, asks Claude to rewrite it into +# user-centric notes per tools/release/release-notes-prompt.md, and opens a PR +# adding release-notes/v.md. Merging the PR triggers +# apply-release-notes.yml, which pushes the file's contents to the GH Release. +# +# Stable releases only on the automatic release pipeline — prerelease tags +# (-beta./-alpha.) keep the raw body unless this workflow is triggered +# manually from the Actions tab (workflow_dispatch). + +on: + workflow_call: + inputs: + tag: + description: Release tag to propose notes for (e.g. v2.101.0) + required: true + type: string + non_blocking: + description: Do not fail the workflow run when proposing fails (release pipeline) + required: false + type: boolean + default: false + workflow_dispatch: + inputs: + tag: + description: Release tag to propose notes for (e.g. v2.101.0 or v2.99.0-beta.1) + required: true + type: string + +permissions: + contents: read + +jobs: + propose: + # workflow_call (release pipeline): skip prereleases. workflow_dispatch: + # allow any tag so reviewers can opt in for beta/alpha from the Actions tab. + if: ${{ github.event_name == 'workflow_dispatch' || (!contains(inputs.tag, '-beta.') && !contains(inputs.tag, '-alpha.')) }} + runs-on: ubuntu-latest + continue-on-error: ${{ inputs.non_blocking }} + permissions: + contents: write + pull-requests: write + env: + TAG: ${{ inputs.tag }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + # App token gets us push to a protected default branch *and* PR creation + # under the App identity, matching the rest of release.yml. + - id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + # Full history + tags so backfill-release-notes.ts can reach the + # commit graph it needs (semantic-release walks notes back to the + # last release on the channel). + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: ./.github/actions/setup + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Propose release notes + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: pnpm exec bun apps/cli/scripts/propose-release-notes.ts --tag "${TAG}" --apply diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 710d37b0dc..6a5422fc82 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -323,6 +323,20 @@ jobs: apply: true non_blocking: true + # Once the raw semantic-release block is in the release body, ask Claude to + # rewrite it into user-centric notes and open a PR for human approval. Stable + # releases only on this path — prereleases keep the raw body. Non-blocking so + # an LLM hiccup never gates a published release; reviewers can propose beta/ + # alpha notes manually from the Actions tab (workflow_dispatch). + propose-release-notes: + uses: ./.github/workflows/propose-release-notes.yml + needs: backfill-release-notes + if: ${{ !inputs.dry_run && !inputs.prerelease && needs.backfill-release-notes.result == 'success' }} + with: + tag: v${{ inputs.version }} + non_blocking: true + secrets: inherit + publish-homebrew: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} diff --git a/apps/cli/package.json b/apps/cli/package.json index fa98c8d27c..0bc2ba0687 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -37,10 +37,13 @@ "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.146", + "@anthropic-ai/sdk": "^0.97.1", "@clack/prompts": "^1.4.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", "@parcel/watcher": "^2.5.6", "@supabase/api": "workspace:*", @@ -120,7 +123,10 @@ "oxfmt", "oxlint", "oxlint-tsgolint", - "semantic-release" + "semantic-release", + "@anthropic-ai/claude-agent-sdk", + "@anthropic-ai/sdk", + "@modelcontextprotocol/sdk" ] }, "nx": { diff --git a/apps/cli/scripts/apply-release-notes.ts b/apps/cli/scripts/apply-release-notes.ts new file mode 100644 index 0000000000..9027208231 --- /dev/null +++ b/apps/cli/scripts/apply-release-notes.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env bun +// Push the contents of release-notes/v.md to the GitHub Release body +// for tag v. Invoked from apply-release-notes.yml after a +// release-notes PR is merged to main. +// +// Usage: +// bun apps/cli/scripts/apply-release-notes.ts --tag v2.101.0 +import { $ } from "bun"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + tag: { type: "string" }, + }, + strict: true, +}); + +const tag = values.tag; +if (!tag) { + console.error("--tag is required (e.g. --tag v2.101.0)"); + process.exit(2); +} +const version = tag.replace(/^v/, ""); + +const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); +const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); +if (!existsSync(notesPath)) { + console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`); + process.exit(1); +} + +console.error(`==> Updating GitHub Release body for ${tag}`); +await $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot); +console.error(`==> Done`); diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts new file mode 100644 index 0000000000..33b1f2faaf --- /dev/null +++ b/apps/cli/scripts/propose-release-notes.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env bun +// Generate a user-centric GitHub Release body for a Supabase CLI tag +// by running the Claude Agent SDK against tools/release/release-notes-prompt.md +// with the raw semantic-release block substituted in. +// +// Pipeline shape: +// 1. `backfill-release-notes.ts --tag ` produces the raw semantic-release +// markdown (without writing anything to the GH release). We always +// re-derive this so the proposer is decoupled from whatever happens to +// sit in the release body at the moment. +// 2. The raw block is inlined into tools/release/release-notes-prompt.md in +// place of the {{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} placeholder. +// 3. The Claude Agent SDK runs the rendered prompt with WebFetch + Bash so +// it can investigate PR bodies, linked issues, and changed files (the +// prompt's investigation step is real work, not boilerplate). +// 4. The agent's final assistant message is written to +// release-notes/v.md. +// 5. Unless --dry-run is passed, the script commits the file on a branch +// `release-notes/v` and opens a PR. Approving the PR (as a +// supabase/cli team member) triggers apply-release-notes.yml, which +// pushes the file's contents to the GH release body and closes the PR +// without merging — the file never lands on `main`. +// +// Usage: +// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run +// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --apply +// +// --tag Required. Release tag (e.g. v2.101.0 or v2.99.0-beta.1). +// --dry-run Print the proposed notes to stdout. Does not write any files, +// does not touch git. +// --apply Write release-notes/v.md, commit on a branch, push, +// and open a PR. Default behavior when neither flag is passed +// is `--dry-run`. +// --render-only Print the rendered prompt (template + raw notes block) +// and exit before any LLM call. Useful for prompt iteration +// and for verifying the pipeline shape without spending tokens. +// --model Optional. Override the Claude model (default: claude-haiku-4-5-20251001). +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { $ } from "bun"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + tag: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + apply: { type: "boolean", default: false }, + "render-only": { type: "boolean", default: false }, + model: { type: "string", default: "claude-haiku-4-5-20251001" }, + }, + strict: true, +}); + +const tag = values.tag; +if (!tag) { + console.error("--tag is required (e.g. --tag v2.101.0)"); + process.exit(2); +} +const version = tag.replace(/^v/, ""); +const apply = values.apply === true && values["dry-run"] !== true; + +const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); +const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); +const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); +const notesDir = path.join(repoRoot, "release-notes"); +const notesPath = path.join(notesDir, `v${version}.md`); + +console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); +const rawNotes = (await $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text()).trim(); +if (!rawNotes) { + console.error(`backfill-release-notes produced no output for ${tag}`); + process.exit(1); +} + +const promptTemplate = await readFile(promptPath, "utf8"); +const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; +if (!promptTemplate.includes(placeholder)) { + console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); + process.exit(1); +} +const rendered = promptTemplate.replace(placeholder, rawNotes); + +if (values["render-only"]) { + process.stdout.write(rendered); + process.exit(0); +} + +console.error(`==> Running Claude Agent SDK (model=${values.model})`); +const options: Options = { + model: values.model, + // The agent needs WebFetch / WebSearch to investigate PR bodies and linked + // issues per the prompt's step 3, and Bash so it can use `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are + // intentionally excluded — the script owns the final file output. + allowedTools: ["WebFetch", "WebSearch", "Bash"], + // Don't load the repo's CLAUDE.md or settings.json — the prompt is + // self-contained and we don't want unrelated agent context bleeding in. + settingSources: [], + cwd: repoRoot, + effort: "low", +}; + +let finalText = ""; +let cost = 0; +const stream = query({ prompt: rendered, options }); +for await (const msg of stream) { + if (msg.type === "result") { + if (msg.subtype === "success") { + finalText = msg.result; + cost = msg.total_cost_usd; + } else { + console.error(`Agent failed: ${msg.subtype}`); + if (msg.errors?.length) console.error(msg.errors.join("\n")); + process.exit(1); + } + } +} + +if (!finalText.trim()) { + console.error("Agent returned no result text"); + process.exit(1); +} + +// Append the raw notes to the final text to ensure the output is complete. +const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; +console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); + +if (!apply) { + process.stdout.write(normalized); + process.exit(0); +} + +await mkdir(notesDir, { recursive: true }); +if (existsSync(notesPath)) { + console.error( + `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + + `Delete it or rerun with --dry-run to preview.`, + ); + process.exit(1); +} +await writeFile(notesPath, normalized); +console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); + +const branch = `release-notes/v${version}`; +const currentBranch = (await $`git rev-parse --abbrev-ref HEAD`.cwd(repoRoot).text()).trim(); +if (currentBranch !== branch) { + await $`git checkout -B ${branch}`.cwd(repoRoot); +} +await $`git add ${notesPath}`.cwd(repoRoot); +const commitMessage = `docs(release): propose user-facing notes for ${tag}`; +await $`git commit -m ${commitMessage}`.cwd(repoRoot); + +console.error(`==> Pushing ${branch}`); +let pushed = false; +for (let attempt = 0; attempt < 4; attempt++) { + const result = await $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(); + if (result.exitCode === 0) { + pushed = true; + break; + } + const wait = 2 ** (attempt + 1) * 1000; + console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); + await new Promise((r) => setTimeout(r, wait)); +} +if (!pushed) { + console.error("git push failed after 4 attempts"); + process.exit(1); +} + +// Idempotently ensure the `do not merge` label exists on the repo, then attach +// it on PR creation. The label is a visual reminder for reviewers — the +// approval-based apply workflow never invokes the merge button — but the +// publish flow itself does not depend on it. +const labelName = "do not merge"; +await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` + .cwd(repoRoot) + .nothrow(); + +const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; +const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. + +## How to update the notes + +Edit \`release-notes/v${version}.md\` directly on this branch — use the GitHub web editor or push commits to \`${branch}\` — before approving. The applied notes will reflect the file at the approved commit. + +## How to publish + +Approve this PR as a \`supabase/cli\` team member. The \`.github/workflows/apply-release-notes.yml\` workflow will then: + +1. Overwrite the GitHub Release body for [\`${tag}\`](${releaseUrl}) with the contents of \`release-notes/v${version}.md\`. +2. Comment the release URL on this PR. +3. Close this PR and delete the \`${branch}\` branch. + +**This PR is not merged** — the \`do not merge\` label is a reminder. Nothing lands on \`main\`. + +Approvals from anyone outside the \`supabase/cli\` team are ignored; the workflow will post a comment explaining that and leave the release untouched. + +## How to abandon + +Close the PR without approving. The auto-generated semantic-release body for \`${tag}\` stays in place. + +## Re-generation + +After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. +`; + +await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base main --head ${branch} --label ${labelName}`.cwd( + repoRoot, +); +console.error(`==> PR opened for ${branch}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0467e6880a..7a114320de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,12 @@ importers: apps/cli: devDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.146 + version: 0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: ^0.97.1 + version: 0.97.1(zod@4.4.3) '@clack/prompts': specifier: ^1.4.0 version: 1.4.0 @@ -95,6 +101,9 @@ importers: '@effect/vitest': specifier: 'catalog:' version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -527,6 +536,67 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + resolution: {integrity: sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + resolution: {integrity: sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + resolution: {integrity: sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + resolution: {integrity: sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + resolution: {integrity: sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + resolution: {integrity: sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + resolution: {integrity: sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + resolution: {integrity: sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.146': + resolution: {integrity: sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.97.1': + resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -582,6 +652,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -841,6 +915,12 @@ packages: tailwindcss: optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1024,6 +1104,16 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -2478,6 +2568,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2871,6 +2964,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2893,6 +2990,14 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -3039,6 +3144,10 @@ packages: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -3270,6 +3379,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -3310,6 +3423,10 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -3613,6 +3730,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -3628,10 +3753,20 @@ packages: express-rate-limit@5.5.1: resolution: {integrity: sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3653,6 +3788,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3700,6 +3838,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -3765,6 +3907,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4031,6 +4177,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} + engines: {node: '>=16.9.0'} + hook-std@4.0.0: resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} engines: {node: '>=20'} @@ -4095,6 +4245,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4162,6 +4316,10 @@ packages: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4233,6 +4391,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4289,6 +4450,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4313,9 +4477,16 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -4615,6 +4786,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -4622,6 +4797,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4754,6 +4933,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4853,6 +5036,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5207,6 +5394,9 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5252,6 +5442,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5346,6 +5540,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -5538,6 +5736,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5595,10 +5797,18 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -5732,6 +5942,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5950,6 +6163,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -5990,6 +6206,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -6369,6 +6589,11 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -6398,6 +6623,52 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.97.1(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.146 + + '@anthropic-ai/sdk@0.97.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6475,6 +6746,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.2': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6577,7 +6850,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -6587,7 +6859,6 @@ snapshots: '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -6600,7 +6871,6 @@ snapshots: '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.28.0': optional: true @@ -6699,6 +6969,10 @@ snapshots: '@fumadocs/tailwind@0.0.5': {} + '@hono/node-server@1.19.14(hono@4.12.21)': + dependencies: + hono: 4.12.21 + '@img/colour@1.1.0': optional: true @@ -6851,6 +7125,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.21) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.21 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -6922,8 +7218,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -7942,6 +8238,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@supabase/auth-js@2.106.2': @@ -8413,6 +8711,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -8432,6 +8735,10 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -8552,6 +8859,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bottleneck@2.19.5: {} brace-expansion@2.1.1: @@ -8791,6 +9112,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} content-type@2.0.0: {} @@ -8822,6 +9145,8 @@ snapshots: cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} core-util-is@1.0.2: {} @@ -9121,6 +9446,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -9152,6 +9483,11 @@ snapshots: express-rate-limit@5.5.1: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@4.22.1: dependencies: accepts: 1.3.8 @@ -9188,6 +9524,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extsprintf@1.3.0: {} @@ -9208,6 +9577,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -9260,6 +9631,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way-ts@0.1.6: {} find-up-simple@1.0.1: {} @@ -9306,6 +9688,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@11.3.5: @@ -9656,6 +10040,8 @@ snapshots: highlight.js@10.7.3: {} + hono@4.12.21: {} + hook-std@4.0.0: {} hosted-git-info@7.0.2: @@ -9724,6 +10110,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -9809,6 +10199,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -9854,6 +10246,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-stream@3.0.0: {} is-stream@4.0.1: {} @@ -9899,6 +10293,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -9915,8 +10311,15 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stringify-safe@5.0.1: {} @@ -9946,7 +10349,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.0 + semver: 7.8.1 jsprim@2.0.2: dependencies: @@ -10317,10 +10720,14 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + meow@13.2.0: {} merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -10604,6 +11011,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -10678,6 +11089,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} nerf-dart@1.0.0: {} @@ -10935,7 +11348,7 @@ snapshots: bl: 4.1.0 chalk: 4.1.2 cli-cursor: 3.1.0 - cli-spinners: 2.6.1 + cli-spinners: 2.9.2 is-interactive: 1.0.0 log-symbols: 4.1.0 strip-ansi: 6.0.1 @@ -11132,6 +11545,8 @@ snapshots: path-to-regexp@0.1.13: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11179,6 +11594,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -11266,6 +11683,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -11540,6 +11964,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.2 '@rolldown/binding-win32-x64-msvc': 1.0.2 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -11624,6 +12058,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -11633,6 +12083,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + setprototypeof@1.2.0: {} sharp@0.34.5: @@ -11804,6 +12263,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -12016,6 +12480,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -12049,6 +12515,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} uglify-js@3.19.3: @@ -12397,6 +12869,10 @@ snapshots: yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/tools/release/release-notes-prompt.md b/tools/release/release-notes-prompt.md new file mode 100644 index 0000000000..ba68328962 --- /dev/null +++ b/tools/release/release-notes-prompt.md @@ -0,0 +1,164 @@ +## Output + +Generate release notes for **supabase/cli** from the pasted semantic-release block below. +**Replace** the pasted block entirely — do not extend it. + +Output **only** the final markdown release notes: no reasoning, no investigation commentary, no +`Now I have everything…`, no ` ```markdown ` wrappers. + +--- + +## Inputs + +``` +REPO: supabase/cli +PRODUCT_NAME: Supabase CLI +AUDIENCE: developers using the Supabase CLI locally and in CI +TONE: clear, direct, lightly informal, no marketing fluff +``` + +**Semantic-release changelog block** (paste between the fences): + +``` +{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} +``` + +Example header shape: `# [2.101.0](https://github.com/supabase/cli/compare/v2.100.1...v2.101.0) (2026-05-21)` with `### Bug Fixes` / `### Features` bullets. + +--- + +## Role + +Senior devrel writer for **Supabase CLI**. Translate merged PRs into workflow-focused notes — not +PR-title summaries. Answer: **Should I upgrade?** **What's new for me?** **Any gotchas?** + +--- + +## Repo scope (apply first) + +### Two shells — only `legacy/` counts + +| Path | Status | +|------|--------| +| `apps/cli/src/legacy/` | What users run as `supabase` today — **all user-facing behavior** | +| `apps/cli/src/next/` | v3 / alpha — **not user-facing** | + +- **Drop** PRs that only touch `next/` (commands, flags, tests, alpha plumbing): no bullet, **no tail count**, never mention `next/` or v3. +- PRs touching both `legacy/`/`shared/` and `next/`: write **only** the legacy/shared impact. + +### Go → TypeScript port + +Ongoing port: `apps/cli-go/` → `apps/cli/src/legacy/`. Parity PRs are **not** features/fixes. + +- If leaf commands were ported: **one line** under **TypeScript port progress** — list leaf commands only (`db diff`, not `db`); behavior matches Go CLI; cite PRs. Omit section if none. +- Port infra (services, tests, parity scripts) → tail count only. +- Port PR that **also** fixes a real bug or adds a non-Go flag → promote that part to Bug fixes / New features; still list the command under port progress. + +### Where user-visible changes usually live + +- `apps/cli/src/legacy/commands/**` — behavior, output, flags, errors (beyond pure porting) +- `apps/cli/src/shared/**` — telemetry, global flags, output inherited by legacy +- `apps/cli-go/**` — while still the production binary +- `packages/cli-*`, `apps/cli/scripts/` — install/packaging (homebrew, scoop, build) + +Everything else is usually internal. + +--- + +## Process + +Do not skip investigation — titles alone are insufficient. + +1. **Parse** — Extract version, compare URL, date, and each PR (title, prefix/scope, number, URL). Semantic-release sections (`### Bug Fixes`, etc.) are **hints only**, not final grouping. + +2. **Prefix triage** (fast pass) + +| Prefix | Action | +|--------|--------| +| `chore:`, `ci:`, `test:` | Tail (open only if title hints user impact) | +| `docs:` | Tail unless user-read docs / in-CLI help | +| `refactor:`, `style:` | Judge | +| `perf:` | Usually investigate | +| `fix:`, `feat:` (+ product scopes `cli`, `db`, `auth`, …) | Investigate | +| `feat!:`, `fix!:`, `BREAKING CHANGE` | Investigate + breaking section | + +Tail PRs count toward "Plus N internal…". **`next/`-only PRs do not.** + +3. **Investigate** each survivor — open the PR URL: body (not just title), linked issues (`Closes`/`Fixes`/`Refs`), files changed, labels, `!` / `BREAKING CHANGE`. Unclear after that → `` — do not guess. + +4. **User-relevance gate** — Would a CLI user notice this in workflow, output, errors, or commands/flags? + - **Yes** → entry + - **No** → tail (e.g. build-time credential injection, CI smoke-test fixes, `next/`-only) + - **Borderline** (e.g. `--version` now correct) → one-liner under Bug fixes, not Highlights + +5. **Classify** — Highlights (1–4 lead items), New features, Improvements, Bug fixes, Breaking changes (separate, always if any), TypeScript port progress, Internal (tail only). **Group** related PRs into one bullet with all PR numbers. + +6. **Write entries** — `**** — . (#1234)` + +Voice: second person, active; lead with benefit; name commands/flags/env vars; short examples when helpful; no marketing filler; never mention `next/`. + +- **Bug fixes:** symptom users saw, not root cause — ✅ `` `supabase start` no longer crashes when `[db.pooler]` is missing `` not "Fixed nil pointer in resolver" +- **Breaking:** what's breaking, who's affected, exact migration step + +7. **Intro** — 1–3 sentences on the headline. Honest if mostly fixes or grab-bag. Don't lead with port progress unless a command surface meaningfully changed. + +--- + +## Output format + +From the header line extract `VERSION`, `COMPARE_URL`, `DATE`. + +```markdown +## Supabase CLI v + +<1–3 sentence intro> + +### ⚠️ Breaking changes + +- **** — . (#1234) + +### Highlights +- **** — . (#1234) + +### New features +- **** — . (#1234) + +### Improvements +- . (#1234) + +### Bug fixes +- . (#1234) + +### TypeScript port progress + +- **Now served by the TypeScript shell:** ``, ``. Behavior matches the Go CLI. (#1234) + +--- + +Plus N internal improvements and dependency updates. + +**Full changelog:** +``` + +Omit empty sections. + +--- + +## Quick examples + +| Case | ❌ | ✅ | +|------|----|----| +| Feature `feat(db): --linked on db diff` | Added `--linked` flag (#4567) | **`db diff` against your linked project, no Docker** — pass `--linked` to diff remote without a local stack; handy in CI (#4567) | +| Bug + issue | Fixed nil pointer in config parser (#5012) | `supabase start` no longer crashes when optional sections like `[db.pooler]` are missing (#5012) | +| 3 PRs, one feature | Three `db lint --json` bullets | **`db lint` machine-readable output** — `--json` for CI; empty array when clean (#4801, #4815, #4823) | +| Port only | New native `db diff` implementation | Under **TypeScript port progress** only — `db diff`; behavior unchanged (#5314) | +| Port + real bug | (same bullet as port) | **Bug fixes:** `orgs list` returns all orgs, not first 100 (#5318); **Port:** `orgs list` (#5318) | +| `fix(cli):` build inject credentials | (bullet) | Tail only — scope `cli` ≠ user impact | +| `feat(next):` only | Any mention | Silent drop | + +--- + +## Avoid + +PR titles verbatim; implementation-first wording; buried breaking changes; vague "various improvements"; +marketing tone; guessing when unclear; port PRs as features; any `next/` / v3 / alpha mention. From 221d758cc4f6d45a892453e921a591c4b13e9904 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 3 Jun 2026 17:09:46 +0200 Subject: [PATCH 31/38] chore(ci): fix propose release workflow dispatch (#5447) ## What kind of change does this PR introduce? - Fix workflow propose-release manual dispatch with for `non_blocking` input - Uses `client-id` instead of `app-id` everywhere / fix actionlint self-hosted runner labels - Rework `.github/workflows/apply-release-notes.yml` to use github output --- .github/actionlint.yaml | 6 ++++ .github/workflows/apply-release-notes.yml | 39 ++++++++++++++------- .github/workflows/propose-release-notes.yml | 11 ++++-- .github/workflows/release-shared.yml | 6 +++- .github/workflows/release.yml | 3 +- apps/cli/scripts/propose-release-notes.ts | 23 ++++++++---- 6 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..e9c0d32e94 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - blacksmith-32vcpu-ubuntu-2404 + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-6vcpu-macos-latest + - blacksmith-8vcpu-windows-2025 diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index 823d923456..0c75eb0ca2 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -4,7 +4,8 @@ name: Apply release notes # release-notes PR (head ref `release-notes/v`), this workflow pushes # the proposed notes to the GitHub Release body for the corresponding tag, # comments the release URL on the PR, and closes the PR without merging. The -# release-notes file never lands on `main`. +# release-notes PR targets `develop` (not `main`) so an accidental merge can +# never rewrite `main`'s history; the file is not meant to land on any branch. # # Mirrors the fast-forward job in release.yml, which already gates on a # `pull_request_review` + `approved` event. @@ -17,29 +18,31 @@ permissions: contents: read jobs: - apply: + authorize: # `state == 'open'` makes re-approvals on an already-closed PR a no-op # (a reviewer can re-approve from the GitHub UI even after close). if: | github.event.review.state == 'approved' && startsWith(github.event.pull_request.head.ref, 'release-notes/') && - github.event.pull_request.base.ref == 'main' && + github.event.pull_request.base.ref == 'develop' && github.event.pull_request.state == 'open' runs-on: ubuntu-latest permissions: - contents: write pull-requests: write + outputs: + authorized: ${{ steps.check.outputs.authorized }} steps: # App token: needs `orgs/.../teams/.../memberships` read (the org-installed # App has it), repo write to edit the release, and PR write to comment # and close. Matches release.yml's fast-forward step. - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Authorize approver against supabase/cli team + id: check env: GH_TOKEN: ${{ steps.app-token.outputs.token }} APPROVER: ${{ github.event.review.user.login }} @@ -57,25 +60,37 @@ jobs: echo "Approver @${APPROVER} is not an active supabase/cli team member (state='${status:-none}'); ignoring approval." >&2 gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ "@${APPROVER} is not an active \`supabase/cli\` team member, so this approval was ignored. Ask a team member to approve to publish the notes." + echo "authorized=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "AUTHORIZED=true" >> "$GITHUB_ENV" + echo "authorized=true" >> "$GITHUB_OUTPUT" + + apply: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} # Checkout the PR head so any reviewer edits made in the GitHub UI before # approval are captured. apply-release-notes.ts reads from the working # tree. - - if: env.AUTHORIZED == 'true' - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 persist-credentials: false - - if: env.AUTHORIZED == 'true' - uses: ./.github/actions/setup + - uses: ./.github/actions/setup - name: Apply notes, comment, and close - if: env.AUTHORIZED == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml index 179e116293..37da05c755 100644 --- a/.github/workflows/propose-release-notes.yml +++ b/.github/workflows/propose-release-notes.yml @@ -22,6 +22,11 @@ on: required: false type: boolean default: false + secrets: + ANTHROPIC_API_KEY: + required: true + GH_APP_PRIVATE_KEY: + required: true workflow_dispatch: inputs: tag: @@ -38,7 +43,7 @@ jobs: # allow any tag so reviewers can opt in for beta/alpha from the Actions tab. if: ${{ github.event_name == 'workflow_dispatch' || (!contains(inputs.tag, '-beta.') && !contains(inputs.tag, '-alpha.')) }} runs-on: ubuntu-latest - continue-on-error: ${{ inputs.non_blocking }} + continue-on-error: ${{ inputs.non_blocking || false }} permissions: contents: write pull-requests: write @@ -49,9 +54,9 @@ jobs: # App token gets us push to a protected default branch *and* PR creation # under the App identity, matching the rest of release.yml. - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 6a5422fc82..8505ba61e0 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -51,6 +51,8 @@ on: required: false GH_APP_PRIVATE_KEY: required: false + ANTHROPIC_API_KEY: + required: false jobs: build: name: Build CLI artifacts @@ -335,7 +337,9 @@ jobs: with: tag: v${{ inputs.version }} non_blocking: true - secrets: inherit + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} publish-homebrew: needs: publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 107424383c..3b8bb9a099 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: if: github.event_name == 'push' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write # `persist-credentials: false` is required: otherwise checkout caches the @@ -208,6 +208,7 @@ jobs: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index 33b1f2faaf..37abf57fbd 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -19,7 +19,9 @@ // `release-notes/v` and opens a PR. Approving the PR (as a // supabase/cli team member) triggers apply-release-notes.yml, which // pushes the file's contents to the GH release body and closes the PR -// without merging — the file never lands on `main`. +// without merging. The PR targets `develop` (not `main`) so an +// accidental merge can never rewrite `main`'s history; in practice the +// file never lands on any branch. // // Usage: // bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run @@ -145,10 +147,17 @@ await writeFile(notesPath, normalized); console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); const branch = `release-notes/v${version}`; -const currentBranch = (await $`git rev-parse --abbrev-ref HEAD`.cwd(repoRoot).text()).trim(); -if (currentBranch !== branch) { - await $`git checkout -B ${branch}`.cwd(repoRoot); -} +// Always cut the notes branch from origin/develop — the PR base. The workflow +// can be dispatched from an arbitrary feature branch that has diverged from +// the base by many commits; branching off the checked-out ref would drag +// every one of those commits into the PR (so the PR shows N changed files +// instead of just the proposed notes). The notes file is untracked at this +// point, so resetting HEAD to origin/develop leaves it untouched in the +// working tree. We target `develop` rather than `main` so that an accidental +// merge of this approval-only PR lands on the integration branch instead of +// rewriting `main`'s history. +await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); +await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); await $`git add ${notesPath}`.cwd(repoRoot); const commitMessage = `docs(release): propose user-facing notes for ${tag}`; await $`git commit -m ${commitMessage}`.cwd(repoRoot); @@ -194,7 +203,7 @@ Approve this PR as a \`supabase/cli\` team member. The \`.github/workflows/apply 2. Comment the release URL on this PR. 3. Close this PR and delete the \`${branch}\` branch. -**This PR is not merged** — the \`do not merge\` label is a reminder. Nothing lands on \`main\`. +**This PR is not merged** — the \`do not merge\` label is a reminder. It targets \`develop\` so that even an accidental merge never rewrites \`main\`. Nothing is meant to land on any branch. Approvals from anyone outside the \`supabase/cli\` team are ignored; the workflow will post a comment explaining that and leave the release untouched. @@ -207,7 +216,7 @@ Close the PR without approving. The auto-generated semantic-release body for \`$ After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. `; -await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base main --head ${branch} --label ${labelName}`.cwd( +await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( repoRoot, ); console.error(`==> PR opened for ${branch}`); From f5b186a48b7d2929eee0a28dc8ae0e1b57cdeb6a Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 3 Jun 2026 18:10:36 +0200 Subject: [PATCH 32/38] chore(ci): update workflows to skip CI for release-notes PRs (#5455) ## What kind of change does this PR introduce? Skip the tests / lint / release preview for the `release-notes/*` pr's. This also have the nice benefit of blocking any accidental merge for those PR's since those checks are required to pass to allow merge on develop. --- .github/workflows/lint-pull-request.yml | 8 +++++++- .../publish-preview-cli-packages.yml | 16 ++++++++++++--- .github/workflows/test.yml | 20 ++++++++++++++----- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index 4d24fa4509..32e676df3c 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -1,5 +1,7 @@ name: Lint Pull Request +# Release-notes PRs (head ref `release-notes/*`) skip CI; only +# apply-release-notes.yml runs for those. on: pull_request_target: types: @@ -23,7 +25,11 @@ concurrency: jobs: main: - if: github.event_name != 'pull_request_target' || github.event.pull_request.draft == false + if: | + github.event_name == 'merge_group' || + (github.event_name == 'pull_request_target' && + !startsWith(github.event.pull_request.head.ref, 'release-notes/') && + github.event.pull_request.draft == false) name: Lint Pull Request runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 26f6174986..2f79390d19 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -1,5 +1,7 @@ name: Publish Preview CLI Packages +# Release-notes PRs (head ref `release-notes/*`) are markdown-only and are not +# meant to produce installable preview packages. on: pull_request: types: @@ -20,7 +22,9 @@ concurrency: jobs: build: - if: github.event.pull_request.draft == false + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false name: Build preview CLI packages uses: ./.github/workflows/build-cli-artifacts.yml with: @@ -29,7 +33,10 @@ jobs: publish: needs: build - if: github.event.pull_request.draft == false && needs.build.result == 'success' + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false && + needs.build.result == 'success' name: Publish preview package runs-on: ubuntu-latest env: @@ -83,7 +90,10 @@ jobs: comment: needs: publish - if: github.event.pull_request.draft == false && needs.publish.result == 'success' + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false && + needs.publish.result == 'success' name: Post preview command comment runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 770a7242d5..74889c64a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,7 @@ name: Test +# Release-notes PRs (head ref `release-notes/*`) only add markdown under +# `release-notes/` and are published via approval — skip the full CI suite. on: push: branches: @@ -23,7 +25,9 @@ concurrency: jobs: check: - if: github.event.pull_request.draft == false || github.event_name == 'push' + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event.pull_request.draft == false || github.event_name == 'push') name: Check code quality runs-on: blacksmith-8vcpu-ubuntu-2404 steps: @@ -49,7 +53,9 @@ jobs: run: pnpm run check:all test-core: - if: github.event.pull_request.draft == false || github.event_name == 'push' + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event.pull_request.draft == false || github.event_name == 'push') name: Run unit and integration tests runs-on: blacksmith-8vcpu-ubuntu-2404 steps: @@ -75,7 +81,9 @@ jobs: run: pnpm run test:core test-e2e: - if: github.event.pull_request.draft == false || github.event_name == 'push' + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event.pull_request.draft == false || github.event_name == 'push') name: Run end-to-end tests (shard ${{ matrix.shard }}/3) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: @@ -173,8 +181,10 @@ jobs: # branch protection rules already require. It succeeds iff every shard # succeeded (or skipped — `success()` is true for skipped jobs). test-e2e-summary: - if: always() && (github.event.pull_request.draft == false || github.event_name - == 'push') + if: | + always() && + !startsWith(github.head_ref, 'release-notes/') && + (github.event.pull_request.draft == false || github.event_name == 'push') name: Run end-to-end tests needs: test-e2e runs-on: ubuntu-latest From beee75c4cac465ee7cc60d52d2fd78dbcbcad1a1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 3 Jun 2026 18:04:31 +0100 Subject: [PATCH 33/38] feat(cli): port login and logout commands to native TypeScript (#5446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes `supabase login` and `supabase logout` from Phase 0 Go-binary proxy wrappers to native TypeScript Effect handlers in the legacy shell (CLI-1302). Parity with the Go CLI is the explicit priority — matching stdout/stderr strings, streams, exit codes, the OAuth crypto flow, the credential-delete ordering, and the telemetry stitch. ## login - **Token path** — resolves the token from `--token` → `SUPABASE_ACCESS_TOKEN` → piped stdin (non-TTY), saves it, then stitches telemetry identity and prints `You are now logged in. Happy coding!`. - **Browser OAuth flow** — ECDH P-256 keypair + AES-256-GCM decrypt (`LegacyLoginCrypto`), session polling at `/platform/cli/login/{sessionId}` + best-effort `/v1/profile` gotrue-id fetch (`LegacyLoginApi`). Verify-with-retries mirrors Go's backoff: 3 attempts total, `Retry (n/2)` printed on the first two failures only. - **Telemetry** — fetches the gotrue id, stitches or clears the `distinct_id`, and always captures `cli_login_completed`. The stitch *aliases* only (no `identify`) to match Go's `StitchLogin`. - **Profile persistence** — on success, an explicitly-set profile is written to `~/.supabase/profile` (Go's `PostRunE` / `SaveProfileName`); `LegacyCliConfig` now reads that file back as the lowest-precedence profile source. - Claude Code plugin hint on a TTY stdout. ## logout - Confirm prompt honoring `--yes`, the not-logged-in stderr path (exits 0, skips the credential sweep), real-removal-failure propagation, and the project-credential sweep. ## Shared infra - `LegacyCredentials.deleteAccessToken` reshaped to the Go-faithful tri-state (`void` | `LegacyNotLoggedInError` | `LegacyDeleteTokenError`) reproducing Go's file-first / legacy-keyring / profile-keyring ordering — including the deliberate "file removed yet *not logged in*" quirk on no-keyring hosts. Adds `deleteAllProjectCredentials`. - `LegacyTelemetryState` gains `stitchLogin` / `clearDistinctId` (alias + persist, sharing one JSON read/merge internal). ## Reviewer-relevant notes - The profile-file **read** fallback lives in the shared `LegacyCliConfig` layer, so it now applies to every legacy command (correct Go parity, wider than just login). - `~/.supabase/profile` write failure is fatal (exit 1), matching Go's "block subsequent CI commands on save failure". SIDE_EFFECTS.md added for both commands; the porting-status tracker is flipped to `ported`. --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/auth/legacy-credentials.layer.ts | 151 +++++++- .../legacy-credentials.layer.unit.test.ts | 213 ++++++++++- .../legacy/auth/legacy-credentials.service.ts | 31 +- apps/cli/src/legacy/auth/legacy-errors.ts | 23 ++ .../legacy-platform-api.layer.unit.test.ts | 3 +- .../src/legacy/commands/login/SIDE_EFFECTS.md | 93 +++-- .../legacy/commands/login/login-api.layer.ts | 84 +++++ .../commands/login/login-api.service.ts | 39 ++ .../commands/login/login-claude-hint.ts | 25 ++ .../login/login-claude-hint.unit.test.ts | 30 ++ .../commands/login/login-crypto.layer.ts | 58 +++ .../commands/login/login-crypto.service.ts | 42 +++ .../legacy/commands/login/login.command.ts | 9 +- .../legacy/commands/login/login.e2e.test.ts | 46 +++ .../src/legacy/commands/login/login.errors.ts | 45 +++ .../legacy/commands/login/login.handler.ts | 235 +++++++++++- .../commands/login/login.integration.test.ts | 354 ++++++++++++++++++ .../src/legacy/commands/login/login.layers.ts | 40 ++ .../legacy/commands/logout/SIDE_EFFECTS.md | 71 ++-- .../legacy/commands/logout/logout.command.ts | 9 +- .../legacy/commands/logout/logout.e2e.test.ts | 55 +++ .../legacy/commands/logout/logout.errors.ts | 14 + .../legacy/commands/logout/logout.handler.ts | 64 +++- .../logout/logout.integration.test.ts | 151 ++++++++ .../legacy/commands/logout/logout.layers.ts | 24 ++ .../legacy/config/legacy-cli-config.layer.ts | 27 +- .../legacy-cli-config.layer.unit.test.ts | 28 +- .../src/legacy/config/legacy-profile-file.ts | 55 +++ .../telemetry/legacy-telemetry-state.layer.ts | 47 ++- .../legacy-telemetry-state.layer.unit.test.ts | 99 +++++ .../legacy-telemetry-state.service.ts | 17 + apps/cli/tests/helpers/legacy-mocks.ts | 189 +++++++++- apps/cli/tests/helpers/mocks.ts | 14 +- 34 files changed, 2256 insertions(+), 133 deletions(-) create mode 100644 apps/cli/src/legacy/commands/login/login-api.layer.ts create mode 100644 apps/cli/src/legacy/commands/login/login-api.service.ts create mode 100644 apps/cli/src/legacy/commands/login/login-claude-hint.ts create mode 100644 apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/login/login-crypto.layer.ts create mode 100644 apps/cli/src/legacy/commands/login/login-crypto.service.ts create mode 100644 apps/cli/src/legacy/commands/login/login.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/login/login.errors.ts create mode 100644 apps/cli/src/legacy/commands/login/login.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/login/login.layers.ts create mode 100644 apps/cli/src/legacy/commands/logout/logout.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/logout/logout.errors.ts create mode 100644 apps/cli/src/legacy/commands/logout/logout.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/logout/logout.layers.ts create mode 100644 apps/cli/src/legacy/config/legacy-profile-file.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index b096e74a17..ff70e62590 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -261,8 +261,8 @@ Legend: | `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) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | | `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | | `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | | `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 1cc8325d59..befbe14e8a 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -4,7 +4,12 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; -import { LegacyCredentialDeleteError, LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import { + LegacyCredentialDeleteError, + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "./legacy-errors.ts"; const KEYRING_SERVICE = "Supabase CLI"; const LEGACY_KEYRING_ACCOUNT = "access-token"; @@ -14,6 +19,9 @@ const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`."; +// Go's `utils.ErrNotLoggedIn` (`access_token.go:19`). +const NOT_LOGGED_IN_MESSAGE = "You were not logged in, nothing to do."; + type KeyringModule = typeof import("@napi-rs/keyring"); type KeyringEntry = InstanceType; type RuntimePlatform = NodeJS.Platform; @@ -219,6 +227,92 @@ const deleteKeyringEntryStrict = ( return deleted; }); +// Delete the access-token profile entry, distinguishing the three outcomes Go's +// `credentials.StoreProvider.Delete(profile)` collapses into via the +// `access_token.go:110-117` error mapping: +// - `"deleted"` — an entry existed and was removed (→ logged out, exit 0); +// - `"notFound"` — no entry existed (→ Go's `ErrNotLoggedIn`, exit 0); +// - `LegacyDeleteTokenError` — a real `deleteCredential()` failure (exit 1). +// Like `deleteKeyringEntryStrict`, the entry is probed first so deleting an +// absent macOS entry never blocks on a Keychain prompt, and the Windows +// target-shaped credential is handled separately. +const deleteProfileKeyringEntry = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect<"deleted" | "notFound", LegacyDeleteTokenError> => + Effect.gen(function* () { + let found = false; + + const plain = new module.Entry(KEYRING_SERVICE, account); + if (readEntryPassword(plain)) { + yield* Effect.try({ + try: () => { + plain.deleteCredential(); + }, + catch: (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + }); + found = true; + } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + const target = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + yield* Effect.try({ + try: () => { + target.deleteCredential(); + }, + catch: (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + }); + found = true; + } + + return found ? "deleted" : "notFound"; + }); + +// Best-effort wipe of every entry in the `"Supabase CLI"` keyring namespace — +// the project database-password credentials `link` writes. Mirrors Go's +// `keyring.DeleteAll(namespace)` (`store.go:71`). Never fails: per-entry delete +// errors are swallowed so a single stuck credential can't abort logout. +// +// On Windows, Go stores credentials under the target-shaped name +// `Supabase CLI:` rather than the plain `Entry(service, account)` form +// (see `writeGoWindowsTarget`). So each discovered account is deleted in BOTH +// forms — the plain entry and, on win32, the Go target entry — mirroring the +// individual deletes in `deleteProfileKeyringEntry`. Without this, a Go-written +// project credential would survive `logout` on Windows. +const deleteAllKeyringEntries = ( + module: KeyringModule, + platform: RuntimePlatform, +): Effect.Effect => + Effect.sync(() => { + let entries: ReadonlyArray<{ account: string }>; + try { + entries = module.findCredentials(KEYRING_SERVICE); + } catch { + return; + } + for (const { account } of entries) { + try { + new module.Entry(KEYRING_SERVICE, account).deleteCredential(); + } catch { + // best-effort per entry + } + if (platform === "win32" && readGoWindowsTarget(module, account)) { + deleteGoWindowsTarget(module, account); + } + } + }); + const makeLegacyCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -307,23 +401,50 @@ const makeLegacyCredentials = Effect.gen(function* () { }), deleteAccessToken: Effect.gen(function* () { - let anyDeleted = false; - if (Option.isSome(keyringModule)) { - if (yield* tryKeyringDelete(keyringModule.value, profileAccount, runtimeInfo.platform)) { - anyDeleted = true; - } - if ( - yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform) - ) { - anyDeleted = true; - } - } + // Reproduce Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) in + // its exact order. + + // 1. Always remove the fallback token file first. A missing file is + // ignored (Go's `errors.Is(err, os.ErrNotExist)`); any other removal + // failure aborts before the keyring is touched. const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { - yield* fs.remove(fallbackPath).pipe(Effect.orDie); - anyDeleted = true; + yield* fs.remove(fallbackPath).pipe( + Effect.catch((error) => + Effect.fail( + new LegacyDeleteTokenError({ + message: `failed to remove access token file: ${error.message}`, + }), + ), + ), + ); + } + + // 2. Best-effort delete of the legacy `access-token` keyring account. + // Go debug-logs and ignores any error here — never affects the result. + if (Option.isSome(keyringModule)) { + yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); + } + + // 3. Delete the profile keyring account — this alone decides the outcome. + // No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps to + // Go's `ErrNotSupported`/`ErrUnsupportedPlatform` → `ErrNotLoggedIn`. + if (Option.isNone(keyringModule)) { + return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); } - return anyDeleted; + const outcome = yield* deleteProfileKeyringEntry( + keyringModule.value, + profileAccount, + runtimeInfo.platform, + ); + if (outcome === "notFound") { + return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); + } + }), + + deleteAllProjectCredentials: Effect.gen(function* () { + if (Option.isNone(keyringModule)) return; + yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform); }), deleteProjectCredential: (projectRef: string) => diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 8aef37589c..db0b5128b2 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; import { afterEach, beforeEach, vi } from "vitest"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; @@ -12,7 +12,11 @@ import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.t import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; -import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import { + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "./legacy-errors.ts"; // --------------------------------------------------------------------------- // Keyring mock @@ -22,13 +26,20 @@ const passwords = new Map(); let throwOnSetPassword = false; let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); +const throwOnDeleteAccounts = new Set(); const withTargetCalls: string[] = []; vi.mock("@napi-rs/keyring", () => ({ findCredentials: (service: string, target?: string) => Array.from(passwords.entries()) .filter(([key]) => - target === undefined ? key.startsWith(`${service}/`) : key.startsWith(`${target}/`), + // No target → model the Windows `CredEnumerate("*")` sweep, + // which matches both the plain (`/…`) and the Go target-shaped + // (`:/…`) entries by the leading segment. With a + // target → narrow to that specific Go target (used by readGoWindowsTarget). + target === undefined + ? key.split("/")[0]!.startsWith(service) + : key.startsWith(`${target}/`), ) .map(([key, password]) => ({ account: key.split("/").at(-1)!, @@ -69,6 +80,7 @@ vi.mock("@napi-rs/keyring", () => ({ } deleteCredential(): boolean { const key = this.key(); + if (throwOnDeleteAccounts.has(key)) throw new Error("Keyring delete failed"); if (!passwords.has(key)) throw new Error("not found"); passwords.delete(key); return true; @@ -116,6 +128,7 @@ beforeEach(() => { throwOnSetPassword = false; throwOnSetSecret = false; throwOnGetPasswordAccounts.clear(); + throwOnDeleteAccounts.clear(); withTargetCalls.length = 0; tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -301,31 +314,199 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { }); }); +// Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) collapses three +// outcomes — logged out / not-logged-in / real failure — into the file + +// legacy-keyring + profile-keyring sequence. These cases assert the TS port +// reproduces that ordering and tri-state exactly (parity note 1). describe("legacyCredentialsLayer.deleteAccessToken", () => { - it.effect("returns false when no token is stored anywhere", () => - Effect.gen(function* () { + const seedTokenFile = (home: string, token = VALID_TOKEN) => { + const supaDir = join(home, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), token, { mode: 0o600 }); + }; + const tokenFileExists = (home: string) => existsSync(join(home, ".supabase", "access-token")); + + it.effect("logged in via keyring profile entry → deletes file + entry, succeeds", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + seedTokenFile(tempHome); + return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; - expect(yield* deleteAccessToken).toBe(false); - }).pipe(Effect.provide(makeLayer())), + yield* deleteAccessToken; + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect( + "keyring profile entry absent → LegacyNotLoggedInError even though the file was removed", + () => { + seedTokenFile(tempHome); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); + } + // File is still removed first (Go's deliberate ordering). + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }, + ); + + it.effect( + "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", + () => { + seedTokenFile(tempHome); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); + }, ); - it.effect("removes both keyring entries plus the filesystem file", () => { + it.effect( + "file remove error (non-ENOENT) → LegacyDeleteTokenError before touching keyring", + () => { + const home = tempHome; + const env = { HOME: home }; + const tokenPath = join(home, ".supabase", "access-token"); + // Seed a profile keyring entry to prove the keyring is never touched once + // the file removal fails. + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const fsLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (p) => Effect.succeed(p === tokenPath), + remove: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "permission denied", + pathOrDescriptor: tokenPath, + }), + ), + }), + ); + const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + const layer = legacyCredentialsLayer.pipe( + Layer.provide(cliConfigLayer), + Layer.provide(runtimeInfoLayer), + Layer.provide(fsLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(JSON.stringify(exit.cause)).toContain("failed to remove access token file"); + } + expect(passwords.has("Supabase CLI/supabase")).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("real profile-keyring delete error → LegacyDeleteTokenError", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + throwOnDeleteAccounts.add("Supabase CLI/supabase"); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(JSON.stringify(exit.cause)).toContain("failed to delete access token from keyring"); + } + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("win32: deletes both the plain and the Go Windows target entries", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); - passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); passwords.set(goWindowsKey("supabase"), VALID_TOKEN); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; - expect(yield* deleteAccessToken).toBe(true); + yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(passwords.has("Supabase CLI/access-token")).toBe(false); expect(passwords.has(goWindowsKey("supabase"))).toBe(false); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + throwOnDeleteAccounts.add("Supabase CLI/access-token"); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + yield* deleteAccessToken; + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { + it.effect("deletes every Supabase CLI keyring entry", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + passwords.set("Supabase CLI/abcdefghijklmnopqrs2", "secret-2"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs1")).toBe(false); + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs2")).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("no-ops when the keyring is unavailable", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs1")).toBe(true); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); + }); + + it.effect("win32: deletes Go target-shaped project credentials", () => { + // Go stores Windows project credentials under `Supabase CLI:`, not the + // plain `Supabase CLI/` form. The sweep must delete the target form too. + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("never fails even when an individual delete throws", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + throwOnDeleteAccounts.add("Supabase CLI/abcdefghijklmnopqrs1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + }).pipe(Effect.provide(makeLayer())); + }); }); -// Suppress unused-import nag — referenced in JSDoc. +// Suppress unused-import nag — referenced in JSDoc / used in assertions above. void LegacyInvalidAccessTokenError; +void LegacyDeleteTokenError; +void LegacyNotLoggedInError; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts index b6198f7627..def4668443 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.service.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -3,7 +3,9 @@ import { Context } from "effect"; import type { LegacyCredentialDeleteError, + LegacyDeleteTokenError, LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, } from "./legacy-errors.ts"; interface LegacyCredentialsShape { @@ -12,7 +14,34 @@ interface LegacyCredentialsShape { LegacyInvalidAccessTokenError >; readonly saveAccessToken: (token: string) => Effect.Effect; - readonly deleteAccessToken: Effect.Effect; + /** + * Deletes the access token, reproducing Go's `utils.DeleteAccessToken` + * (`apps/cli-go/internal/utils/access_token.go:100-119`) exactly: + * + * 1. Remove `~/.supabase/access-token` first. A non-`ENOENT` removal error + * fails `LegacyDeleteTokenError`; a missing file is ignored. + * 2. Best-effort delete of the legacy `access-token` keyring account — any + * error other than not-found is swallowed and never affects the outcome. + * 3. Delete the profile keyring account (account = profile name). This + * **alone** decides the result: + * - keyring unavailable (no module / WSL / `SUPABASE_NO_KEYRING`) or the + * entry is absent → `LegacyNotLoggedInError`; + * - a real delete error → `LegacyDeleteTokenError`; + * - success → `void`. + * + * The deliberate Go quirk this preserves: on a no-keyring host the file is + * still removed, yet the call fails `LegacyNotLoggedInError` because the + * profile-keyring delete reports not-supported. + */ + readonly deleteAccessToken: Effect.Effect; + /** + * Deletes **every** entry in the `"Supabase CLI"` keyring namespace (project + * database passwords stored by `link`). Best-effort: never fails, and is a + * no-op when the keyring is unavailable. Mirrors Go's + * `credentials.StoreProvider.DeleteAll()` (`store.go:67-78`), used by + * `supabase logout` after the access token is removed. + */ + readonly deleteAllProjectCredentials: Effect.Effect; /** * Deletes the stored database-password credential for a project from the OS * keyring (keyring service `"Supabase CLI"`, account = the **project ref** — diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index e07dd2d648..effab2b544 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -22,3 +22,26 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ readonly message: string; }> {} + +/** + * Raised by `deleteAccessToken` when there is no access token to delete, i.e. + * the profile keyring entry is absent or the keyring backend is unavailable + * (WSL / `SUPABASE_NO_KEYRING` / unsupported platform). Mirrors Go's + * `utils.ErrNotLoggedIn` (`apps/cli-go/internal/utils/access_token.go:19`), + * which `supabase logout` surfaces as `You were not logged in, nothing to do.` + * on stderr while still exiting 0. + */ +export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInError")<{ + readonly message: string; +}> {} + +/** + * Raised by `deleteAccessToken` when removing the token fails for a real reason + * — a non-`ENOENT` failure removing `~/.supabase/access-token`, or a non + * not-found error deleting the profile keyring entry. Mirrors Go's + * `failed to remove access token file: …` / `failed to delete access token from + * keyring: …` errors (`access_token.go:100-119`), which exit 1. + */ +export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index ef36953252..45e3fec170 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -35,7 +35,8 @@ function mockCredentials(token: Option.Option) { return Layer.succeed(LegacyCredentials, { getAccessToken: Effect.succeed(Option.map(token, Redacted.make)), saveAccessToken: () => Effect.void, - deleteAccessToken: Effect.succeed(false), + deleteAccessToken: Effect.void, + deleteAllProjectCredentials: Effect.void, deleteProjectCredential: () => Effect.succeed(false), }); } diff --git a/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md index 75cd2e5d69..ef6af8dcdb 100644 --- a/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md @@ -1,66 +1,83 @@ # `supabase login` +Native TypeScript port of Go's `internal/login`. Writes the access token, then +stitches/clears the telemetry identity and captures `cli_login_completed`. + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | -------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | not read; login writes this file | -| stdin | plain text (token string) | when piped input is detected in non-TTY mode | +| Path | Format | When | +| --------------------------------------- | ------------------------- | -------------------------------------------------------------------------- | +| stdin | plain text (token string) | non-TTY only, when `--token` is unset and `SUPABASE_ACCESS_TOKEN` is unset | +| OS keyring / `~/.supabase/access-token` | token string | written, not read, on the login path | ## Files Written -| Path | Format | When | -| -------------------------- | ------------------------- | ------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when keyring is unavailable; stores access token | +| Path | Format | When | +| ----------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| OS keyring (`Supabase CLI` / profile) | token string | always on success when the keyring is available | +| `~/.supabase/access-token` | plain text (mode `0600`) | on success when the keyring is unavailable (WSL / `SUPABASE_NO_KEYRING`) | +| `/telemetry.json` | JSON | always (PersistentPostRun flush); `distinct_id` set on stitch, removed on clear | +| `~/.supabase/profile` | plain text (profile name) | on success only, when a profile is explicitly set (`--profile` ≠ default, else `SUPABASE_PROFILE`) — Go's `PostRunE`/`SaveProfileName` | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------- | ---- | ------------ | -------------------------------------------------------- | -| `GET` | `/platform/cli/login/{session}` | none | none | `{access_token, public_key, nonce}` (for automated flow) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------- | ------------------------ | ------------ | ------------------------------------------------------------- | +| `GET` | `{dashboardUrl}/cli/login?session_id&token_name&public_key` | none (opened in browser) | none | — (not fetched by the CLI) | +| `GET` | `{apiHost}/platform/cli/login/{sessionId}?device_code=` | none | none | `{access_token, public_key, nonce}` (10s timeout, expect 200) | +| `GET` | `{apiHost}/v1/profile` | Bearer (saved token) | none | `{gotrue_id}` (best-effort, for the telemetry stitch) | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------ | ------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | token provided via env (bypass interactive flow) | no (falls back to `--token` flag or browser flow) | +| Variable | Purpose | Required? | +| ---------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | non-interactive token source | no (falls back to `--token` → piped stdin → browser flow) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the file fallback | no | +| `CLAUDECODE` / `CLAUDE_CODE` | enables the Claude Code plugin hint (TTY stdout only) | no | +| `DO_NOT_TRACK` / `SUPABASE_TELEMETRY_DISABLED` | suppress analytics delivery (state file still written) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------- | -| `0` | success — token saved | -| `1` | non-TTY environment with no `--token` flag and no piped stdin | -| `1` | invalid token format (must be `sbp_*`) | -| `1` | automated browser flow: API polling failure or decryption error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------- | +| `0` | success (token path or browser path) | +| `1` | invalid `--token` (`cannot save provided token: …`) | +| `1` | non-TTY with no token (`Cannot use automatic login flow inside non-TTY environments. …`) | +| `1` | keygen failure, verification retries exhausted, or decryption failure (browser path) | +| `1` | failure to persist `~/.supabase/profile` (Go blocks subsequent CI commands on save failure) | -## Output +Browser-open failure is non-fatal (logged, ignored — `login.go:206-208`). -### `--output-format text` (Go CLI compatible) +## Telemetry Events Fired -On success, prints the browser URL for the automated login flow (if TTY) or a confirmation message after token is saved. +| Event | When | Notable properties / identity | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------- | +| `cli_login_completed` | after the token persists | rides the stitched `gotrue_id` (`alias` + `distinct_id`) | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -``` -Hello from Supabase! Press Enter to open browser and login... - -Token saved successfully. -``` +## Output + +### `--output-format text` (Go CLI compatible) -For `--token` flag flow, prints a success confirmation to stdout. +Token path (stdout): `You are now logged in. Happy coding!` -### `--output-format json` +Browser path (stdout): `Hello from Supabase! Press Enter to open browser and login automatically.`, +then (after Enter) `Here is your login link in case browser did not open `; with +`--no-browser`: `Here is your login link, open it in the browser `. On success: +`Token created successfully.` then `You are now logged in. Happy coding!`. -Not applicable — login is an interactive command. No machine-readable JSON output defined. +stderr: verification prompt `Enter your verification code`, retry notices +`\nRetry (n/2): `, and the Claude Code hint (when applicable). -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable — login is an interactive command. +Emits a single structured `success` result (`You are now logged in.`); human banners +are suppressed. Interactive prompts (browser path) fail with `NonInteractiveError`. ## Notes -- In TTY mode without `--token`, the command opens a browser and polls the Supabase platform for a session token. -- In non-TTY mode (CI), the command requires `--token` or piped stdin. Otherwise it fails with `ErrMissingToken`. -- The `--name` flag overrides the token name used for keyring storage. -- The `--no-browser` flag skips automatic browser opening even in TTY mode. -- Token is stored in the OS keyring when available; falls back to `~/.supabase/access-token`. -- The `PostRunE` hook saves `--profile` value via `utils.SaveProfileName` if `PROFILE` is set. +- Token resolution priority: `--token` → `SUPABASE_ACCESS_TOKEN` → piped stdin (non-TTY) → browser flow (TTY). +- The login-session query string is built without URL-encoding, matching Go (`login.go:197-198`). +- Telemetry stitch always replaces a stale `distinct_id` (Go's `StitchLogin`), independent of the platform-API auto-stitch. The stitch _aliases_ only — Go's login never calls `identify`. +- On success, an explicitly-set profile is persisted to `~/.supabase/profile` (Go's `PostRunE`); `LegacyCliConfig` reads it back as the lowest-precedence profile source. +- Aqua/Bold styling from Go renders as plain text (parity on a non-TTY). diff --git a/apps/cli/src/legacy/commands/login/login-api.layer.ts b/apps/cli/src/legacy/commands/login/login-api.layer.ts new file mode 100644 index 0000000000..78d2eaa9fd --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-api.layer.ts @@ -0,0 +1,84 @@ +import { Effect, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyLoginApi, type LegacyLoginSessionResponse } from "./login-api.service.ts"; +import { LegacyLoginVerificationError } from "./login.errors.ts"; + +const POLL_TIMEOUT = "10 seconds"; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +export const legacyLoginApiLayer = Layer.effect( + LegacyLoginApi, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + + return LegacyLoginApi.of({ + fetchLoginSession: (apiHost: string, sessionId: string, deviceCode: string) => + Effect.gen(function* () { + const url = `${apiHost}/platform/cli/login/${sessionId}?device_code=${deviceCode}`; + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new LegacyLoginVerificationError({ + message: `Error status ${response.status}: ${body}`, + }), + ); + } + const body = yield* response.json; + const session: LegacyLoginSessionResponse = { + access_token: readString(body, "access_token"), + public_key: readString(body, "public_key"), + nonce: readString(body, "nonce"), + }; + return session; + }).pipe( + // Map transport / JSON-decode failures to the retry-driving error. + // The explicit non-200 `LegacyLoginVerificationError` above passes + // through untouched (it is not an `HttpClientError`). + Effect.catchTag("HttpClientError", (cause) => + Effect.fail( + new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + }), + ), + ), + Effect.timeoutOrElse({ + duration: POLL_TIMEOUT, + orElse: () => + Effect.fail( + new LegacyLoginVerificationError({ + message: "failed to execute http request: request timed out", + }), + ), + }), + ), + + fetchGotrueId: (apiHost: string, token: string) => + Effect.gen(function* () { + const request = HttpClientRequest.get(`${apiHost}/v1/profile`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + const body = yield* response.json; + const gotrueId = readString(body, "gotrue_id"); + return gotrueId.length > 0 ? Option.some(gotrueId) : Option.none(); + }).pipe(Effect.orElseSucceed(() => Option.none())), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/login/login-api.service.ts b/apps/cli/src/legacy/commands/login/login-api.service.ts new file mode 100644 index 0000000000..1234f54e59 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-api.service.ts @@ -0,0 +1,39 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +import type { LegacyLoginVerificationError } from "./login.errors.ts"; + +/** + * Subset of Go's `AccessTokenResponse` (`login.go:39-45`) the decrypt step + * consumes. `id` / `created_at` are returned by the API but unused. + */ +export type LegacyLoginSessionResponse = { + readonly access_token: string; + readonly public_key: string; + readonly nonce: string; +}; + +interface LegacyLoginApiShape { + /** + * Polls `GET {apiHost}/platform/cli/login/{sessionId}?device_code=` + * (Go's `pollForAccessToken`, `login.go:132-157`). Expects HTTP 200 with a + * 10s timeout; any transport / status / parse failure becomes a + * `LegacyLoginVerificationError` that drives the retry loop. + */ + readonly fetchLoginSession: ( + apiHost: string, + sessionId: string, + deviceCode: string, + ) => Effect.Effect; + /** + * Best-effort fetch of the authenticated user's `gotrue_id` from + * `GET {apiHost}/v1/profile` (Go's `getProfileGotrueID`, `login.go:301-310`). + * Returns `None` on any failure so the caller clears the telemetry + * `distinct_id`, matching Go's `handleTelemetryAfterLogin` error branch. + */ + readonly fetchGotrueId: (apiHost: string, token: string) => Effect.Effect>; +} + +export class LegacyLoginApi extends Context.Service()( + "supabase/legacy/LoginApi", +) {} diff --git a/apps/cli/src/legacy/commands/login/login-claude-hint.ts b/apps/cli/src/legacy/commands/login/login-claude-hint.ts new file mode 100644 index 0000000000..40e3113ccd --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-claude-hint.ts @@ -0,0 +1,25 @@ +/** + * Port of Go's `utils.SuggestClaudePlugin` (`apps/cli-go/internal/utils/misc.go:43-57`). + * + * Returns the Claude Code plugin-install hint **only** when both: + * 1. the CLI is running inside Claude Code (`CLAUDECODE` / `CLAUDE_CODE` env — + * Go's `agent.IsClaudeCode`), and + * 2. stdout is an interactive terminal (Go's `term.IsTerminal(stdout)`). + * + * Otherwise returns `""`. Pure: env + TTY state are passed in so the helper is + * trivially unit-testable and free of service dependencies. + */ +const CLAUDE_CODE_HINT = ``; + +export function legacyIsClaudeCode(env: NodeJS.ProcessEnv = process.env): boolean { + return (env["CLAUDECODE"] ?? "") !== "" || (env["CLAUDE_CODE"] ?? "") !== ""; +} + +export function legacySuggestClaudePlugin(opts: { + readonly stdoutIsTty: boolean; + readonly env?: NodeJS.ProcessEnv; +}): string { + if (!legacyIsClaudeCode(opts.env)) return ""; + if (!opts.stdoutIsTty) return ""; + return CLAUDE_CODE_HINT; +} diff --git a/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts b/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts new file mode 100644 index 0000000000..6b5e9a7e46 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { legacyIsClaudeCode, legacySuggestClaudePlugin } from "./login-claude-hint.ts"; + +const HINT = ``; + +describe("legacySuggestClaudePlugin", () => { + it("returns the hint when running inside Claude Code with a TTY stdout", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDECODE: "1" } })).toBe(HINT); + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDE_CODE: "1" } })).toBe(HINT); + }); + + it("returns empty string when stdout is not a TTY", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: false, env: { CLAUDECODE: "1" } })).toBe(""); + }); + + it("returns empty string when not running inside Claude Code", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: {} })).toBe(""); + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDECODE: "" } })).toBe(""); + }); +}); + +describe("legacyIsClaudeCode", () => { + it("detects CLAUDECODE / CLAUDE_CODE env presence", () => { + expect(legacyIsClaudeCode({ CLAUDECODE: "1" })).toBe(true); + expect(legacyIsClaudeCode({ CLAUDE_CODE: "yes" })).toBe(true); + expect(legacyIsClaudeCode({})).toBe(false); + expect(legacyIsClaudeCode({ CLAUDECODE: "" })).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/commands/login/login-crypto.layer.ts b/apps/cli/src/legacy/commands/login/login-crypto.layer.ts new file mode 100644 index 0000000000..c63eeac9b6 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-crypto.layer.ts @@ -0,0 +1,58 @@ +import { Buffer } from "node:buffer"; +import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; +import { hostname, userInfo } from "node:os"; +import { Effect, Layer } from "effect"; + +import { LegacyLoginCrypto, type LegacyEncryptedPayload } from "./login-crypto.service.ts"; +import { LegacyLoginCryptoError, LegacyLoginDecryptError } from "./login.errors.ts"; + +const DECRYPTION_ERROR_MSG = "cannot decrypt access token"; + +export const legacyLoginCryptoLayer = Layer.sync(LegacyLoginCrypto, () => + LegacyLoginCrypto.of({ + generateKeyPair: Effect.try({ + try: () => { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { ecdh, publicKeyHex: ecdh.getPublicKey("hex", "uncompressed") }; + }, + catch: (cause) => + new LegacyLoginCryptoError({ message: `cannot generate crypto keys: ${String(cause)}` }), + }), + generateSessionId: Effect.sync(() => randomUUID()), + defaultTokenName: Effect.sync(() => { + const ts = Math.floor(Date.now() / 1000); + try { + const user = userInfo().username; + const host = hostname(); + if (user && host) return `cli_${user}@${host}_${ts}`; + } catch { + /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ + } + return `cli_${ts}`; + }), + decryptToken: (ecdh: ECDH, payload: LegacyEncryptedPayload) => + Effect.try({ + try: () => { + const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); + // Go's `aesgcm.Open` expects the 16-byte GCM tag appended to the + // ciphertext; Node wants it supplied separately via `setAuthTag`. + const ciphertextHex = payload.ciphertext.slice(0, -32); + const authTagHex = payload.ciphertext.slice(-32); + const decipher = createDecipheriv( + "aes-256-gcm", + sharedSecret, + Buffer.from(payload.nonce, "hex"), + ); + decipher.setAuthTag(Buffer.from(authTagHex, "hex")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, "hex")), + decipher.final(), + ]); + return decrypted.toString("utf-8"); + }, + catch: (cause) => + new LegacyLoginDecryptError({ message: `${DECRYPTION_ERROR_MSG}: ${String(cause)}` }), + }), + }), +); diff --git a/apps/cli/src/legacy/commands/login/login-crypto.service.ts b/apps/cli/src/legacy/commands/login/login-crypto.service.ts new file mode 100644 index 0000000000..0022dbcff4 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-crypto.service.ts @@ -0,0 +1,42 @@ +import type { ECDH } from "node:crypto"; +import type { Effect } from "effect"; +import { Context } from "effect"; + +import type { LegacyLoginCryptoError, LegacyLoginDecryptError } from "./login.errors.ts"; + +export type LegacyEncryptedPayload = { + readonly ciphertext: string; + readonly publicKey: string; + readonly nonce: string; +}; + +interface LegacyLoginCryptoShape { + /** + * Generates a P-256 (prime256v1) ECDH keypair and the uncompressed, + * hex-encoded public key sent to the dashboard. Mirrors Go's + * `LoginEncryption.generateKeys` + `encodedPublicKey` (`login.go:71-84`). + */ + readonly generateKeyPair: Effect.Effect< + { readonly ecdh: ECDH; readonly publicKeyHex: string }, + LegacyLoginCryptoError + >; + /** Fresh login session UUID (Go's `uuid.New().String()`, `login.go:187`). */ + readonly generateSessionId: Effect.Effect; + /** + * Default token name `cli_@_`, falling back to `cli_` + * when the username/hostname lookup fails (`login.go:249-271`). + */ + readonly defaultTokenName: Effect.Effect; + /** + * Derives the ECDH shared secret and AES-256-GCM decrypts the access token. + * Mirrors Go's `decryptAccessToken` (`login.go:86-128`). + */ + readonly decryptToken: ( + ecdh: ECDH, + payload: LegacyEncryptedPayload, + ) => Effect.Effect; +} + +export class LegacyLoginCrypto extends Context.Service()( + "supabase/legacy/LoginCrypto", +) {} diff --git a/apps/cli/src/legacy/commands/login/login.command.ts b/apps/cli/src/legacy/commands/login/login.command.ts index 129b75b2f5..869cdb0516 100644 --- a/apps/cli/src/legacy/commands/login/login.command.ts +++ b/apps/cli/src/legacy/commands/login/login.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyLoginRuntimeLayer } from "./login.layers.ts"; import { legacyLogin } from "./login.handler.ts"; const config = { @@ -21,5 +25,8 @@ export type LegacyLoginFlags = CliCommand.Command.Config.Infer; export const legacyLoginCommand = Command.make("login", config).pipe( Command.withDescription("Authenticate using an access token."), Command.withShortDescription("Authenticate using an access token"), - Command.withHandler((flags) => legacyLogin(flags)), + Command.withHandler((flags) => + legacyLogin(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyLoginRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/login/login.e2e.test.ts b/apps/cli/src/legacy/commands/login/login.e2e.test.ts new file mode 100644 index 0000000000..3158d3a3ea --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.e2e.test.ts @@ -0,0 +1,46 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase login (legacy)", () => { + // Golden path: --token persists the access token and reports success. The e2e + // harness sets SUPABASE_NO_KEYRING=1, so the token lands in the isolated + // HOME's ~/.supabase/access-token rather than the OS keyring. + test( + "login --token persists the token and prints the logged-in message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout } = await runSupabase(["login", "--token", VALID_TOKEN], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("You are now logged in. Happy coding!"); + expect(existsSync(join(home.dir, ".supabase", "access-token"))).toBe(true); + }, + ); + + // Non-TTY with no token cannot use the automatic flow. + test( + "login with no token in a non-TTY exits non-zero with the missing-token message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout, stderr } = await runSupabase(["login"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts new file mode 100644 index 0000000000..65b9c5aab1 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; + +/** + * Go's `ErrMissingToken` (`apps/cli-go/cmd/login.go:16`). Go Aqua-styles the + * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the legacy port renders + * styling as plain text (Go strips color on a non-TTY), so this is byte-exact. + */ +export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = + `Cannot use automatic login flow inside non-TTY environments. ` + + `Please provide --token flag or set the SUPABASE_ACCESS_TOKEN environment variable.`; + +/** Token-path save failure — Go's `cannot save provided token: %w` (`login.go:171`). */ +export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ + readonly message: string; +}> {} + +/** Non-TTY environment with no token supplied (`login.go:34-35`). */ +export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginMissingTokenError")<{ + readonly message: string; +}> {} + +/** + * A single login-session poll/parse failure. Carries the underlying message so + * the retry notifier can print `\nRetry (n/2): ` exactly like Go's + * `newErrorCallback` (`login.go:159-166`); also the value `verifyWithRetries` + * surfaces after the final attempt. + */ +export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ + readonly message: string; +}> {} + +/** All verification retries exhausted (`login.go:214-216`). */ +export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ + readonly message: string; +}> {} + +/** ECDH / AES-GCM decryption failure — Go's `cannot decrypt access token` (`login.go:47`). */ +export class LegacyLoginDecryptError extends Data.TaggedError("LegacyLoginDecryptError")<{ + readonly message: string; +}> {} + +/** ECDH keypair generation failure — Go's `cannot generate crypto keys` (`login.go:66`). */ +export class LegacyLoginCryptoError extends Data.TaggedError("LegacyLoginCryptoError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/login/login.handler.ts b/apps/cli/src/legacy/commands/login/login.handler.ts index 01f82f4c96..ea32aef833 100644 --- a/apps/cli/src/legacy/commands/login/login.handler.ts +++ b/apps/cli/src/legacy/commands/login/login.handler.ts @@ -1,12 +1,231 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path, Redacted } from "effect"; + +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { saveLegacyProfileName } from "../../config/legacy-profile-file.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; +import { LegacyProfileFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import type { NonInteractiveError } from "../../../shared/output/errors.ts"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { Stdin } from "../../../shared/runtime/stdin.service.ts"; +import { Tty } from "../../../shared/runtime/tty.service.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { EventLoginCompleted } from "../../../shared/telemetry/event-catalog.ts"; +import { LegacyLoginApi, type LegacyLoginSessionResponse } from "./login-api.service.ts"; +import { LegacyLoginCrypto } from "./login-crypto.service.ts"; +import { legacySuggestClaudePlugin } from "./login-claude-hint.ts"; +import { + LEGACY_LOGIN_MISSING_TOKEN_MESSAGE, + LegacyLoginFailedError, + LegacyLoginMissingTokenError, + LegacyLoginSaveTokenError, +} from "./login.errors.ts"; import type { LegacyLoginFlags } from "./login.command.ts"; +// Go's `maxRetries` (`login.go:130`): the initial probe plus 2 retries (3 total). +const MAX_LOGIN_RETRIES = 2; + +const LOGGED_IN_MSG = "You are now logged in. Happy coding!\n"; + export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLoginFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["login"]; - if (Option.isSome(flags.token)) args.push("--token", flags.token.value); - if (Option.isSome(flags.name)) args.push("--name", flags.name.value); - if (flags.noBrowser) args.push("--no-browser"); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const crypto = yield* LegacyLoginCrypto; + const loginApi = yield* LegacyLoginApi; + const telemetryState = yield* LegacyTelemetryState; + const analytics = yield* Analytics; + const browser = yield* Browser; + const tty = yield* Tty; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const profileFlag = yield* LegacyProfileFlag; + + const apiHost = cliConfig.apiUrl; + const claudeHint = legacySuggestClaudePlugin({ stdoutIsTty: tty.stdoutIsTty }); + + // Mirrors Go's login `PostRunE` (`cmd/login.go:42-48`): when a profile was + // explicitly chosen (`--profile` over its default, else `SUPABASE_PROFILE`), + // persist it to `~/.supabase/profile` on success so later commands resolve the + // same profile. The raw token is written (Go's `viper.GetString("PROFILE")`), + // so a YAML-path profile round-trips. A write failure is fatal (Go: "Failure + // to save should block subsequent commands on CI"). + const envProfile = process.env["SUPABASE_PROFILE"]; + const profileToken = + profileFlag !== "supabase" + ? profileFlag + : envProfile !== undefined && envProfile.length > 0 + ? envProfile + : undefined; + const saveProfileName = + profileToken === undefined + ? Effect.void + : saveLegacyProfileName(fs, path, runtimeInfo.homeDir, profileToken); + + // Mirrors Go's `handleTelemetryAfterLogin` (`login.go:273-299`): fetch the + // gotrue id (best-effort), stitch or clear the telemetry identity, then always + // capture `cli_login_completed`. The capture rides the just-stitched identity + // so PostHog attributes it to the user (Go's `s.distinctID()` after StitchLogin). + // + // NOTE: Go's `StitchLogin` only *aliases* (`service.go:137`) — it does NOT + // call `identify`. Do not add `analytics.identify` here; that is a `next/` + // shell behavior and would emit an event Go never sends. + const postLoginTelemetry = (token: string) => + Effect.gen(function* () { + const gotrueId = yield* loginApi.fetchGotrueId(apiHost, token); + if (Option.isSome(gotrueId)) { + yield* telemetryState.stitchLogin(gotrueId.value); + yield* analytics + .capture(EventLoginCompleted) + .pipe(withAnalyticsContext({ distinct_id: gotrueId.value })); + } else { + yield* telemetryState.clearDistinctId; + yield* analytics.capture(EventLoginCompleted); + } + }); + + const tokenPath = (token: string) => + Effect.gen(function* () { + yield* credentials.saveAccessToken(token).pipe( + Effect.catchTag("LegacyInvalidAccessTokenError", (cause) => + Effect.fail( + new LegacyLoginSaveTokenError({ + message: `cannot save provided token: ${cause.message}`, + }), + ), + ), + ); + yield* postLoginTelemetry(token); + + if (output.format !== "text") { + yield* output.success("You are now logged in."); + return; + } + yield* output.raw(LOGGED_IN_MSG, "stdout"); + if (claudeHint.length > 0) yield* output.raw(`${claudeHint}\n`, "stderr"); + }); + + const browserPath = Effect.gen(function* () { + const { ecdh, publicKeyHex } = yield* crypto.generateKeyPair; + const sessionId = yield* crypto.generateSessionId; + const tokenName = Option.isSome(flags.name) ? flags.name.value : yield* crypto.defaultTokenName; + + // Go concatenates the query string without URL-encoding (`login.go:197-198`). + const loginUrl = + `${legacyDashboardUrl(cliConfig.profile)}/cli/login` + + `?session_id=${sessionId}&token_name=${tokenName}&public_key=${publicKeyHex}`; + + // The banners are human-facing text — suppressed in json / stream-json so + // stdout stays payload-only. The prompts still run (and fail cleanly with + // `NonInteractiveError` in a non-interactive machine mode). + const isText = output.format === "text"; + if (!flags.noBrowser) { + if (isText) { + yield* output.raw( + "Hello from Supabase! Press Enter to open browser and login automatically.\n", + "stdout", + ); + } + yield* output.promptText(""); + if (isText) { + yield* output.raw( + `Here is your login link in case browser did not open ${loginUrl}\n\n`, + "stdout", + ); + } + yield* Effect.ignore(browser.open(loginUrl)); + } else if (isText) { + yield* output.raw( + `Here is your login link, open it in the browser ${loginUrl}\n\n`, + "stdout", + ); + } + + // Verify + retry, mirroring Go's `pollForAccessToken` backoff + // (`login.go:132-166`): the notifier prints `\nRetry (n/2): ` after the + // first 2 failures; the 3rd failure gives up without a notice. + const verifyWithRetries = ( + failuresSoFar: number, + ): Effect.Effect => + Effect.gen(function* () { + const code = yield* output.promptText("Enter your verification code: ", { + validate: (v) => (v.trim().length > 0 ? undefined : "Verification code is required"), + }); + return yield* loginApi.fetchLoginSession(apiHost, sessionId, code.trim()); + }).pipe( + Effect.catchTag("LegacyLoginVerificationError", (err) => + Effect.gen(function* () { + const failures = failuresSoFar + 1; + if (failures > MAX_LOGIN_RETRIES) { + return yield* Effect.fail(new LegacyLoginFailedError({ message: err.message })); + } + yield* output.raw( + `${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, + "stderr", + ); + return yield* verifyWithRetries(failures); + }), + ), + ); + + const session = yield* verifyWithRetries(0); + + const token = yield* crypto.decryptToken(ecdh, { + ciphertext: session.access_token, + publicKey: session.public_key, + nonce: session.nonce, + }); + // Go returns the raw save error here (`login.go:222-224`) — not the + // "cannot save provided token" wrapper used on the token path. + yield* credentials.saveAccessToken(token); + yield* postLoginTelemetry(token); + + if (output.format !== "text") { + yield* output.success("You are now logged in.", { token_name: tokenName }); + return; + } + yield* output.raw(`Token ${tokenName} created successfully.\n\n`, "stdout"); + yield* output.raw(LOGGED_IN_MSG, "stdout"); + if (claudeHint.length > 0) yield* output.raw(`${claudeHint}\n`, "stderr"); + }); + + const body = Effect.gen(function* () { + // Token resolution priority: --token → SUPABASE_ACCESS_TOKEN → piped stdin + // (non-TTY only). Matches `cmd/login.go:31-39` + `login.go:236-247`. + const resolved = yield* resolveToken(flags); + if (Option.isSome(resolved)) { + return yield* tokenPath(resolved.value); + } + return yield* browserPath; + }); + + // `Effect.tap` runs the profile save only on success (Go's `PostRunE`); + // `Effect.ensuring` persists telemetry state on success and failure alike + // (PersistentPostRun parity, `cmd/root.go:176`). + return yield* body.pipe( + Effect.tap(() => saveProfileName), + Effect.ensuring(telemetryState.flush), + ); +}); + +const resolveToken = Effect.fnUntraced(function* (flags: LegacyLoginFlags) { + if (Option.isSome(flags.token)) return Option.some(flags.token.value); + const cliConfig = yield* LegacyCliConfig; + if (Option.isSome(cliConfig.accessToken)) { + return Option.some(Redacted.value(cliConfig.accessToken.value)); + } + const stdin = yield* Stdin; + if (!stdin.isTTY) { + const piped = yield* stdin.readPipedText; + if (Option.isSome(piped)) return Option.some(piped.value); + return yield* Effect.fail( + new LegacyLoginMissingTokenError({ message: LEGACY_LOGIN_MISSING_TOKEN_MESSAGE }), + ); + } + return Option.none(); }); diff --git a/apps/cli/src/legacy/commands/login/login.integration.test.ts b/apps/cli/src/legacy/commands/login/login.integration.test.ts new file mode 100644 index 0000000000..1902ce5721 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.integration.test.ts @@ -0,0 +1,354 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { + mockAnalytics, + mockBrowser, + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../tests/helpers/mocks.ts"; +import { LegacyProfileFlag } from "../../../shared/legacy/global-flags.ts"; +import { + LEGACY_VALID_TOKEN, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyCredentialsTracked, + mockLegacyLoginApi, + mockLegacyLoginCrypto, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { EventLoginCompleted } from "../../../shared/telemetry/event-catalog.ts"; +import { legacyLogin } from "./login.handler.ts"; +import type { LegacyLoginFlags } from "./login.command.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-login-int-"); + +const noopHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HttpClient.execute in login test")), +); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly isTTY?: boolean; + readonly stdoutIsTty?: boolean; + readonly accessTokenEnv?: string; + readonly pipedStdin?: string; + readonly gotrueId?: string; + readonly profileFails?: boolean; + readonly failTimes?: number; + readonly decryptFails?: boolean; + readonly keygenFails?: boolean; + readonly tokenName?: string; + readonly saveFails?: boolean; + readonly promptTextFail?: boolean; + readonly profileFlag?: string; + readonly homeDir?: string; +} + +function flags(overrides: Partial = {}): LegacyLoginFlags { + return { + token: Option.none(), + name: Option.none(), + noBrowser: false, + ...overrides, + }; +} + +function setupLegacyLogin(opts: SetupOpts = {}) { + const isTTY = opts.isTTY ?? false; + const out = mockOutput({ format: opts.format ?? "text", promptTextFail: opts.promptTextFail }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ saveFails: opts.saveFails }); + const crypto = mockLegacyLoginCrypto({ + decryptFails: opts.decryptFails, + keygenFails: opts.keygenFails, + tokenName: opts.tokenName, + }); + const loginApi = mockLegacyLoginApi({ + failTimes: opts.failTimes, + gotrueId: opts.gotrueId, + profileFails: opts.profileFails, + }); + const analytics = mockAnalytics(); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + accessToken: + opts.accessTokenEnv !== undefined + ? Option.some(Redacted.make(opts.accessTokenEnv)) + : Option.none(), + }); + const tty = mockTty({ stdinIsTty: isTTY, stdoutIsTty: opts.stdoutIsTty ?? false }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { + layer: mockLegacyPlatformApiService({ v1: {} }).layer, + httpClientLayer: noopHttpClient, + }, + cliConfig, + analytics, + telemetry: telemetry.layer, + tty, + ...(opts.homeDir !== undefined + ? { runtimeInfo: mockRuntimeInfo({ homeDir: opts.homeDir }) } + : {}), + }), + credentials.layer, + crypto.layer, + loginApi.layer, + mockStdin(isTTY, opts.pipedStdin), + mockBrowser(), + Layer.succeed(LegacyProfileFlag, opts.profileFlag ?? "supabase"), + ); + return { layer, out, credentials, crypto, loginApi, telemetry, analytics }; +} + +describe("legacy login integration", () => { + it.live("saves the token from --token and reports logged in", () => { + const { layer, out, credentials, analytics } = setupLegacyLogin(); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + }).pipe(Effect.provide(layer)); + }); + + it.live("saves the token from SUPABASE_ACCESS_TOKEN env when no flag is given", () => { + const { layer, credentials } = setupLegacyLogin({ accessTokenEnv: LEGACY_VALID_TOKEN }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("saves the token piped via stdin in non-TTY", () => { + const { layer, credentials } = setupLegacyLogin({ + isTTY: false, + pipedStdin: LEGACY_VALID_TOKEN, + }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an invalid --token with 'cannot save provided token:'", () => { + const { layer } = setupLegacyLogin({ saveFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags({ token: Option.some("not-a-token") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginSaveTokenError"); + expect(json).toContain("cannot save provided token:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails in non-TTY with no token", () => { + const { layer } = setupLegacyLogin({ isTTY: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginMissingTokenError"); + expect(json).toContain("Cannot use automatic login flow inside non-TTY environments"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow: generates link, opens browser, decrypts, saves, prints created", () => { + const { layer, out, credentials } = setupLegacyLogin({ isTTY: true, tokenName: "my-machine" }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(out.stdoutText).toContain( + "Hello from Supabase! Press Enter to open browser and login automatically.", + ); + expect(out.stdoutText).toContain("/cli/login?session_id=test-session-id"); + expect(out.stdoutText).toContain("Token my-machine created successfully."); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow with --no-browser prints the link without the open-browser banner", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ noBrowser: true })); + expect(out.stdoutText).toContain("Here is your login link, open it in the browser"); + expect(out.stdoutText).not.toContain("Press Enter to open browser"); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow uses the default token name when --name is absent", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + // mockLegacyLoginCrypto default token name. + expect(out.stdoutText).toContain("Token cli_test@host_123 created successfully."); + }).pipe(Effect.provide(layer)); + }); + + it.live("retries verification on poll failure then succeeds", () => { + const { layer, out, loginApi } = setupLegacyLogin({ isTTY: true, failTimes: 2 }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(out.stderrText).toContain("Retry (1/2): "); + expect(out.stderrText).toContain("Retry (2/2): "); + // 2 failures + 1 success = 3 poll attempts. + expect(loginApi.loginCallCount).toBe(3); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails after 2 retries are exhausted", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true, failTimes: 3 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLoginFailedError"); + } + // The 3rd (final) failure gives up without printing a Retry notice. + expect(out.stderrText).toContain("Retry (2/2): "); + expect(out.stderrText).not.toContain("Retry (3/2): "); + }).pipe(Effect.provide(layer)); + }); + + it.live("decrypt failure surfaces 'cannot decrypt access token'", () => { + const { layer } = setupLegacyLogin({ isTTY: true, decryptFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginDecryptError"); + expect(json).toContain("cannot decrypt access token"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("telemetry: successful profile fetch stitches the gotrue_id", () => { + const { layer, telemetry, analytics } = setupLegacyLogin({ gotrueId: "gotrue-abc" }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.stitchedDistinctId).toBe("gotrue-abc"); + expect(telemetry.clearedDistinctId).toBe(false); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "telemetry: profile fetch failure clears distinct_id but login still succeeds + still captures", + () => { + const { layer, out, telemetry, analytics } = setupLegacyLogin({ profileFails: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.clearedDistinctId).toBe(true); + expect(telemetry.stitchedDistinctId).toBeUndefined(); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("flushes telemetry state via ensuring", () => { + const { layer, telemetry } = setupLegacyLogin(); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format}: --token emits a single success result with no human banner`, () => { + const { layer, out } = setupLegacyLogin({ format }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You are now logged in."); + expect(out.stdoutText).not.toContain("Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + } + + it.live("browser flow: keygen failure exits with LegacyLoginCryptoError", () => { + const { layer } = setupLegacyLogin({ isTTY: true, keygenFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLoginCryptoError"); + } + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format}: browser flow emits a success result with token_name`, () => { + const { layer, out } = setupLegacyLogin({ format, isTTY: true, tokenName: "my-machine" }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You are now logged in."); + expect(success?.data).toMatchObject({ token_name: "my-machine" }); + expect(out.stdoutText).not.toContain("Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + } + + it.live( + "prints the Claude Code plugin hint to stderr when in Claude Code with a TTY stdout", + () => { + const prev = process.env["CLAUDECODE"]; + process.env["CLAUDECODE"] = "1"; + const { layer, out } = setupLegacyLogin({ stdoutIsTty: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(out.stderrText).toContain("claude-code-hint"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["CLAUDECODE"]; + else process.env["CLAUDECODE"] = prev; + }), + ), + ); + }, + ); + + it.live("persists ~/.supabase/profile on success when --profile is set", () => { + const { layer } = setupLegacyLogin({ + profileFlag: "supabase-staging", + homeDir: tempRoot.current, + }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + const profilePath = join(tempRoot.current, ".supabase", "profile"); + expect(existsSync(profilePath)).toBe(true); + expect(readFileSync(profilePath, "utf8")).toBe("supabase-staging"); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow in json mode fails cleanly at the prompt", () => { + const { layer } = setupLegacyLogin({ format: "json", isTTY: true, promptTextFail: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/login/login.layers.ts b/apps/cli/src/legacy/commands/login/login.layers.ts new file mode 100644 index 0000000000..4ed14b4295 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.layers.ts @@ -0,0 +1,40 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { browserLayer } from "../../../shared/runtime/browser.layer.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; +import { legacyLoginApiLayer } from "./login-api.layer.ts"; +import { legacyLoginCryptoLayer } from "./login-crypto.layer.ts"; + +// `login` is the only command that writes the access token, so it builds its own +// lean runtime instead of `legacyManagementApiRuntimeLayer` — it must NOT eagerly +// construct the platform-API client (which fails when no token exists yet). +// +// `legacyCliConfigLayer` is provided to both `legacyCredentialsLayer` and +// `legacyLoginApiLayer`, and exposed at the top level for the handler's direct +// `LegacyCliConfig` reads. `Layer.provide` does not share to siblings inside a +// `Layer.mergeAll` (legacy CLAUDE.md item 5), so the shared sub-layers are +// memoised by reference to avoid building two keyring readers / config loaders. +// `Analytics`, `Output`, `Stdio`, `Tty`, `TelemetryRuntime`, `FileSystem`, and +// `Path` come from the root layer. +const credentials = legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)); +const loginApi = legacyLoginApiLayer.pipe( + Layer.provide(legacyHttpClientLayer), + Layer.provide(legacyCliConfigLayer), +); + +export const legacyLoginRuntimeLayer = Layer.mergeAll( + credentials, + legacyCliConfigLayer, + legacyHttpClientLayer, + loginApi, + legacyLoginCryptoLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["login"]), + browserLayer, + stdinLayer, +); diff --git a/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md index 784207df75..292843f8f9 100644 --- a/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase logout` +Native TypeScript port of Go's `internal/logout`. Deletes the access token and +sweeps all stored project credentials. Makes no API calls. + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | --------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when keyring is unavailable; reads stored token to delete | +| Path | Format | When | +| -------------------------- | ------------------------- | ---------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | existence is checked before removal (no token parse) | ## Files Written -| Path | Format | When | -| -------------------------- | ------ | -------------------------------------------------- | -| `~/.supabase/access-token` | — | deleted on successful logout when keyring not used | +| Path | Format | When | +| ----------------------------------------------- | ------ | ---------------------------------------------------------------------------- | +| `~/.supabase/access-token` | — | deleted first, always (a missing file is ignored) | +| OS keyring (`Supabase CLI` namespace) | — | the access-token entries **and** all project DB-password entries are deleted | +| `/telemetry.json` | JSON | always (PersistentPostRun flush) | ## API Routes @@ -20,41 +25,47 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | not consumed by logout; env token is not deleted | no | +| Variable | Purpose | Required? | +| ---------------------- | -------------------------------------------------- | --------- | +| `SUPABASE_YES`/`--yes` | auto-confirm the logout prompt | no | +| `SUPABASE_NO_KEYRING` | disables the OS keyring (forces the file fallback) | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------ | -| `0` | success — all stored credentials deleted | -| `0` | not logged in — nothing to delete, exits cleanly | -| `1` | user cancels the confirmation prompt (`context.Canceled`) | -| `1` | failure to delete credential file (e.g. `$HOME` not defined) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------- | +| `0` | success — token + project credentials deleted | +| `0` | not logged in — profile keyring entry absent / keyring unavailable (prints to stderr) | +| `1` | user declines the confirmation prompt (`context canceled`) | +| `1` | a real removal failure — non-`ENOENT` file remove error or a real profile-keyring delete error | -## Output +## Telemetry Events Fired -### `--output-format text` (Go CLI compatible) +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | --------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms` | -Prints a confirmation prompt to stdout. On success, prints a logout confirmation. +## Output -``` -Do you want to log out? [Y/n] -Finished supabase logout. -``` +### `--output-format text` (Go CLI compatible) -### `--output-format json` +stdout (success): `Access token deleted successfully. You are now logged out.` -Not applicable — logout is an interactive confirmation command. No machine-readable JSON output defined. +stderr: the confirm prompt `Do you want to log out? This will remove the access token +from your system.`; the not-logged-in notice `You were not logged in, nothing to do.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable — logout is an interactive confirmation command. +Emits a single structured `success` result. Without `--yes`, the confirm prompt fails +with `NonInteractiveError` in these modes. ## Notes -- Removes all Supabase CLI credentials: the access token and any stored project database passwords. -- The command prompts for confirmation before deleting credentials (even in non-TTY mode if stdin is connected). -- If keyring reports `ErrUnsupportedPlatform`, falls back to deleting `~/.supabase/access-token` file. -- `SUPABASE_ACCESS_TOKEN` env var is not affected by logout (it is not written to disk by the CLI). +- **Deliberate Go quirk (parity):** `deleteAccessToken` removes the file first, but the + outcome is decided solely by the profile-keyring delete. On a no-keyring host + (WSL / `SUPABASE_NO_KEYRING`) or when the token lived only in the file, the file is + removed yet logout still reports `You were not logged in, nothing to do.` and exits 0. +- The legacy `access-token` keyring entry delete is best-effort — its failure never + changes the outcome. +- Project DB-password credentials are swept only after a successful token delete; the + sweep is best-effort and never fails (Go's `StoreProvider.DeleteAll`). diff --git a/apps/cli/src/legacy/commands/logout/logout.command.ts b/apps/cli/src/legacy/commands/logout/logout.command.ts index d400ebbe97..3cb1cc3efd 100644 --- a/apps/cli/src/legacy/commands/logout/logout.command.ts +++ b/apps/cli/src/legacy/commands/logout/logout.command.ts @@ -1,8 +1,15 @@ import { Command } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { legacyLogoutRuntimeLayer } from "./logout.layers.ts"; import { legacyLogout } from "./logout.handler.ts"; export const legacyLogoutCommand = Command.make("logout").pipe( Command.withDescription("Log out and delete access tokens locally."), Command.withShortDescription("Log out and delete access tokens locally"), - Command.withHandler(() => legacyLogout()), + Command.withHandler(() => + legacyLogout().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyLogoutRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts new file mode 100644 index 0000000000..a5a0f50fed --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function seedTokenFile(home: string): string { + const supaDir = join(home, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + const tokenPath = join(supaDir, "access-token"); + writeFileSync(tokenPath, VALID_TOKEN, { mode: 0o600 }); + return tokenPath; +} + +describe("supabase logout (legacy)", () => { + // Deliberate Go quirk (parity note 1): under SUPABASE_NO_KEYRING=1 the profile + // keyring delete is unsupported, so logout removes the file token yet still + // reports "not logged in" and exits 0. + test( + "logout --yes removes a file token but reports not-logged-in under no-keyring", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const tokenPath = seedTokenFile(home.dir); + const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + expect(existsSync(tokenPath)).toBe(false); + }, + ); + + // No token at all: same not-logged-in message, exit 0. + test( + "logout --yes with no token reports not-logged-in and exits 0", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts new file mode 100644 index 0000000000..1f3dd768f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -0,0 +1,14 @@ +import { Data } from "effect"; + +/** + * Raised when the user declines the logout confirmation prompt. Go returns + * `errors.New(context.Canceled)` (`apps/cli-go/internal/logout/logout.go:18`), + * which the root error handler renders as `context canceled` on stderr with + * exit code 1 (`cmd/root.go:288-301` skips the debug suggestion for + * `context.Canceled`). + */ +export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ + readonly message: string; +}> {} + +export const LEGACY_LOGOUT_CANCELLED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index 954a958af8..5c4cd5855b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -1,7 +1,65 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyCredentials } from "../../auth/legacy-credentials.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 { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; + +const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; export const legacyLogout = Effect.fn("legacy.logout")(function* () { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["logout"]); + const output = yield* Output; + const credentials = yield* LegacyCredentials; + const telemetryState = yield* LegacyTelemetryState; + const yes = yield* LegacyYesFlag; + + const body = Effect.gen(function* () { + // Confirm prompt, honoring the global `--yes` (`logout.go:15`). + const confirmed = yes + ? true + : yield* output.promptConfirm( + "Do you want to log out? This will remove the access token from your system.", + { defaultValue: false }, + ); + if (!confirmed) { + return yield* Effect.fail( + new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), + ); + } + + // Delete the access token. `LegacyNotLoggedInError` is the not-logged-in + // path (print to stderr, exit 0, and do NOT sweep project credentials — + // Go returns before `DeleteAll`, `logout.go:21-23`). `LegacyDeleteTokenError` + // propagates as exit 1 (`logout.go:24-26`). + const notLoggedIn = yield* credentials.deleteAccessToken.pipe( + Effect.as(false), + Effect.catchTag("LegacyNotLoggedInError", (err) => + Effect.gen(function* () { + if (output.format !== "text") { + // Machine modes have no Go equivalent (Go is text-only). Emit the + // message as the structured result so consumers can distinguish the + // not-logged-in outcome from a real logout instead of an empty blob. + yield* output.success(err.message); + } else { + yield* output.raw(`${err.message}\n`, "stderr"); + } + return true; + }), + ), + ); + if (notLoggedIn) return; + + // Best-effort sweep of all stored project DB passwords (`logout.go:29-31`). + yield* credentials.deleteAllProjectCredentials; + + if (output.format !== "text") { + yield* output.success(LOGGED_OUT_MSG); + return; + } + yield* output.raw(`${LOGGED_OUT_MSG}\n`, "stdout"); + }); + + // PersistentPostRun parity: persist telemetry state on success and failure. + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts new file mode 100644 index 0000000000..376d88d9f2 --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCredentialsTracked, + mockLegacyTelemetryStateTracked, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyLogout } from "./logout.handler.ts"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly confirm?: boolean; + readonly yes?: boolean; + readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; + readonly promptConfirmFail?: boolean; +} + +function setupLegacyLogout(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + confirmLogout: opts.confirm ?? false, + promptConfirmFail: opts.promptConfirmFail, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ deleteOutcome: opts.deleteOutcome ?? "ok" }); + const layer = Layer.mergeAll( + out.layer, + credentials.layer, + telemetry.layer, + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ); + return { layer, out, telemetry, credentials }; +} + +describe("legacy logout integration", () => { + it.live("confirms then deletes the token + all project credentials", () => { + const { layer, out, credentials } = setupLegacyLogout({ confirm: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + expect(out.stdoutText).toContain( + "Access token deleted successfully. You are now logged out.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("--yes skips the prompt and logs out", () => { + const { layer, out, credentials } = setupLegacyLogout({ yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + expect(out.stdoutText).toContain( + "Access token deleted successfully. You are now logged out.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("declining the prompt cancels with a failure and does not sweep credentials", () => { + const { layer, credentials } = setupLegacyLogout({ confirm: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogout()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLogoutCancelledError"); + } + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("not logged in: prints to stderr, exits 0, and does not sweep credentials", () => { + const { layer, out, credentials } = setupLegacyLogout({ + yes: true, + deleteOutcome: "notLoggedIn", + }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(out.stderrText).toContain("You were not logged in, nothing to do."); + expect(out.stdoutText).not.toContain("Access token deleted successfully."); + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("delete failure propagates as a failure", () => { + const { layer, credentials } = setupLegacyLogout({ yes: true, deleteOutcome: "deleteError" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogout()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + } + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on success", () => { + const { layer, telemetry } = setupLegacyLogout({ yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on cancel", () => { + const { layer, telemetry } = setupLegacyLogout({ confirm: false }); + return Effect.gen(function* () { + yield* Effect.exit(legacyLogout()); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format} with --yes emits a single success result`, () => { + const { layer, out } = setupLegacyLogout({ format, yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Access token deleted successfully. You are now logged out."); + expect(out.stdoutText).not.toContain("Access token deleted successfully."); + }).pipe(Effect.provide(layer)); + }); + } + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format} not-logged-in emits the not-logged-in message as the result`, () => { + const { layer, out, credentials } = setupLegacyLogout({ + format, + yes: true, + deleteOutcome: "notLoggedIn", + }); + return Effect.gen(function* () { + yield* legacyLogout(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You were not logged in, nothing to do."); + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + } + + it.live("json mode without --yes fails cleanly at the confirm prompt", () => { + const { layer } = setupLegacyLogout({ format: "json", yes: false, promptConfirmFail: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogout()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/logout/logout.layers.ts b/apps/cli/src/legacy/commands/logout/logout.layers.ts new file mode 100644 index 0000000000..9c7d671a94 --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.layers.ts @@ -0,0 +1,24 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; + +/** + * Lean runtime for `logout`. Like `unlink`, it must NOT use + * `legacyManagementApiRuntimeLayer` — that layer eagerly builds the platform-API + * client, which fails with "Access token not provided" when logging out without + * a token. It provides only what the handler + instrumentation consume. + * + * `legacyCliConfigLayer` is provided to `legacyCredentialsLayer` and also exposed + * at the top level (`Layer.provide` does not share to siblings inside a merge — + * legacy CLAUDE.md item 5). `Analytics`, `Output`, `Stdio`, `FileSystem`, + * `Path`, `TelemetryRuntime`, and `LegacyYesFlag` come from the root layer. + */ +export const legacyLogoutRuntimeLayer = Layer.mergeAll( + legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)), + legacyCliConfigLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["logout"]), +); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 9ea6c773d7..41e461ddcf 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -5,6 +5,7 @@ import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global import { legacyProjectHost } from "../shared/legacy-profile.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; +import { readLegacyProfileFile } from "./legacy-profile-file.ts"; interface ResolvedProfile { readonly name: string; @@ -51,6 +52,10 @@ function safeParseYaml( * Resolves the profile that produces the API URL. Mirrors Go's `LoadProfile` * (`apps/cli-go/internal/utils/profile.go:96-118`): * + * Profile-name precedence mirrors Go's `getProfileName` (`profile.go:121-136`): + * `--profile` flag (when not the default) → `SUPABASE_PROFILE` env → the + * persisted `~/.supabase/profile` file → `supabase`. The resolved token is then: + * * 1. If the token matches a built-in profile name, use that. * 2. Otherwise treat the token as a path to a YAML config file with `api_url:`. * 3. Fall back to the `supabase` built-in if the file is missing or malformed. @@ -64,9 +69,21 @@ function resolveProfile( flagValue: string, envValue: string | undefined, fs: FileSystem.FileSystem, + path: Path.Path, + homeDir: string, ): Effect.Effect { return Effect.gen(function* () { - const token = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); + let token: string; + if (flagValue !== "supabase") { + token = flagValue; + } else if (envValue !== undefined && envValue.length > 0) { + token = envValue; + } else { + // Lowest precedence: the persisted `~/.supabase/profile` file (Go's + // `getProfileName` file fallback, `profile.go:129-131`). + const fileName = yield* readLegacyProfileFile(fs, path, homeDir); + token = Option.getOrElse(fileName, () => "supabase"); + } if (isBuiltinProfileName(token)) { return resolvedBuiltin(token); @@ -137,7 +154,13 @@ export const legacyCliConfigLayer = Layer.unwrap( name: profile, apiUrl, projectHost, - } = yield* resolveProfile(profileFlag, env["SUPABASE_PROFILE"], fs); + } = yield* resolveProfile( + profileFlag, + env["SUPABASE_PROFILE"], + fs, + path, + runtimeInfo.homeDir, + ); const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; const accessToken = diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 387a93ece4..e2053a0abd 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -17,13 +17,14 @@ function makeLayer(opts: { workdirFlag?: Option.Option; env?: Record; cwd?: string; + home?: string; }) { const profileFlag = opts.profileFlag ?? "supabase"; const workdirFlag = opts.workdirFlag ?? Option.none(); return legacyCliConfigLayer.pipe( Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), - Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd" })), + Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", homeDir: opts.home })), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(opts.env ?? {})), ); @@ -75,6 +76,31 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), ); + it.effect("reads the persisted ~/.supabase/profile file when no flag/env is set", () => { + const home = join(tempRoot, "home"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(join(home, ".supabase", "profile"), "supabase-staging\n"); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + }).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot }))); + }); + + it.effect("flag and env take precedence over the persisted profile file", () => { + const home = join(tempRoot, "home"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(join(home, ".supabase", "profile"), "supabase-staging"); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + // SUPABASE_PROFILE wins over the file. + expect(config.profile).toBe("supabase-local"); + }).pipe( + Effect.provide( + makeLayer({ home, cwd: tempRoot, env: { SUPABASE_PROFILE: "supabase-local" } }), + ), + ); + }); + it.effect( "falls back to supabase profile when SUPABASE_PROFILE is neither a known name nor a readable file", () => diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts new file mode 100644 index 0000000000..2c3edd5165 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -0,0 +1,55 @@ +import { Data, Effect, FileSystem, Option, Path } from "effect"; + +/** + * Helpers for the persisted profile-name file `~/.supabase/profile`, mirroring + * Go's `getProfileName` file fallback and `SaveProfileName` + * (`apps/cli-go/internal/utils/profile.go:121-152`). + * + * `login` writes this file (on success, when a profile was explicitly set) so a + * later command run without `--profile` / `SUPABASE_PROFILE` resolves the same + * profile; `LegacyCliConfig` reads it as the lowest-precedence profile source. + */ + +/** Raised when persisting the profile name fails — Go's `SaveProfileName` error, + * which `login`'s PostRunE returns to block subsequent CI commands + * (`apps/cli-go/cmd/login.go:42-46`). */ +export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveError")<{ + readonly message: string; +}> {} + +function legacyProfileFilePath(path: Path.Path, homeDir: string): string { + return path.join(homeDir, ".supabase", "profile"); +} + +/** Reads `~/.supabase/profile`, returning `None` when missing or empty. */ +export const readLegacyProfileFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + homeDir: string, +): Effect.Effect> => + Effect.gen(function* () { + const filePath = legacyProfileFilePath(path, homeDir); + const content = yield* fs.readFileString(filePath).pipe(Effect.option); + if (Option.isNone(content)) return Option.none(); + const trimmed = content.value.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + +/** Writes the profile name to `~/.supabase/profile`. Fatal on failure (Go parity). */ +export const saveLegacyProfileName = ( + fs: FileSystem.FileSystem, + path: Path.Path, + homeDir: string, + name: string, +): Effect.Effect => + Effect.gen(function* () { + const filePath = legacyProfileFilePath(path, homeDir); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, name); + }).pipe( + Effect.catch((error) => + Effect.fail( + new LegacyProfileSaveError({ message: `failed to save profile: ${error.message}` }), + ), + ), + ); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index fa709cc384..69ec359015 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -1,6 +1,8 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { homedir } from "node:os"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; interface State { @@ -120,6 +122,27 @@ export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled" return nextState; }); +/** + * Re-derives the current telemetry state (reusing `loadOrCreateLegacyTelemetryState`'s + * read / session-rotation / merge — no third copy of that logic) and writes it + * back with the `distinct_id` field set (`stitchLogin`) or removed + * (`clearDistinctId`). Mirrors Go's `SaveState(s.state, fsys)` after mutating + * `s.state.DistinctID` (`service.go:141-150`). + */ +const persistLegacyDistinctId = Effect.fn("legacy.telemetry.persistDistinctId")(function* ( + distinctId: string | undefined, +) { + const base = yield* loadOrCreateLegacyTelemetryState(); + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const { distinct_id: _drop, ...rest } = base; + const nextState: State = + distinctId !== undefined && distinctId.length > 0 ? { ...rest, distinct_id: distinctId } : rest; + const filePath = legacyTelemetryPath(process.env, pathSvc); + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(nextState)); +}); + /** * Writes `/telemetry.json` on every command run. * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): @@ -141,12 +164,30 @@ export const legacyTelemetryStateLayer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; + const analytics = yield* Analytics; + const runtime = yield* TelemetryRuntime; + + const provide = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathSvc), + ); + return LegacyTelemetryState.of({ - flush: loadOrCreateLegacyTelemetryState().pipe( + flush: provide(loadOrCreateLegacyTelemetryState()).pipe(Effect.asVoid, Effect.ignore), + stitchLogin: (distinctId: string) => + // Go's `StitchLogin` always sets `state.DistinctID = distinctId` + // (replacing any stale value) and sends the alias through analytics, + // which gates delivery on consent (`service.go:132-143`). The alias is + // fire-and-forget here so a PostHog delivery error never prevents the + // `distinct_id` from being persisted to `telemetry.json`. + Effect.gen(function* () { + yield* analytics.alias(distinctId, runtime.deviceId).pipe(Effect.ignore); + yield* provide(persistLegacyDistinctId(distinctId)); + }).pipe(Effect.ignore), + clearDistinctId: provide(persistLegacyDistinctId(undefined)).pipe( Effect.asVoid, Effect.ignore, - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathSvc), ), }); }), diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts new file mode 100644 index 0000000000..50a8371df7 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { mockAnalytics } from "../../../tests/helpers/mocks.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; +import { legacyTelemetryStateLayer } from "./legacy-telemetry-state.layer.ts"; +import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; + +let tempHome: string; +let prevHome: string | undefined; + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-telemetry-")); + prevHome = process.env["SUPABASE_HOME"]; + process.env["SUPABASE_HOME"] = tempHome; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env["SUPABASE_HOME"]; + else process.env["SUPABASE_HOME"] = prevHome; + rmSync(tempHome, { recursive: true, force: true }); +}); + +const runtimeLayer = Layer.succeed(TelemetryRuntime, { + configDir: "/tmp", + tracesDir: "/tmp", + consent: "granted", + showDebug: false, + deviceId: "device-xyz", + sessionId: "session-1", + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.0.0-dev", +}); + +function makeLayer(analytics: ReturnType) { + return legacyTelemetryStateLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(analytics.layer), + Layer.provide(runtimeLayer), + ); +} + +const telemetryPath = () => join(tempHome, "telemetry.json"); +const readState = (): Record => + JSON.parse(readFileSync(telemetryPath(), "utf8")) as Record; +const seedState = (distinctId?: string) => + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: true, + device_id: "device-xyz", + session_id: "session-1", + session_last_active: new Date().toISOString(), + ...(distinctId !== undefined ? { distinct_id: distinctId } : {}), + schema_version: 1, + }), + ); + +describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { + it.effect("stitchLogin aliases the device id and persists the distinct_id", () => { + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.stitchLogin("gotrue-1"); + expect(analytics.aliased).toEqual([{ distinctId: "gotrue-1", alias: "device-xyz" }]); + expect(readState().distinct_id).toBe("gotrue-1"); + }).pipe(Effect.provide(makeLayer(analytics))); + }); + + it.effect("stitchLogin replaces a stale distinct_id (parity: stale id is replaced)", () => { + seedState("stale-id"); + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.stitchLogin("fresh-id"); + expect(readState().distinct_id).toBe("fresh-id"); + }).pipe(Effect.provide(makeLayer(analytics))); + }); + + it.effect("clearDistinctId removes the persisted distinct_id", () => { + seedState("to-clear"); + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.clearDistinctId; + expect(readState().distinct_id).toBeUndefined(); + }).pipe(Effect.provide(makeLayer(analytics))); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts index c39e094487..47ac612da8 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts @@ -9,6 +9,23 @@ interface LegacyTelemetryStateShape { * Best-effort: any filesystem error is swallowed. */ readonly flush: Effect.Effect; + /** + * Aliases the device id to the resolved gotrue id and persists it as the + * telemetry `distinct_id`. Mirrors Go's `Service.StitchLogin` + * (`service.go:132-143`): the alias is sent through the Analytics layer + * (which gates delivery on consent), and `distinct_id` is **always** written + * to `telemetry.json` — replacing any stale value. + * + * Best-effort: filesystem / analytics errors are swallowed. + */ + readonly stitchLogin: (distinctId: string) => Effect.Effect; + /** + * Clears the persisted telemetry `distinct_id`. Mirrors Go's + * `Service.ClearDistinctID` (`service.go:145-151`). + * + * Best-effort: any filesystem error is swallowed. + */ + readonly clearDistinctId: Effect.Effect; } export class LegacyTelemetryState extends Context.Service< diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index a3ae051cea..a2497bbd11 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -14,8 +14,23 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; -import { LegacyCredentialDeleteError } from "../../src/legacy/auth/legacy-errors.ts"; +import { + LegacyCredentialDeleteError, + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "../../src/legacy/auth/legacy-errors.ts"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; +import { + LegacyLoginApi, + type LegacyLoginSessionResponse, +} from "../../src/legacy/commands/login/login-api.service.ts"; +import { LegacyLoginCrypto } from "../../src/legacy/commands/login/login-crypto.service.ts"; +import { + LegacyLoginCryptoError, + LegacyLoginDecryptError, + LegacyLoginVerificationError, +} from "../../src/legacy/commands/login/login.errors.ts"; import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../src/legacy/config/legacy-project-ref.layer.ts"; import { LegacyLinkedProjectCache } from "../../src/legacy/telemetry/legacy-linked-project-cache.service.ts"; @@ -49,6 +64,8 @@ export const mockLegacyLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProje export const mockLegacyTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void, + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, }); // Default LegacyCredentials mock. `mockLegacyCliConfig` defaults to an env-set @@ -60,24 +77,67 @@ export const mockLegacyCredentialsLayer = Layer.succeed(LegacyCredentials, { getAccessToken: Effect.sync(() => Option.none()), saveAccessToken: () => Effect.die("unexpected legacy credentials write in test"), deleteAccessToken: Effect.die("unexpected legacy credentials delete in test"), + deleteAllProjectCredentials: Effect.die("unexpected legacy project-credential sweep in test"), deleteProjectCredential: () => Effect.die("unexpected legacy project-credential delete in test"), }); /** - * Tracked `LegacyCredentials` mock for `supabase unlink` tests. Records the - * project refs passed to `deleteProjectCredential` and lets the test choose the - * outcome: `true`/`false` (deleted / not found) or a `LegacyCredentialDeleteError` - * (e.g. permission-denied keyring failure). + * Tracked `LegacyCredentials` mock for `unlink` / `login` / `logout` tests. + * + * - `deleteProjectCredential` (unlink): records refs in `deletedRefs`; `deleteFails` + * makes it raise `LegacyCredentialDeleteError`. + * - `saveAccessToken` (login): records the saved token in `savedToken`; `saveFails` + * raises `LegacyInvalidAccessTokenError` (the token-path "cannot save" branch). + * - `deleteAccessToken` (logout): `deleteOutcome` selects success (`"ok"`), + * `LegacyNotLoggedInError` (`"notLoggedIn"`), or `LegacyDeleteTokenError` + * (`"deleteError"`). + * - `deleteAllProjectCredentials` (logout): flips `deletedAll`. */ -export function mockLegacyCredentialsTracked(opts: { readonly deleteFails?: boolean } = {}): { +export function mockLegacyCredentialsTracked( + opts: { + readonly deleteFails?: boolean; + readonly saveFails?: boolean; + readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; + } = {}, +): { readonly layer: Layer.Layer; readonly deletedRefs: ReadonlyArray; + readonly savedToken: string | undefined; + readonly deletedAll: boolean; } { const deletedRefs: string[] = []; + let savedToken: string | undefined; + let deletedAll = false; + + const deleteAccessToken = + opts.deleteOutcome === "notLoggedIn" + ? Effect.fail( + new LegacyNotLoggedInError({ message: "You were not logged in, nothing to do." }), + ) + : opts.deleteOutcome === "deleteError" + ? Effect.fail( + new LegacyDeleteTokenError({ + message: "failed to remove access token file: permission denied", + }), + ) + : Effect.void; + const layer = Layer.succeed(LegacyCredentials, { getAccessToken: Effect.sync(() => Option.none()), - saveAccessToken: () => Effect.die("unexpected legacy credentials write in test"), - deleteAccessToken: Effect.die("unexpected legacy credentials delete in test"), + saveAccessToken: (token: string) => + opts.saveFails === true + ? Effect.fail( + new LegacyInvalidAccessTokenError({ + message: "Invalid access token format. Must be like `sbp_0102...1920`.", + }), + ) + : Effect.sync(() => { + savedToken = token; + }), + deleteAccessToken, + deleteAllProjectCredentials: Effect.sync(() => { + deletedAll = true; + }), deleteProjectCredential: (projectRef: string) => Effect.gen(function* () { deletedRefs.push(projectRef); @@ -96,6 +156,100 @@ export function mockLegacyCredentialsTracked(opts: { readonly deleteFails?: bool get deletedRefs() { return deletedRefs; }, + get savedToken() { + return savedToken; + }, + get deletedAll() { + return deletedAll; + }, + }; +} + +// --------------------------------------------------------------------------- +// Login crypto / API mocks. The crypto mock returns a dummy ECDH handle (the +// browser-flow integration tests never reach a real decrypt — the API mock +// supplies the ciphertext and the crypto mock returns the decrypted token). +// --------------------------------------------------------------------------- + +export function mockLegacyLoginCrypto( + opts: { + readonly publicKeyHex?: string; + readonly sessionId?: string; + readonly tokenName?: string; + readonly decryptedToken?: string; + readonly decryptFails?: boolean; + readonly keygenFails?: boolean; + } = {}, +): { readonly layer: Layer.Layer } { + const layer = Layer.succeed(LegacyLoginCrypto, { + generateKeyPair: opts.keygenFails + ? Effect.fail(new LegacyLoginCryptoError({ message: "cannot generate crypto keys: boom" })) + : Effect.succeed({ + ecdh: {} as import("node:crypto").ECDH, + publicKeyHex: opts.publicKeyHex ?? "04abcd", + }), + generateSessionId: Effect.sync(() => opts.sessionId ?? "test-session-id"), + defaultTokenName: Effect.sync(() => opts.tokenName ?? "cli_test@host_123"), + decryptToken: () => + opts.decryptFails + ? Effect.fail( + new LegacyLoginDecryptError({ + message: "cannot decrypt access token: cipher: message authentication failed", + }), + ) + : Effect.succeed(opts.decryptedToken ?? LEGACY_VALID_TOKEN), + }); + return { layer }; +} + +export function mockLegacyLoginApi( + opts: { + readonly sessionResponse?: Partial; + // Number of `fetchLoginSession` failures before it succeeds (drives the + // verification retry loop). + readonly failTimes?: number; + // `gotrue_id` returned by `fetchGotrueId` (Some); `profileFails` returns None. + readonly gotrueId?: string; + readonly profileFails?: boolean; + } = {}, +): { + readonly layer: Layer.Layer; + readonly loginCallCount: number; + readonly gotrueCallCount: number; +} { + let loginCallCount = 0; + let gotrueCallCount = 0; + const failTimes = opts.failTimes ?? 0; + const session: LegacyLoginSessionResponse = { + access_token: "656e6372797074656420746f6b656e", + public_key: "04abcd", + nonce: "0102030405060708090a0b0c", + ...opts.sessionResponse, + }; + const layer = Layer.succeed(LegacyLoginApi, { + fetchLoginSession: () => { + loginCallCount += 1; + if (loginCallCount <= failTimes) { + return Effect.fail( + new LegacyLoginVerificationError({ message: "Error status 404: not found" }), + ); + } + return Effect.succeed(session); + }, + fetchGotrueId: () => { + gotrueCallCount += 1; + if (opts.profileFails === true) return Effect.succeed(Option.none()); + return Effect.succeed(Option.some(opts.gotrueId ?? "gotrue-user-123")); + }, + }); + return { + layer, + get loginCallCount() { + return loginCallCount; + }, + get gotrueCallCount() { + return gotrueCallCount; + }, }; } @@ -108,20 +262,39 @@ export function mockLegacyCredentialsTracked(opts: { readonly deleteFails?: bool export function mockLegacyTelemetryStateTracked(): { readonly layer: Layer.Layer; readonly flushed: boolean; + readonly stitchedDistinctId: string | undefined; + readonly clearedDistinctId: boolean; } { let flushed = false; + let stitchedDistinctId: string | undefined; + let clearedDistinctId = false; const layer = Layer.succeed(LegacyTelemetryState, { get flush() { return Effect.sync(() => { flushed = true; }); }, + stitchLogin: (distinctId: string) => + Effect.sync(() => { + stitchedDistinctId = distinctId; + }), + get clearDistinctId() { + return Effect.sync(() => { + clearedDistinctId = true; + }); + }, }); return { layer, get flushed() { return flushed; }, + get stitchedDistinctId() { + return stitchedDistinctId; + }, + get clearedDistinctId() { + return clearedDistinctId; + }, }; } diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 1689ca1086..2bec89d266 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -222,6 +222,7 @@ export function mockOutput( confirmRelogin?: boolean; confirmLogout?: boolean; promptTextFail?: boolean; + promptConfirmFail?: boolean; promptTextResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptPasswordResponses?: ReadonlyArray; @@ -375,9 +376,16 @@ export function mockOutput( })(), promptPassword: () => Effect.succeed(promptPasswordResponses.shift() ?? ""), promptConfirm: (_message, _opts) => - Effect.succeed( - promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, - ), + opts.promptConfirmFail + ? Effect.fail( + new NonInteractiveError({ + detail: "Prompt cancelled", + suggestion: "Run in interactive mode", + }), + ) + : Effect.succeed( + promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, + ), promptSelect: (message, options, behavior) => Effect.sync(() => { promptSelectCalls.push({ message, options, behavior }); From 82ad83078449578eface34c13630c0f1caa56ece Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:46:35 +0000 Subject: [PATCH 34/38] chore(ci): bump aws-actions/configure-aws-credentials from 6.1.2 to 6.1.3 in the actions-major group (#5463) Bumps the actions-major group with 1 update: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials). Updates `aws-actions/configure-aws-credentials` from 6.1.2 to 6.1.3
    Release notes

    Sourced from aws-actions/configure-aws-credentials's releases.

    v6.1.3

    6.1.3 (2026-05-27)

    Bug Fixes

    Changelog

    Sourced from aws-actions/configure-aws-credentials's changelog.

    Changelog

    All notable changes to this project will be documented in this file. See standard-version for commit guidelines.

    6.2.0 (2026-06-01)

    Features

    Bug Fixes

    • skip credential check on output-env-credentials: false (#1778) (58e7c47)
    • assumeRole failing from session tag size too large (#1808) (d6f5dc3)

    6.1.3 (2026-05-28)

    Bug Fixes

    • fix: allow kubelet token symlink in #1805

    6.1.2 (2026-05-26)

    Bug Fixes

    6.1.1 (2026-05-05)

    Miscellaneous Chores

    • various dependency updates

    6.1.0 (2026-04-06)

    Features

    6.0.0 (2026-02-04)

    ... (truncated)

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=aws-actions/configure-aws-credentials&package-manager=github_actions&previous-version=6.1.2&new-version=6.1.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-mirror-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index c608212502..489d29657a 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,7 +34,7 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2 + uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6.1.3 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 From 26526e4e30163d5d79284351822d3cde4c62f869 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:00:04 +0000 Subject: [PATCH 35/38] fix(docker): bump supabase/realtime from v2.103.1 to v2.103.2 in /apps/cli-go/pkg/config/templates in the docker-minor group (#5464) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 1 update: supabase/realtime. Updates `supabase/realtime` from v2.103.1 to v2.103.2 [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=supabase/realtime&package-manager=docker&previous-version=v2.103.1&new-version=v2.103.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julien Goux --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 6c3c9107b5..753d10ee35 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,7 +11,7 @@ FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.103.1 AS realtime +FROM supabase/realtime:v2.103.2 AS realtime FROM supabase/storage-api:v1.60.4 AS storage FROM supabase/logflare:1.43.3 AS logflare # Append to JobImages when adding new dependencies below From 0172ee9875df10f41520b12744ff056979854b1b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 4 Jun 2026 10:08:01 +0200 Subject: [PATCH 36/38] fix(cli): persist legacy telemetry opt-out (#5465) ## What changed This makes the telemetry state file compatible in both directions between the Go legacy CLI and the TypeScript telemetry runtime. The Go telemetry disable command writes the legacy state shape with enabled: false, while the TypeScript runtime expects the newer consent: denied shape before suppressing the first-run telemetry notice. Because the runtime treated the legacy file as invalid or missing, every TTY command could show the telemetry notice again after opt-out. The TypeScript reader now maps legacy enabled state into effective consent, and the Go loader now understands TypeScript consent state so either side preserves the user opt-out and identity/session fields. ## Reviewer context Regression coverage was added for legacy disabled/enabled state in the TypeScript consent/runtime tests, and for Go loading a TypeScript disabled telemetry config. --- apps/cli-go/internal/telemetry/state.go | 81 +++++++++++++++++-- apps/cli-go/internal/telemetry/state_test.go | 48 +++++++++++ apps/cli/src/shared/telemetry/consent.ts | 50 +++++++++++- .../src/shared/telemetry/consent.unit.test.ts | 60 ++++++++++++++ .../telemetry/runtime.layer.unit.test.ts | 27 +++++++ 5 files changed, 259 insertions(+), 7 deletions(-) diff --git a/apps/cli-go/internal/telemetry/state.go b/apps/cli-go/internal/telemetry/state.go index 8d69996468..825ce5fc7c 100644 --- a/apps/cli-go/internal/telemetry/state.go +++ b/apps/cli-go/internal/telemetry/state.go @@ -32,6 +32,16 @@ type State struct { SchemaVersion int `json:"schema_version"` } +type rawState struct { + Enabled *bool `json:"enabled"` + Consent *string `json:"consent"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + SessionLastActive json.RawMessage `json:"session_last_active"` + DistinctID string `json:"distinct_id,omitempty"` + SchemaVersion int `json:"schema_version"` +} + func telemetryPath() (string, error) { if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" { return filepath.Join(home, "telemetry.json"), nil @@ -43,6 +53,71 @@ func telemetryPath() (string, error) { return filepath.Join(home, ".supabase", "telemetry.json"), nil } +func parseConsent(raw rawState) (bool, bool, error) { + if raw.Consent != nil { + switch *raw.Consent { + case "granted": + return true, true, nil + case "denied": + return false, true, nil + default: + return false, false, errors.Errorf("%w: invalid consent", errMalformedState) + } + } + if raw.Enabled == nil { + return false, false, errors.Errorf("%w: missing enabled", errMalformedState) + } + return *raw.Enabled, false, nil +} + +func parseSessionLastActive(raw json.RawMessage, allowUnixMillis bool) (time.Time, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + parsed, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) + } + return parsed, nil + } + if allowUnixMillis { + var millis int64 + if err := json.Unmarshal(raw, &millis); err == nil { + return time.UnixMilli(millis).UTC(), nil + } + } + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) +} + +func decodeState(contents []byte) (State, error) { + var raw rawState + if err := json.Unmarshal(contents, &raw); err != nil { + return State{}, errors.Errorf("%w: %v", errMalformedState, err) + } + enabled, allowUnixMillis, err := parseConsent(raw) + if err != nil { + return State{}, err + } + sessionLastActive, err := parseSessionLastActive(raw.SessionLastActive, allowUnixMillis) + if err != nil { + return State{}, err + } + if raw.DeviceID == "" || raw.SessionID == "" { + return State{}, errors.Errorf("%w: missing identity", errMalformedState) + } + schemaVersion := raw.SchemaVersion + if schemaVersion == 0 { + schemaVersion = SchemaVersion + } + return State{ + Enabled: enabled, + DeviceID: raw.DeviceID, + SessionID: raw.SessionID, + SessionLastActive: sessionLastActive, + DistinctID: raw.DistinctID, + SchemaVersion: schemaVersion, + }, nil +} + func LoadState(fsys afero.Fs) (State, error) { path, err := telemetryPath() if err != nil { @@ -52,11 +127,7 @@ func LoadState(fsys afero.Fs) (State, error) { if err != nil { return State{}, err } - var state State - if err := json.Unmarshal(contents, &state); err != nil { - return State{}, errors.Errorf("%w: %v", errMalformedState, err) - } - return state, nil + return decodeState(contents) } func SaveState(state State, fsys afero.Fs) error { diff --git a/apps/cli-go/internal/telemetry/state_test.go b/apps/cli-go/internal/telemetry/state_test.go index 9cd03dd967..7ca3a178c6 100644 --- a/apps/cli-go/internal/telemetry/state_test.go +++ b/apps/cli-go/internal/telemetry/state_test.go @@ -79,6 +79,54 @@ func TestLoadOrCreateState(t *testing.T) { assert.Equal(t, now, state.SessionLastActive) }) + t.Run("reads disabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"denied","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.False(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + + t.Run("reads enabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"granted","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + t.Run("recovers from corrupted state file", func(t *testing.T) { // Each entry simulates a real-world corruption shape we've observed. corruptions := map[string][]byte{ diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 1f1bd235f1..275676d562 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,17 +1,63 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); const TelemetryConfigFileSchema = Schema.fromJsonString(TelemetryConfigSchema); -const decodeTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const LegacyTelemetryConfigSchema = Schema.Struct({ + enabled: Schema.Boolean, + device_id: Schema.String, + session_id: Schema.String, + session_last_active: Schema.String, + distinct_id: Schema.optionalKey(Schema.String), + schema_version: Schema.optionalKey(Schema.Number), +}); +type LegacyTelemetryConfig = Schema.Schema.Type; + +const decodeCurrentTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const decodeLegacyTelemetryConfigFile = Schema.decodeUnknownEffect( + Schema.fromJsonString(LegacyTelemetryConfigSchema), +); const encodeTelemetryConfig = Schema.encodeUnknownSync(TelemetryConfigSchema); function encodePrettyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } +function legacyConsent(enabled: boolean): ConsentState { + return enabled ? "granted" : "denied"; +} + +function legacyConfigToTelemetryConfig( + legacyConfig: LegacyTelemetryConfig, +): TelemetryConfig | undefined { + const sessionLastActive = Date.parse(legacyConfig.session_last_active); + if (!Number.isFinite(sessionLastActive)) return undefined; + return { + consent: legacyConsent(legacyConfig.enabled), + device_id: legacyConfig.device_id, + session_id: legacyConfig.session_id, + session_last_active: sessionLastActive, + ...(legacyConfig.distinct_id === undefined ? {} : { distinct_id: legacyConfig.distinct_id }), + }; +} + +const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) { + return yield* decodeCurrentTelemetryConfigFile(content).pipe( + Effect.catch(() => + Effect.gen(function* () { + const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); + const config = legacyConfigToTelemetryConfig(legacyConfig); + if (config === undefined) { + return yield* Effect.fail(new Error("invalid legacy telemetry state")); + } + return config; + }), + ), + ); +}); + export const readTelemetryConfig = Effect.fnUntraced( function* (configDir: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index adb4d63d30..e2257ee95e 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -118,6 +118,66 @@ describe("readTelemetryConfig", () => { ); }); + it.live("decodes a legacy disabled telemetry state as denied consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.live("decodes a legacy enabled telemetry state as granted consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "granted", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + distinct_id: "user-123", + }), + ); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + it.live("returns none for malformed JSON instead of throwing", () => { const dir = makeTempDir(); writeTelemetryFile(dir, ""); diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index 580cd9fb4b..fbb165b4fa 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -111,4 +111,31 @@ describe("telemetryRuntimeLayer", () => { Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), ); }); + + it.live("honors a legacy disabled telemetry state", () => { + const homeDir = makeTempDir(); + const configPath = path.join(homeDir, "telemetry.json"); + writeFileSync( + configPath, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + expect(existsSync(configPath)).toBe(true); + }).pipe( + Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), + Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + ); + }); }); From 0ceea0be163ea678cc0c24a954d759627b7e0eb4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 4 Jun 2026 11:36:50 +0200 Subject: [PATCH 37/38] fix(cli): restore Go debug output parity (#5467) Restores the Go CLI debug side effects for native TypeScript legacy Management API commands. The TypeScript path was resolving profiles, credentials, and generated API URLs correctly, but it no longer emitted the debug breadcrumbs that Go printed from profile loading, access-token lookup, root command setup, and HTTP transport logging. That made `supabase --profile supabase projects list --debug` hide which profile file, resolved profile host, token source, and Management API host were used. This ports those debug side effects into the TS legacy config, credentials, and platform API layers, and moves typed Management API HTTP debug logging to the point where generated requests have the active profile base URL attached. The output order now matches the Go management-command flow, including the repeated token-source line around the root debug banner. --- .../legacy/auth/legacy-credentials.layer.ts | 19 +++++- .../legacy-credentials.layer.unit.test.ts | 36 ++++++++++- .../legacy/auth/legacy-http-debug.layer.ts | 46 ++++--------- .../legacy/auth/legacy-platform-api.layer.ts | 37 ++++++++--- .../legacy-platform-api.layer.unit.test.ts | 48 +++++++++++++- .../src/legacy/commands/login/login.layers.ts | 15 +++-- .../legacy/commands/logout/logout.layers.ts | 11 +++- .../legacy/commands/unlink/unlink.command.ts | 11 +++- .../legacy/config/legacy-cli-config.layer.ts | 34 +++++++++- .../legacy-cli-config.layer.unit.test.ts | 30 ++++++++- .../src/legacy/config/legacy-profile-file.ts | 18 +----- .../shared/legacy-debug-logger.layer.ts | 31 +++++++++ .../legacy-debug-logger.layer.unit.test.ts | 64 +++++++++++++++++++ .../shared/legacy-debug-logger.service.ts | 11 ++++ .../legacy-management-api-runtime.layer.ts | 51 ++++++++------- 15 files changed, 358 insertions(+), 104 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts create mode 100644 apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-debug-logger.service.ts diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index befbe14e8a..d558403e55 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -2,6 +2,7 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { @@ -318,6 +319,7 @@ const makeLegacyCredentials = Effect.gen(function* () { const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliConfig = yield* LegacyCliConfig; + const debugLogger = yield* LegacyDebugLogger; const profileAccount = cliConfig.profile; // ~/.supabase/access-token — fallback file path @@ -347,8 +349,19 @@ const makeLegacyCredentials = Effect.gen(function* () { profileAccount, runtimeInfo.platform, ); - if (Option.isSome(profileResult)) return profileResult; - return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); + if (Option.isSome(profileResult)) { + yield* debugLogger.debug(`Using access token for profile: ${profileAccount}`); + return profileResult; + } + const legacyResult = yield* tryKeyringRead( + keyringModule.value, + LEGACY_KEYRING_ACCOUNT, + runtimeInfo.platform, + ); + if (Option.isSome(legacyResult)) { + yield* debugLogger.debug("Using access token from credentials store..."); + } + return legacyResult; }); const readFile = Effect.gen(function* () { @@ -363,6 +376,7 @@ const makeLegacyCredentials = Effect.gen(function* () { getAccessToken: Effect.gen(function* () { // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliConfig.accessToken)) { + yield* debugLogger.debug("Using access token from env var..."); yield* validate(Redacted.value(cliConfig.accessToken.value)); return Option.some(cliConfig.accessToken.value); } @@ -377,6 +391,7 @@ const makeLegacyCredentials = Effect.gen(function* () { // Filesystem fallback at ~/.supabase/access-token. const fileValue = yield* readFile; if (Option.isSome(fileValue)) { + yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); yield* validate(fileValue.value); return Option.some(Redacted.make(fileValue.value)); } diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index db0b5128b2..805b5631d9 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -7,9 +7,14 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; import { afterEach, beforeEach, vi } from "vitest"; -import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, +} from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { @@ -99,6 +104,7 @@ function makeLayer( env?: Record; home?: string; platform?: NodeJS.Platform; + debug?: boolean; } = {}, ) { const home = opts.home ?? tempHome; @@ -109,6 +115,8 @@ function makeLayer( platform: opts.platform, }); const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), Layer.provide(runtimeInfoLayer), @@ -117,6 +125,8 @@ function makeLayer( ); return legacyCredentialsLayer.pipe( Layer.provide(cliConfigLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(runtimeInfoLayer), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(env)), @@ -158,6 +168,10 @@ const expectSomeToken = (token: Option.Option>, expect } }; +function captureStderr() { + return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +} + describe("legacyCredentialsLayer.getAccessToken", () => { it.effect("returns the SUPABASE_ACCESS_TOKEN env value (highest precedence)", () => { passwords.set("Supabase CLI/supabase", "sbp_" + "9".repeat(40)); @@ -177,6 +191,22 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); + it.effect("debug logs the access token source", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + const stderr = captureStderr(); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( + "Using access token for profile: supabase\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer({ debug: true })), + ); + }); + it.effect("decodes Go keyring base64 values from the keyring profile account", () => { passwords.set("Supabase CLI/supabase", encodeGoKeyringBase64(VALID_TOKEN)); return Effect.gen(function* () { @@ -400,6 +430,8 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }), ); const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), Layer.provide(runtimeInfoLayer), @@ -408,6 +440,8 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { ); const layer = legacyCredentialsLayer.pipe( Layer.provide(cliConfigLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), Layer.provide(runtimeInfoLayer), Layer.provide(fsLayer), Layer.provide(BunServices.layer), diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index cd1ec08c50..56cc90e704 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -2,42 +2,20 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; - -const pad = (n: number): string => String(n).padStart(2, "0"); - -/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ -function formatTimestamp(now: Date): string { - return ( - `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + - `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` - ); -} +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so that, when `--debug` is set, every HTTP - * request is logged to stderr in the exact format Go uses - * (`apps/cli-go/internal/debug/http.go`): `HTTP : \n`. - * - * When `--debug` is unset, this is identity over `FetchHttpClient.layer` — no - * runtime overhead beyond a single boolean check at layer-construction time. + * Wraps `FetchHttpClient.layer` so every HTTP request can go through the + * legacy Go-parity debug side channel. The logger itself owns the `--debug` + * guard and byte-for-byte line formatting. */ -export const legacyHttpClientLayer = Layer.unwrap( +export const legacyHttpClientLayer = Layer.effect( + HttpClient.HttpClient, Effect.gen(function* () { - const debug = yield* LegacyDebugFlag; - if (!debug) { - return FetchHttpClient.layer; - } - - return Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const base = yield* HttpClient.HttpClient; - return HttpClient.mapRequest(base, (req) => { - process.stderr.write(`HTTP ${formatTimestamp(new Date())} ${req.method}: ${req.url}\n`); - return req; - }); - }), - ).pipe(Layer.provide(FetchHttpClient.layer)); + const logger = yield* LegacyDebugLogger; + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequestEffect(base, (req) => + logger.http(req.method, req.url).pipe(Effect.as(req)), + ); }), -); +).pipe(Layer.provide(FetchHttpClient.layer)); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 654c97fb5e..45a5f70755 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -3,7 +3,9 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; @@ -67,6 +69,7 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { const runtime = yield* TelemetryRuntime; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const debugLogger = yield* LegacyDebugLogger; let stitchAttempted = false; const needsIdentityStitch = @@ -111,9 +114,13 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { yield* fs.writeFileString(telemetryPath, JSON.stringify(state)); }); - const transformClient = (client: HttpClient.HttpClient) => - Effect.succeed( - HttpClient.transform(client, (requestEffect) => + const transformClient = (client: HttpClient.HttpClient) => { + const debugClient = HttpClient.mapRequestEffect(client, (request) => + debugLogger.http(request.method, request.url).pipe(Effect.as(request)), + ); + + return Effect.succeed( + HttpClient.transform(debugClient, (requestEffect) => requestEffect.pipe( Effect.tap((response) => { const gotrueId = gotrueIdFromResponse(response); @@ -123,14 +130,26 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { ), ), ); + }; - // Env takes precedence over keyring/file (already inside LegacyCredentials), but - // LegacyCliConfig.accessToken is the env value alone — read in the same order Go uses. const configuredToken = cliConfig.accessToken; - const storedToken = Option.isSome(configuredToken) - ? configuredToken - : yield* credentials.getAccessToken; - + const resolveAccessToken = Effect.gen(function* () { + if (Option.isSome(configuredToken)) { + yield* debugLogger.debug("Using access token from env var..."); + return configuredToken; + } + return yield* credentials.getAccessToken; + }); + + const authGateToken = yield* resolveAccessToken; + if (Option.isNone(authGateToken)) { + return yield* Effect.fail( + new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), + ); + } + yield* debugLogger.debug(`Supabase CLI ${CLI_VERSION}`); + yield* debugLogger.debug(`Using profile: ${cliConfig.profile} (${cliConfig.projectHost})`); + const storedToken = yield* resolveAccessToken; if (Option.isNone(storedToken)) { return yield* Effect.fail( new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index 45e3fec170..24ff6c6b6c 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -7,10 +7,13 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { vi } from "vitest"; +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; @@ -18,11 +21,17 @@ import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; const VALID_TOKEN = "sbp_" + "a".repeat(40); const SESSION_LAST_ACTIVE = 1_777_200_000_000; -function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent?: string }) { +function mockCliConfig(opts: { + accessToken?: string; + apiUrl?: string; + userAgent?: string; + profile?: string; + projectHost?: string; +}) { return Layer.succeed(LegacyCliConfig, { - profile: "supabase", + profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", - projectHost: "supabase.co", + projectHost: opts.projectHost ?? "supabase.co", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), @@ -49,6 +58,7 @@ function mockTelemetryRuntime( isFirstRun?: boolean; isTty?: boolean; isCi?: boolean; + debug?: boolean; } = {}, ) { return Layer.succeed( @@ -152,6 +162,7 @@ function withBaseDeps( isFirstRun?: boolean; isTty?: boolean; isCi?: boolean; + debug?: boolean; } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); @@ -167,6 +178,8 @@ function withBaseDeps( isCi: opts.isCi, }), ), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(nodeFileSystemLayer()), Layer.provide(nodePathLayer()), ); @@ -282,6 +295,35 @@ describe("legacyPlatformApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("debug logs the CLI profile and fully resolved request URL", () => { + const http = captureRequests(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide( + mockCliConfig({ + accessToken: VALID_TOKEN, + apiUrl: "https://api.supabase.green", + profile: "supabase-staging", + projectHost: "supabase.red", + }), + ), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ debug: true }), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(output).toContain("Supabase CLI "); + expect(output).toContain("Using profile: supabase-staging (supabase.red)\n"); + expect(output.match(/Using access token from env var\.\.\.\n/g)).toHaveLength(2); + expect(output).toMatch( + /\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} HTTP GET: https:\/\/api\.supabase\.green\/v1\/projects\n/, + ); + }).pipe(Effect.ensuring(Effect.sync(() => stderr.mockRestore())), Effect.provide(layer)); + }); + it.effect("stitches identity from X-Gotrue-Id responses outside CI", () => { const configDir = tempTelemetryConfig(); const analytics = mockAnalytics(); diff --git a/apps/cli/src/legacy/commands/login/login.layers.ts b/apps/cli/src/legacy/commands/login/login.layers.ts index 4ed14b4295..040f3cf952 100644 --- a/apps/cli/src/legacy/commands/login/login.layers.ts +++ b/apps/cli/src/legacy/commands/login/login.layers.ts @@ -3,6 +3,7 @@ import { Layer } from "effect"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { browserLayer } from "../../../shared/runtime/browser.layer.ts"; @@ -21,16 +22,18 @@ import { legacyLoginCryptoLayer } from "./login-crypto.layer.ts"; // memoised by reference to avoid building two keyring readers / config loaders. // `Analytics`, `Output`, `Stdio`, `Tty`, `TelemetryRuntime`, `FileSystem`, and // `Path` come from the root layer. -const credentials = legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)); -const loginApi = legacyLoginApiLayer.pipe( - Layer.provide(legacyHttpClientLayer), - Layer.provide(legacyCliConfigLayer), +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), ); +const loginApi = legacyLoginApiLayer.pipe(Layer.provide(httpClient), Layer.provide(cliConfig)); export const legacyLoginRuntimeLayer = Layer.mergeAll( credentials, - legacyCliConfigLayer, - legacyHttpClientLayer, + cliConfig, + httpClient, loginApi, legacyLoginCryptoLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/logout/logout.layers.ts b/apps/cli/src/legacy/commands/logout/logout.layers.ts index 9c7d671a94..a2b44c218d 100644 --- a/apps/cli/src/legacy/commands/logout/logout.layers.ts +++ b/apps/cli/src/legacy/commands/logout/logout.layers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -16,9 +17,15 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay * legacy CLAUDE.md item 5). `Analytics`, `Output`, `Stdio`, `FileSystem`, * `Path`, `TelemetryRuntime`, and `LegacyYesFlag` come from the root layer. */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + export const legacyLogoutRuntimeLayer = Layer.mergeAll( - legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)), - legacyCliConfigLayer, + credentials, + cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["logout"]), ); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.command.ts b/apps/cli/src/legacy/commands/unlink/unlink.command.ts index eaaf1737e0..23f578ac75 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.command.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.command.ts @@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -15,9 +16,15 @@ import { legacyUnlink } from "./unlink.handler.ts"; // `unlink`. It provides only the services the handler + instrumentation consume. // `legacyCliConfigLayer` is provided to credentials AND exposed at the top level // (Layer.provide does not share to siblings inside a merge — legacy CLAUDE.md item 5). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + const legacyUnlinkRuntimeLayer = Layer.mergeAll( - legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)), - legacyCliConfigLayer, + credentials, + cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["unlink"]), ); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 41e461ddcf..d77de843db 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -3,9 +3,13 @@ import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; import { legacyProjectHost } from "../shared/legacy-profile.ts"; +import { + LegacyDebugLogger, + type LegacyDebugLoggerShape, +} from "../shared/legacy-debug-logger.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; -import { readLegacyProfileFile } from "./legacy-profile-file.ts"; +import { legacyProfileFilePath } from "./legacy-profile-file.ts"; interface ResolvedProfile { readonly name: string; @@ -48,6 +52,10 @@ function safeParseYaml( } } +function unknownMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * Resolves the profile that produces the API URL. Mirrors Go's `LoadProfile` * (`apps/cli-go/internal/utils/profile.go:96-118`): @@ -71,18 +79,36 @@ function resolveProfile( fs: FileSystem.FileSystem, path: Path.Path, homeDir: string, + debugLogger: LegacyDebugLoggerShape, ): Effect.Effect { return Effect.gen(function* () { let token: string; if (flagValue !== "supabase") { + yield* debugLogger.debug(`Loading profile from flag: ${flagValue}`); token = flagValue; } else if (envValue !== undefined && envValue.length > 0) { + // Go reads SUPABASE_PROFILE through viper's PROFILE key, so debug output + // cannot distinguish env from an explicitly changed flag. + yield* debugLogger.debug(`Loading profile from flag: ${envValue}`); token = envValue; } else { // Lowest precedence: the persisted `~/.supabase/profile` file (Go's // `getProfileName` file fallback, `profile.go:129-131`). - const fileName = yield* readLegacyProfileFile(fs, path, homeDir); - token = Option.getOrElse(fileName, () => "supabase"); + const filePath = legacyProfileFilePath(path, homeDir); + const content = yield* fs.readFileString(filePath).pipe( + Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)), + Effect.map(Option.some), + Effect.catch((error) => + debugLogger.debug(unknownMessage(error)).pipe(Effect.as(Option.none())), + ), + ); + token = Option.match(content, { + onNone: () => "supabase", + onSome: (value) => { + const trimmed = value.trim(); + return trimmed.length === 0 ? "supabase" : trimmed; + }, + }); } if (isBuiltinProfileName(token)) { @@ -141,6 +167,7 @@ export const legacyCliConfigLayer = Layer.unwrap( Effect.gen(function* () { const profileFlag = yield* LegacyProfileFlag; const workdirFlag = yield* LegacyWorkdirFlag; + const debugLogger = yield* LegacyDebugLogger; return Layer.effect( LegacyCliConfig, @@ -160,6 +187,7 @@ export const legacyCliConfigLayer = Layer.unwrap( fs, path, runtimeInfo.homeDir, + debugLogger, ); const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index e2053a0abd..7e20040fce 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -5,10 +5,15 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Redacted } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; -import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, +} from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { legacyCliConfigLayer } from "./legacy-cli-config.layer.ts"; import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; @@ -18,10 +23,13 @@ function makeLayer(opts: { env?: Record; cwd?: string; home?: string; + debug?: boolean; }) { const profileFlag = opts.profileFlag ?? "supabase"; const workdirFlag = opts.workdirFlag ?? Option.none(); return legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", homeDir: opts.home })), @@ -86,6 +94,24 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot }))); }); + it.effect("debug logs the persisted profile file source", () => { + const home = join(tempRoot, "home"); + const profilePath = join(home, ".supabase", "profile"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(profilePath, "supabase-staging\n"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( + `Loading profile from file: ${profilePath}\n`, + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer({ home, cwd: tempRoot, debug: true })), + ); + }); + it.effect("flag and env take precedence over the persisted profile file", () => { const home = join(tempRoot, "home"); mkdirSync(join(home, ".supabase"), { recursive: true }); diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index 2c3edd5165..cf7ae6a609 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -1,4 +1,4 @@ -import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { Data, Effect, FileSystem, Path } from "effect"; /** * Helpers for the persisted profile-name file `~/.supabase/profile`, mirroring @@ -17,24 +17,10 @@ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveE readonly message: string; }> {} -function legacyProfileFilePath(path: Path.Path, homeDir: string): string { +export function legacyProfileFilePath(path: Path.Path, homeDir: string): string { return path.join(homeDir, ".supabase", "profile"); } -/** Reads `~/.supabase/profile`, returning `None` when missing or empty. */ -export const readLegacyProfileFile = ( - fs: FileSystem.FileSystem, - path: Path.Path, - homeDir: string, -): Effect.Effect> => - Effect.gen(function* () { - const filePath = legacyProfileFilePath(path, homeDir); - const content = yield* fs.readFileString(filePath).pipe(Effect.option); - if (Option.isNone(content)) return Option.none(); - const trimmed = content.value.trim(); - return trimmed.length === 0 ? Option.none() : Option.some(trimmed); - }); - /** Writes the profile name to `~/.supabase/profile`. Fatal on failure (Go parity). */ export const saveLegacyProfileName = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts new file mode 100644 index 0000000000..3658df0e70 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts @@ -0,0 +1,31 @@ +import { Effect, Layer } from "effect"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; +import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +function formatTimestamp(now: Date): string { + return ( + `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +export const legacyDebugLoggerLayer = Layer.effect( + LegacyDebugLogger, + Effect.gen(function* () { + const debug = yield* LegacyDebugFlag; + + const writeLine = (message: string) => + Effect.sync(() => { + if (debug) process.stderr.write(`${message}\n`); + }); + + return LegacyDebugLogger.of({ + debug: writeLine, + http: (method, url) => writeLine(`${formatTimestamp(new Date())} HTTP ${method}: ${url}`), + }); + }), +); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts new file mode 100644 index 0000000000..5886d2dd3b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach, vi } from "vitest"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; +import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; + +function makeLayer(debug: boolean) { + return legacyDebugLoggerLayer.pipe(Layer.provide(Layer.succeed(LegacyDebugFlag, debug))); +} + +function captureStderr() { + return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("legacyDebugLoggerLayer", () => { + it.effect("does not write stderr bytes when debug is disabled", () => { + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.debug("hidden"); + yield* logger.http("GET", "https://api.supabase.green/v1/projects"); + expect(stderr).not.toHaveBeenCalled(); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(false)), + ); + }); + + it.effect("debug emits the exact newline-terminated message", () => { + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.debug("Using profile: supabase-staging (supabase.red)"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( + "Using profile: supabase-staging (supabase.red)\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(true)), + ); + }); + + it.effect("http emits Go timestamp order and method/url format", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47)); + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.http("GET", "https://api.supabase.green/v1/projects"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( + "2026/06/04 08:24:47 HTTP GET: https://api.supabase.green/v1/projects\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(true)), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts new file mode 100644 index 0000000000..b1b57d5764 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +export interface LegacyDebugLoggerShape { + readonly debug: (message: string) => Effect.Effect; + readonly http: (method: string, url: string) => Effect.Effect; +} + +export class LegacyDebugLogger extends Context.Service()( + "supabase/legacy/DebugLogger", +) {} diff --git a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts index f115f26148..aab3363700 100644 --- a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts @@ -1,5 +1,6 @@ import { Layer } from "effect"; import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; import { legacyCredentialsLayer } from "../auth/legacy-credentials.layer.ts"; @@ -10,6 +11,7 @@ import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { LegacyProjectRefResolver } from "../config/legacy-project-ref.service.ts"; import { legacyProjectRefLayer } from "../config/legacy-project-ref.layer.ts"; +import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; import { LegacyLinkedProjectCache } from "../telemetry/legacy-linked-project-cache.service.ts"; import { legacyLinkedProjectCacheLayer } from "../telemetry/legacy-linked-project-cache.layer.ts"; import { LegacyTelemetryState } from "../telemetry/legacy-telemetry-state.service.ts"; @@ -17,27 +19,18 @@ import { legacyTelemetryStateLayer } from "../telemetry/legacy-telemetry-state.l import { CommandRuntime } from "../../shared/runtime/command-runtime.service.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; -// Shared platform-API stack used by every Management-API legacy subcommand. -// `legacyHttpClientLayer` wraps the default fetch transport with a debug logger when `--debug` is set. -const legacyPlatformApiStack = legacyPlatformApiLayer.pipe( - Layer.provide(legacyCredentialsLayer), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), -); - /** * Composes the runtime layer for a Management-API-style `supabase ` * invocation. * - * `legacyCliConfigLayer` must be piped to both `legacyPlatformApiStack` and + * `legacyCliConfigLayer` must be piped to both the platform API stack and * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, * so without an explicit provide here the bundled runtime panics with * `Service not found: supabase/legacy/CliConfig`. Handlers that yield `LegacyCliConfig` * directly (e.g. `branches get`, `legacySuggestUpgrade`) also need the service exposed - * at the top level of the merged layer, hence the bare `legacyCliConfigLayer` entry - * below. + * at the top level of the merged layer, hence the top-level `cliConfig` entry below. * * `legacyHttpClientLayer` and `LegacyCredentials` are exposed at the top level so * handlers / helpers that bypass the typed Management API client can read them @@ -55,23 +48,33 @@ const legacyPlatformApiStack = legacyPlatformApiLayer.pipe( * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. */ export function legacyManagementApiRuntimeLayer(subcommand: ReadonlyArray) { - // Memoise the credentials layer so the top-level surface and the linked-project - // cache pipeline share one keyring/file lookup. Same rationale applies to the - // HTTP client + CLI config layers below. - const credentials = legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)); + // Memoise the shared layers so the platform API, top-level service surface, + // project resolver, and linked-project cache all reuse the same config / + // credentials / HTTP instances. + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + // `legacyPlatformApiLayer` applies typed API debug logging after generated + // requests have been prefixed with the active profile's API URL. + const platformApiStack = legacyPlatformApiLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(FetchHttpClient.layer), + Layer.provide(legacyDebugLoggerLayer), + ); const built = Layer.mergeAll( - legacyPlatformApiStack, - legacyHttpClientLayer, + platformApiStack, + httpClient, credentials, - legacyCliConfigLayer, - legacyProjectRefLayer.pipe( - Layer.provide(legacyPlatformApiStack), - Layer.provide(legacyCliConfigLayer), - ), + cliConfig, + legacyProjectRefLayer.pipe(Layer.provide(platformApiStack), Layer.provide(cliConfig)), legacyLinkedProjectCacheLayer.pipe( Layer.provide(credentials), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), + Layer.provide(cliConfig), + Layer.provide(httpClient), ), legacyTelemetryStateLayer, commandRuntimeLayer([...subcommand]), From b749d52b8e86813dfbcef4b34d0f038b78695131 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 4 Jun 2026 13:44:07 +0200 Subject: [PATCH 38/38] ci(release): add pull-requests permission to release job (#5472) The release job uses a nested reusable workflow (release-shared.yml) that calls propose-release-notes.yml, which requires `pull-requests: write` permission to create pull requests. For nested reusable workflows, a called job's permissions cannot exceed those granted to the calling job, so this permission must be declared at the release job level even though the propose job uses an App token for its actual PR creation. **Changes:** - Added `pull-requests: write` permission to the release job in `.github/workflows/release.yml` - Added explanatory comments documenting why this permission is required for nested reusable workflows https://claude.ai/code/session_017eKVgHfYw2vb26wX4xh61q Co-authored-by: Claude --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b8bb9a099..fe7dc96ac7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,9 +189,15 @@ jobs: name: Release needs: plan if: needs.plan.outputs.should_release == 'true' + # pull-requests: write is required by the nested propose-release-notes + # workflow (release-shared.yml -> propose-release-notes.yml). For nested + # reusable workflows, a called job's permissions can't exceed those granted + # to the calling job, so this must be declared here even though the propose + # job uses an App token for its actual PR creation. permissions: contents: write id-token: write + pull-requests: write uses: ./.github/workflows/release-shared.yml with: version: ${{ needs.plan.outputs.version }}