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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-go/docs/supabase/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Connect the Supabase CLI to your Supabase account by logging in with your [personal access token](https://supabase.com/dashboard/account/tokens).

Your access token is stored securely in [native credentials storage](https://github.com/zalando/go-keyring#dependencies). If native credentials storage is unavailable, it will be written to a plain text file at `~/.supabase/access-token`.
Your access token is stored securely in [native credentials storage](https://github.com/zalando/go-keyring#dependencies). If native credentials storage is unavailable, it will be written to a plain text file at `<SUPABASE_HOME or ~/.supabase>/access-token`.

> If this behavior is not desired, such as in a CI environment, you may skip login by specifying the `SUPABASE_ACCESS_TOKEN` environment variable in other commands.

Expand Down
10 changes: 3 additions & 7 deletions apps/cli-go/internal/telemetry/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"

"github.com/go-errors/errors"
Expand Down Expand Up @@ -43,14 +42,11 @@ type rawState struct {
}

func telemetryPath() (string, error) {
if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" {
return filepath.Join(home, "telemetry.json"), nil
}
home, err := os.UserHomeDir()
home, err := utils.SupabaseHomeDir()
if err != nil {
return "", errors.Errorf("failed to get $HOME directory: %w", err)
return "", err
}
return filepath.Join(home, ".supabase", "telemetry.json"), nil
return filepath.Join(home, "telemetry.json"), nil
}

func parseConsent(raw rawState) (bool, bool, error) {
Expand Down
7 changes: 3 additions & 4 deletions apps/cli-go/internal/utils/access_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,9 @@ func fallbackDeleteToken(fsys afero.Fs) error {
}

func getAccessTokenPath() (string, error) {
home, err := os.UserHomeDir()
home, err := SupabaseHomeDir()
if err != nil {
return "", errors.Errorf("failed to get $HOME directory: %w", err)
return "", err
}
// TODO: fallback to workdir
return filepath.Join(home, ".supabase", AccessTokenKey), nil
return filepath.Join(home, AccessTokenKey), nil
}
19 changes: 19 additions & 0 deletions apps/cli-go/internal/utils/access_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package utils

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/afero"
Expand Down Expand Up @@ -130,6 +131,24 @@ func TestSaveTokenFallback(t *testing.T) {
assert.Equal(t, []byte(token), contents)
})

t.Run("fallback saves to SUPABASE_HOME when configured", func(t *testing.T) {
t.Setenv("HOME", "/home/test")
t.Setenv("SUPABASE_HOME", "/custom/supabase")
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Run test
assert.NoError(t, fallbackSaveToken(token, fsys))
// Validate saved token
configuredPath := filepath.Join("/custom/supabase", AccessTokenKey)
contents, err := afero.ReadFile(fsys, configuredPath)
assert.NoError(t, err)
assert.Equal(t, []byte(token), contents)
defaultPath := filepath.Join("/home/test", ".supabase", AccessTokenKey)
exists, err := afero.Exists(fsys, defaultPath)
assert.NoError(t, err)
assert.False(t, exists)
})

t.Run("throws error on home dir failure", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
Expand Down
4 changes: 2 additions & 2 deletions apps/cli-go/internal/utils/deno.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ func GetDenoPath() (string, error) {
if len(DenoPathOverride) > 0 {
return DenoPathOverride, nil
}
home, err := os.UserHomeDir()
home, err := SupabaseHomeDir()
if err != nil {
return "", err
}
denoBinName := "deno"
if runtime.GOOS == "windows" {
denoBinName = "deno.exe"
}
denoPath := filepath.Join(home, ".supabase", denoBinName)
denoPath := filepath.Join(home, denoBinName)
return denoPath, nil
}

Expand Down
13 changes: 13 additions & 0 deletions apps/cli-go/internal/utils/deno_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ func TestGetDenoPath(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, expected, path)
})

t.Run("returns SUPABASE_HOME path when configured", func(t *testing.T) {
t.Setenv("SUPABASE_HOME", "/custom/supabase")
expected := filepath.Join("/custom/supabase", "deno")
if runtime.GOOS == "windows" {
expected += ".exe"
}

path, err := GetDenoPath()

assert.NoError(t, err)
assert.Equal(t, expected, path)
})
}

func TestIsScriptModified(t *testing.T) {
Expand Down
7 changes: 3 additions & 4 deletions apps/cli-go/internal/utils/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package utils
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -136,11 +135,11 @@ func getProfileName(fsys afero.Fs) string {
}

func getProfilePath() (string, error) {
home, err := os.UserHomeDir()
home, err := SupabaseHomeDir()
if err != nil {
return "", errors.Errorf("failed to get $HOME directory: %w", err)
return "", err
}
return filepath.Join(home, ".supabase", "profile"), nil
return filepath.Join(home, "profile"), nil
}

func SaveProfileName(prof string, fsys afero.Fs) error {
Expand Down
20 changes: 20 additions & 0 deletions apps/cli-go/internal/utils/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"embed"
"os"
"path/filepath"
"testing"

"github.com/go-playground/validator/v10"
Expand Down Expand Up @@ -76,3 +77,22 @@ func TestLoadProfile(t *testing.T) {
assert.ErrorIs(t, err, os.ErrNotExist)
})
}

func TestSaveProfileName(t *testing.T) {
t.Run("saves to SUPABASE_HOME when configured", func(t *testing.T) {
t.Setenv("HOME", "/home/test")
t.Setenv("SUPABASE_HOME", "/custom/supabase")
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Run test
err := SaveProfileName("supabase-staging", fsys)
// Check error
assert.NoError(t, err)
contents, err := afero.ReadFile(fsys, filepath.Join("/custom/supabase", "profile"))
assert.NoError(t, err)
assert.Equal(t, "supabase-staging", string(contents))
exists, err := afero.Exists(fsys, filepath.Join("/home/test", ".supabase", "profile"))
assert.NoError(t, err)
assert.False(t, exists)
})
}
20 changes: 20 additions & 0 deletions apps/cli-go/internal/utils/supabase_home.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package utils

import (
"os"
"path/filepath"
"strings"

"github.com/go-errors/errors"
)

func SupabaseHomeDir() (string, error) {
if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" {
return home, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", errors.Errorf("failed to get $HOME directory: %w", err)
}
return filepath.Join(home, ".supabase"), nil
}
10 changes: 8 additions & 2 deletions apps/cli/docs/supabase-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ Not all runtime files live in the repo.
Auth is still machine-global today:

- keyring entry: `Supabase CLI/access-token`
- filesystem fallback: `~/.supabase/access-token`
- filesystem fallback: `<SUPABASE_HOME or ~/.supabase>/access-token`

### Telemetry and traces

Expand All @@ -268,7 +268,13 @@ Telemetry state remains in `SUPABASE_HOME`:
Downloaded binaries remain shared across projects in:

```text
~/.supabase/bin/
<SUPABASE_HOME or ~/.supabase>/bin/
```

The legacy Go installer stores its Deno binary directly under the state root:

```text
<SUPABASE_HOME or ~/.supabase>/deno
```

### Live runtime sockets
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { legacySupabaseHome } from "../config/legacy-profile-file.ts";
import { LEGACY_ACCESS_TOKEN_PATTERN, validateLegacyAccessToken } from "./legacy-access-token.ts";
import { LegacyCredentials } from "./legacy-credentials.service.ts";
import {
Expand Down Expand Up @@ -318,8 +319,8 @@ const makeLegacyCredentials = Effect.gen(function* () {
const debugLogger = yield* LegacyDebugLogger;
const profileAccount = cliConfig.profile;

// ~/.supabase/access-token — fallback file path
const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase");
// <SUPABASE_HOME or ~/.supabase>/access-token — fallback file path
const fallbackDir = legacySupabaseHome(path, runtimeInfo.homeDir, process.env);
const fallbackPath = path.join(fallbackDir, "access-token");

// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches `next/`'s
Expand Down Expand Up @@ -379,7 +380,7 @@ const makeLegacyCredentials = Effect.gen(function* () {
return Option.some(Redacted.make(keyringValue.value));
}

// Filesystem fallback at ~/.supabase/access-token.
// Filesystem fallback in the Supabase home directory.
const fileValue = yield* readFile;
if (Option.isSome(fileValue)) {
yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`);
Expand Down
38 changes: 37 additions & 1 deletion apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ describe("legacyCredentialsLayer.getAccessToken", () => {
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => {
it.effect("falls back to the Supabase home access-token file when keyring entries miss", () => {
const supaDir = join(tempHome, ".supabase");
mkdirSync(supaDir, { recursive: true });
writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 });
Expand All @@ -256,6 +256,17 @@ describe("legacyCredentialsLayer.getAccessToken", () => {
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls back to SUPABASE_HOME/access-token when configured", () => {
const supabaseHome = join(tempHome, "custom-supabase-home");
mkdirSync(supabaseHome, { recursive: true });
writeFileSync(join(supabaseHome, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 });
return Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
const token = yield* getAccessToken;
expectSomeToken(token, VALID_TOKEN);
}).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } })));
});

it.effect("returns None when no source provides a token", () =>
Effect.gen(function* () {
const { getAccessToken } = yield* LegacyCredentials;
Expand Down Expand Up @@ -342,6 +353,18 @@ describe("legacyCredentialsLayer.saveAccessToken", () => {
expect(content).toBe(VALID_TOKEN);
}).pipe(Effect.provide(makeLayer()));
});

it.effect("falls back to SUPABASE_HOME/access-token when configured", () => {
throwOnSetPassword = true;
const supabaseHome = join(tempHome, "custom-supabase-home");
return Effect.gen(function* () {
const { saveAccessToken } = yield* LegacyCredentials;
yield* saveAccessToken(VALID_TOKEN);
const content = readFileSync(join(supabaseHome, "access-token"), "utf-8");
expect(content).toBe(VALID_TOKEN);
expect(existsSync(join(tempHome, ".supabase", "access-token"))).toBe(false);
}).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } })));
});
});

// Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) collapses three
Expand Down Expand Up @@ -369,6 +392,19 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => {
}).pipe(Effect.provide(makeLayer()));
});

it.effect("logged in via keyring profile entry → deletes SUPABASE_HOME file", () => {
const supabaseHome = join(tempHome, "custom-supabase-home");
mkdirSync(supabaseHome, { recursive: true });
writeFileSync(join(supabaseHome, "access-token"), VALID_TOKEN, { mode: 0o600 });
passwords.set("Supabase CLI/supabase", VALID_TOKEN);
return Effect.gen(function* () {
const { deleteAccessToken } = yield* LegacyCredentials;
yield* deleteAccessToken;
expect(passwords.has("Supabase CLI/supabase")).toBe(false);
expect(existsSync(join(supabaseHome, "access-token"))).toBe(false);
}).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } })));
});

it.effect(
"keyring profile entry absent → LegacyNotLoggedInError even though the file was removed",
() => {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/legacy/auth/legacy-credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ interface LegacyCredentialsShape {
* 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.
* 1. Remove the Supabase home access-token file 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
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/auth/legacy-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInE

/**
* Raised by `deleteAccessToken` when removing the token fails for a real reason
* — a non-`ENOENT` failure removing `~/.supabase/access-token`, or a non
* — a non-`ENOENT` failure removing the Supabase home access-token file, 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.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/commands/login/login.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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.
// The Supabase home access-token file rather than the OS keyring.
Comment thread
ametel01 marked this conversation as resolved.
test(
"login --token persists the token and prints the logged-in message",
{ timeout: E2E_TIMEOUT_MS },
Expand Down
9 changes: 5 additions & 4 deletions apps/cli/src/legacy/commands/login/login.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLog

// 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").
// persist it to `<SUPABASE_HOME or ~/.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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe("legacy login integration", () => {
},
);

it.live("persists ~/.supabase/profile on success when --profile is set", () => {
it.live("persists the Supabase home profile on success when --profile is set", () => {
const { layer } = setupLegacyLogin({
profileFlag: "supabase-staging",
homeDir: tempRoot.current,
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/legacy/config/legacy-cli-config.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function unknownMessage(error: unknown): string {
*
* 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:
* persisted Supabase home 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:`.
Expand Down Expand Up @@ -106,9 +106,9 @@ function resolveProfile(
yield* debugLogger.debug(`Loading profile from flag: ${envValue}`);
token = envValue;
} else {
// Lowest precedence: the persisted `~/.supabase/profile` file (Go's
// Lowest precedence: the persisted Supabase home profile file (Go's
// `getProfileName` file fallback, `profile.go:129-131`).
const filePath = legacyProfileFilePath(path, homeDir);
const filePath = legacyProfileFilePath(path, homeDir, process.env);
const content = yield* fs.readFileString(filePath).pipe(
Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)),
Effect.map(Option.some),
Expand Down
Loading