diff --git a/apps/cli-go/docs/supabase/login.md b/apps/cli-go/docs/supabase/login.md index 59841e7a59..0387dbd3f6 100644 --- a/apps/cli-go/docs/supabase/login.md +++ b/apps/cli-go/docs/supabase/login.md @@ -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 `/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. diff --git a/apps/cli-go/internal/telemetry/state.go b/apps/cli-go/internal/telemetry/state.go index 825ce5fc7c..3808abb78a 100644 --- a/apps/cli-go/internal/telemetry/state.go +++ b/apps/cli-go/internal/telemetry/state.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "time" "github.com/go-errors/errors" @@ -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) { diff --git a/apps/cli-go/internal/utils/access_token.go b/apps/cli-go/internal/utils/access_token.go index 6d1c06b704..fb6ddc4af7 100644 --- a/apps/cli-go/internal/utils/access_token.go +++ b/apps/cli-go/internal/utils/access_token.go @@ -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 } diff --git a/apps/cli-go/internal/utils/access_token_test.go b/apps/cli-go/internal/utils/access_token_test.go index 77743d30d8..c829113fea 100644 --- a/apps/cli-go/internal/utils/access_token_test.go +++ b/apps/cli-go/internal/utils/access_token_test.go @@ -2,6 +2,7 @@ package utils import ( "os" + "path/filepath" "testing" "github.com/spf13/afero" @@ -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()) diff --git a/apps/cli-go/internal/utils/deno.go b/apps/cli-go/internal/utils/deno.go index 86d12da0d0..79786c0b56 100644 --- a/apps/cli-go/internal/utils/deno.go +++ b/apps/cli-go/internal/utils/deno.go @@ -40,7 +40,7 @@ func GetDenoPath() (string, error) { if len(DenoPathOverride) > 0 { return DenoPathOverride, nil } - home, err := os.UserHomeDir() + home, err := SupabaseHomeDir() if err != nil { return "", err } @@ -48,7 +48,7 @@ func GetDenoPath() (string, error) { if runtime.GOOS == "windows" { denoBinName = "deno.exe" } - denoPath := filepath.Join(home, ".supabase", denoBinName) + denoPath := filepath.Join(home, denoBinName) return denoPath, nil } diff --git a/apps/cli-go/internal/utils/deno_test.go b/apps/cli-go/internal/utils/deno_test.go index c5b140a8f7..1c104870d7 100644 --- a/apps/cli-go/internal/utils/deno_test.go +++ b/apps/cli-go/internal/utils/deno_test.go @@ -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) { diff --git a/apps/cli-go/internal/utils/profile.go b/apps/cli-go/internal/utils/profile.go index e9495a481b..4b24870271 100644 --- a/apps/cli-go/internal/utils/profile.go +++ b/apps/cli-go/internal/utils/profile.go @@ -3,7 +3,6 @@ package utils import ( "context" "fmt" - "os" "path/filepath" "sort" "strings" @@ -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 { diff --git a/apps/cli-go/internal/utils/profile_test.go b/apps/cli-go/internal/utils/profile_test.go index c3ebcf39d8..4c5925e2d4 100644 --- a/apps/cli-go/internal/utils/profile_test.go +++ b/apps/cli-go/internal/utils/profile_test.go @@ -4,6 +4,7 @@ import ( "context" "embed" "os" + "path/filepath" "testing" "github.com/go-playground/validator/v10" @@ -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) + }) +} diff --git a/apps/cli-go/internal/utils/supabase_home.go b/apps/cli-go/internal/utils/supabase_home.go new file mode 100644 index 0000000000..7f3963fdcb --- /dev/null +++ b/apps/cli-go/internal/utils/supabase_home.go @@ -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 +} diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index e96210caa2..0f83e8c26f 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -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: `/access-token` ### Telemetry and traces @@ -268,7 +268,13 @@ Telemetry state remains in `SUPABASE_HOME`: Downloaded binaries remain shared across projects in: ```text -~/.supabase/bin/ +/bin/ +``` + +The legacy Go installer stores its Deno binary directly under the state root: + +```text +/deno ``` ### Live runtime sockets diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 66bf62d07e..117b3b708e 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -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 { @@ -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"); + // /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 @@ -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}`); 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 805b5631d9..8ab728f7ff 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 @@ -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 }); @@ -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; @@ -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 @@ -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", () => { diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts index def4668443..8d7c723205 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.service.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -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 diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index effab2b544..d2e6d1a80e 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -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. diff --git a/apps/cli/src/legacy/commands/login/login.e2e.test.ts b/apps/cli/src/legacy/commands/login/login.e2e.test.ts index 3158d3a3ea..3f11eb944b 100644 --- a/apps/cli/src/legacy/commands/login/login.e2e.test.ts +++ b/apps/cli/src/legacy/commands/login/login.e2e.test.ts @@ -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. test( "login --token persists the token and prints the logged-in message", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/legacy/commands/login/login.handler.ts b/apps/cli/src/legacy/commands/login/login.handler.ts index 52e0a08aae..e08cf6b942 100644 --- a/apps/cli/src/legacy/commands/login/login.handler.ts +++ b/apps/cli/src/legacy/commands/login/login.handler.ts @@ -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 `/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" diff --git a/apps/cli/src/legacy/commands/login/login.integration.test.ts b/apps/cli/src/legacy/commands/login/login.integration.test.ts index 1902ce5721..63fa7214bf 100644 --- a/apps/cli/src/legacy/commands/login/login.integration.test.ts +++ b/apps/cli/src/legacy/commands/login/login.integration.test.ts @@ -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, 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 70ec567d30..d98465a63f 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -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:`. @@ -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), 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 b4220a3891..53098aaf3b 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 @@ -86,14 +86,30 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), ); - it.effect("reads the persisted ~/.supabase/profile file when no flag/env is set", () => { + it.effect( + "reads the persisted default Supabase home 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("reads the persisted profile file from SUPABASE_HOME when configured", () => { const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "supabase-staging\n"); + 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 }))); + }).pipe( + Effect.provide(makeLayer({ home, cwd: tempRoot, env: { SUPABASE_HOME: supabaseHome } })), + ); }); it.effect("debug logs the persisted profile file source", () => { diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index cf7ae6a609..d4a9db7257 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -1,8 +1,8 @@ import { Data, Effect, FileSystem, Path } from "effect"; /** - * Helpers for the persisted profile-name file `~/.supabase/profile`, mirroring - * Go's `getProfileName` file fallback and `SaveProfileName` + * Helpers for the persisted profile-name file under the global Supabase home, + * 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 @@ -17,11 +17,26 @@ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveE readonly message: string; }> {} -export function legacyProfileFilePath(path: Path.Path, homeDir: string): string { - return path.join(homeDir, ".supabase", "profile"); +export function legacySupabaseHome( + path: Path.Path, + homeDir: string, + env: Readonly> = process.env, +): string { + const configured = env["SUPABASE_HOME"]?.trim(); + return configured !== undefined && configured.length > 0 + ? configured + : path.join(homeDir, ".supabase"); +} + +export function legacyProfileFilePath( + path: Path.Path, + homeDir: string, + env?: Readonly>, +): string { + return path.join(legacySupabaseHome(path, homeDir, env), "profile"); } -/** Writes the profile name to `~/.supabase/profile`. Fatal on failure (Go parity). */ +/** Writes the profile name to `/profile`. Fatal on failure (Go parity). */ export const saveLegacyProfileName = ( fs: FileSystem.FileSystem, path: Path.Path, diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 3f19ed9573..65dbfd30fe 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, sep } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -1061,6 +1061,38 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(Effect.all([cleanupTempDir(tempDir), cleanupTempDir(outsideDir)]))); }); + it.live("skips outside imports before resolving their real paths", () => { + const tempDir = makeTempDir(); + const projectDir = join(tempDir, "project"); + const functionDir = join(projectDir, "supabase", "functions", "hello-world"); + const outsidePath = join(tempDir, "private", "secret.ts"); + const outsideImport = relative(functionDir, outsidePath).replaceAll(sep, "/"); + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(projectDir)); + yield* Effect.promise(() => + writeLocalFunction( + projectDir, + "hello-world", + `import { secret } from "${outsideImport}"\nDeno.serve(() => new Response(secret))\n`, + ), + ); + + const { out, api, layer } = setup(projectDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(api.multiparts[0]?.fileNames).not.toContain(outsidePath); + expect(out.stderrText).toContain("WARN: Skipping import path outside project root:"); + expect(out.stderrText).not.toContain("failed to read file"); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + it.live("falls back to source upload and warns when explicit Docker is not running", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 1 }); @@ -1207,14 +1239,9 @@ describe("functions deploy", () => { expect(api.requests[1]?.urlParams).toContain("slug=hello-world"); expect(api.requests[1]?.urlParams).toContain("verify_jwt=false"); expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); + const importMapPath = realpathSync(join(tempDir, "supabase", "custom_import_map.json")); expect(child.spawned.at(-1)?.args).toContain( - `${join(tempDir, "supabase", "custom_import_map.json")}:${join( - tempDir, - "supabase", - "custom_import_map.json", - ) - .replaceAll("\\", "/") - .replace(/^[A-Za-z]:/, "")}:ro`, + `${importMapPath}:${importMapPath.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:ro`, ); expect(out.stderrText).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); @@ -1511,8 +1538,9 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer)); expect(child.spawned).toHaveLength(4); + const realStaticFile = realpathSync(staticFile); expect(child.spawned.at(-1)?.args).toContain( - `${staticFile}:${staticFile.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:ro`, + `${realStaticFile}:${realStaticFile.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:ro`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); diff --git a/apps/cli/src/next/commands/logout/logout.guide.md b/apps/cli/src/next/commands/logout/logout.guide.md index 531323309d..ca609e370c 100644 --- a/apps/cli/src/next/commands/logout/logout.guide.md +++ b/apps/cli/src/next/commands/logout/logout.guide.md @@ -4,7 +4,7 @@ Log out of Supabase and remove the stored access token from your system. ## When to use -Run to revoke local CLI access — for example when switching accounts, on a shared machine, or after finishing work. The stored token is deleted from your system keyring (or the fallback file `~/.supabase/access-token`). After logging out, commands that require auth will prompt you to log in again. +Run to revoke local CLI access — for example when switching accounts, on a shared machine, or after finishing work. The stored token is deleted from your system keyring (or the fallback file `/access-token`). After logging out, commands that require auth will prompt you to log in again. diff --git a/apps/cli/src/next/config/cli-config.layer.ts b/apps/cli/src/next/config/cli-config.layer.ts index 07b9264306..13bddb1a1f 100644 --- a/apps/cli/src/next/config/cli-config.layer.ts +++ b/apps/cli/src/next/config/cli-config.layer.ts @@ -16,6 +16,14 @@ function readEnv( return value === undefined ? Option.none() : Option.some(value); } +function readTrimmedNonEmptyEnv( + env: Readonly>, + key: string, +): Option.Option { + const value = env[key]?.trim(); + return value === undefined || value.length === 0 ? Option.none() : Option.some(value); +} + const makeCliConfig = Effect.gen(function* () { const runtimeInfo = yield* RuntimeInfo; const projectContext = yield* ProjectContext; @@ -42,7 +50,7 @@ const makeCliConfig = Effect.gen(function* () { ), noKeyring: readEnv(effectiveEnv, "SUPABASE_NO_KEYRING"), supabaseHome: Option.getOrElse( - readEnv(effectiveEnv, "SUPABASE_HOME"), + readTrimmedNonEmptyEnv(effectiveEnv, "SUPABASE_HOME"), () => `${runtimeInfo.homeDir}/.supabase`, ), debug: readEnv(effectiveEnv, "SUPABASE_DEBUG"), diff --git a/apps/cli/src/next/config/cli-config.layer.unit.test.ts b/apps/cli/src/next/config/cli-config.layer.unit.test.ts index 7d34137e64..0fc05a16d2 100644 --- a/apps/cli/src/next/config/cli-config.layer.unit.test.ts +++ b/apps/cli/src/next/config/cli-config.layer.unit.test.ts @@ -170,6 +170,52 @@ describe("cliConfigLayer", () => { ); }); + it.live("uses SUPABASE_HOME when configured", () => { + const tempDir = makeTempDir(); + const supabaseHome = join(tempDir, "custom-supabase-home"); + return Effect.gen(function* () { + const cliConfig = yield* CliConfig; + + expect(cliConfig.supabaseHome).toBe(supabaseHome); + }).pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + env: { + SUPABASE_HOME: ` ${supabaseHome} `, + }, + }), + ), + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + for (const value of ["", " "]) { + it.live( + `falls back to the default Supabase home when SUPABASE_HOME is ${JSON.stringify(value)}`, + () => { + const tempDir = makeTempDir(); + const homeDir = join(tempDir, "home"); + return Effect.gen(function* () { + const cliConfig = yield* CliConfig; + + expect(cliConfig.supabaseHome).toBe(join(homeDir, ".supabase")); + }).pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + homeDir, + env: { + SUPABASE_HOME: value, + }, + }), + ), + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + } + it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1e7e0af0a6..65873ccd38 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -656,7 +656,23 @@ async function walkImportPaths( } const resolvedModule = resolve(modulePath); - if (!isContainedInAnyPath(allowedRoots, resolvedModule)) { + const lexicalAllowedRoots = [dirname(current), displayRoot, ...allowedRoots]; + if (!isContainedInAnyPath(lexicalAllowedRoots, resolvedModule)) { + await onWarning(`WARN: Skipping import path outside project root: ${modulePath}\n`); + continue; + } + + let realResolvedModule: string; + try { + realResolvedModule = await realpath(resolvedModule); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + queue.push(toSlash(resolvedModule)); + continue; + } + throw error; + } + if (!isContainedInAnyPath(allowedRoots, realResolvedModule)) { await onWarning(`WARN: Skipping import path outside project root: ${modulePath}\n`); continue; } diff --git a/docs/telemetry.md b/docs/telemetry.md index 54405cab75..1d32b6dcea 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -27,7 +27,7 @@ ADR 0001 Pillar 5 and ADR 0002 share infrastructure. No separate metrics SDK and ┌─────────────┼─────────────┐ ▼ ▼ ▼ Local file --debug Remote -~/.supabase/ output export +state root/ output export traces/ (always) (opt-in) (always) │ │ │ │ ┌─────┴─────┐ @@ -39,6 +39,8 @@ Observability Observability Sentry Grafana Sentry receives every command span via its native OpenTelemetry integration and powers error diagnostics, performance monitoring, and product analytics dashboards for all 5 metric categories from ADR 0002. In Phase 2, spans will also be exported to a company-owned Grafana instance via OTLP for long-term retention and custom analytics. The CLI code does not change between phases — only the exporter configuration. +In the diagram, "state root" means ``. + ## Collection Architecture `withTelemetry()` middleware wrapping Stricli command handlers. The middleware: @@ -112,7 +114,7 @@ command({ **Anonymous phase** — before login: -`device_id`: random UUID generated on first run, persisted in `~/.supabase/telemetry.json`. Never changes unless the file is deleted. This is the only identity before the user runs `supabase login`. It is attached to every span as the `cli.device_id` resource attribute. +`device_id`: random UUID generated on first run, persisted in `/telemetry.json`. Never changes unless the file is deleted. This is the only identity before the user runs `supabase login`. It is attached to every span as the `cli.device_id` resource attribute. `session_id`: random UUID that rotates after 30 minutes of inactivity (no CLI commands). This defines "session" for the Engagement metrics. @@ -156,7 +158,7 @@ Privacy guarantees: ## Local Storage -NDJSON files in `~/.supabase/traces/`: +NDJSON files in `/traces/`: - One file per day: `2025-01-15.ndjson` - 7-day automatic retention (older files deleted on CLI startup) @@ -246,7 +248,7 @@ span.setStatus({ code: SpanStatusCode.OK }); span.end(); // 5. Always: append to local trace file -// ~/.supabase/traces/2025-01-15.ndjson += JSON.stringify(spanData) + "\n" +// /traces/2025-01-15.ndjson += JSON.stringify(spanData) + "\n" // 6. If consent === "granted": Sentry SDK exports the span // Non-blocking — SDK batches internally @@ -334,7 +336,7 @@ rootSpan.end(); ## Consent Implementation -Three-state model stored in `~/.supabase/telemetry.json`: +Three-state model stored in `/telemetry.json`: ```typescript type ConsentState = "pending" | "granted" | "denied"; diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 75d39fcf93..308ea8c6ea 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -256,7 +256,7 @@ class BinaryResolver extends ServiceMap.Service< interface BinarySpec { readonly service: ServiceName; // "postgres" | "postgrest" | "auth" readonly version: string; - readonly cacheDir?: string; // defaults to ~/.supabase/bin + readonly cacheDir?: string; // defaults to /bin } ``` @@ -291,7 +291,7 @@ flowchart TD The cache directory mirrors the logical identity of each binary: `////`. Two versions of the same service coexist without conflict. The check is a simple `fs.exists` — if the directory is present, it was extracted successfully on a previous run. ``` -~/.supabase/bin/ +/bin/ postgres/ 17.6.1.081-cli/ darwin-arm64/ <- extracted binary tree @@ -1020,7 +1020,7 @@ graph TB subgraph "3. Asset preparation" DP["detectPlatform()"] - CH["check ~/.supabase/bin cache"] + CH["check /bin cache"] DL["HttpClient.get GitHub release tarball"] PI["docker pull image when service resolves to Docker"] VR["verify SHA-256 (node:crypto createHash)"] diff --git a/packages/stack/src/paths.ts b/packages/stack/src/paths.ts index dc92d1f30a..1235d27dcc 100644 --- a/packages/stack/src/paths.ts +++ b/packages/stack/src/paths.ts @@ -4,7 +4,12 @@ import { basename, join, resolve } from "node:path"; const shortTempRoot = () => (process.platform === "win32" ? tmpdir() : "/tmp"); -export const defaultCacheRoot = (): string => join(homedir(), ".supabase"); +export const defaultCacheRoot = (): string => { + const configured = process.env["SUPABASE_HOME"]?.trim(); + return configured !== undefined && configured.length > 0 + ? configured + : join(homedir(), ".supabase"); +}; export const DEFAULT_MANAGED_STACK_NAME = "default"; diff --git a/packages/stack/src/paths.unit.test.ts b/packages/stack/src/paths.unit.test.ts new file mode 100644 index 0000000000..b560599a34 --- /dev/null +++ b/packages/stack/src/paths.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { defaultCacheRoot } from "./paths.ts"; + +describe("defaultCacheRoot", () => { + it("uses SUPABASE_HOME when configured", () => { + const previous = process.env["SUPABASE_HOME"]; + process.env["SUPABASE_HOME"] = "/custom/supabase-home"; + try { + expect(defaultCacheRoot()).toBe("/custom/supabase-home"); + } finally { + if (previous === undefined) { + delete process.env["SUPABASE_HOME"]; + } else { + process.env["SUPABASE_HOME"] = previous; + } + } + }); +});