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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
})
}
24 changes: 24 additions & 0 deletions apps/cli-go/internal/utils/supabase_home.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package utils

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

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

// SupabaseHomeDir returns the global Supabase CLI state root. It is overridden
// by the SUPABASE_HOME environment variable when set to a non-empty value (an
// absolute path is expected; the value is used verbatim), otherwise it defaults
// to ~/.supabase.
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(runtimeInfo.homeDir);
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
36 changes: 36 additions & 0 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("filesystem fallback honors SUPABASE_HOME 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 the 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
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/auth/legacy-credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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
* 1. Remove `<SUPABASE_HOME or ~/.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.
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 `<SUPABASE_HOME or ~/.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.
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/legacy/commands/login/login.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ 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.
// harness sets SUPABASE_NO_KEYRING=1 and points SUPABASE_HOME at the isolated
// home dir, so the token lands in <SUPABASE_HOME>/access-token rather than the
// OS keyring.
test(
"login --token persists the token and prints the logged-in message",
{ timeout: E2E_TIMEOUT_MS },
Expand All @@ -24,7 +25,7 @@ describe("supabase login (legacy)", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("You are now logged in. Happy coding!");
expect(existsSync(join(home.dir, ".supabase", "access-token"))).toBe(true);
expect(existsSync(join(home.dir, "access-token"))).toBe(true);
},
);

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/commands/login/login.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ 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
// 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").
Expand Down
8 changes: 4 additions & 4 deletions apps/cli/src/legacy/commands/logout/logout.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import { describe, expect, test } from "vitest";
Expand All @@ -8,10 +8,10 @@ import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts";
const E2E_TIMEOUT_MS = 30_000;
const VALID_TOKEN = "sbp_" + "a".repeat(40);

// The e2e harness points SUPABASE_HOME at the isolated home dir, so the fallback
// token file lives at <SUPABASE_HOME>/access-token.
function seedTokenFile(home: string): string {
const supaDir = join(home, ".supabase");
mkdirSync(supaDir, { recursive: true });
const tokenPath = join(supaDir, "access-token");
const tokenPath = join(home, "access-token");
writeFileSync(tokenPath, VALID_TOKEN, { mode: 0o600 });
return tokenPath;
}
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ describe("legacyCliConfigLayer", () => {
}).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot })));
});

it.effect("reads the persisted profile file from SUPABASE_HOME when configured", () => {
const home = join(tempRoot, "home");
const supabaseHome = join(tempRoot, "custom-supabase-home");
mkdirSync(supabaseHome, { recursive: true });
writeFileSync(join(supabaseHome, "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, env: { SUPABASE_HOME: supabaseHome } })),
);
});

it.effect("debug logs the persisted profile file source", () => {
const home = join(tempRoot, "home");
const profilePath = join(home, ".supabase", "profile");
Expand Down
Loading
Loading