diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 41dd8d7927..21587f4601 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -27,6 +27,7 @@ "!apps/cli/src/command-internal/connect-errors.*", "!apps/cli/src/command-internal/db-*.ts", "!apps/cli/src/command-internal/debug-logger.*", + "!apps/cli/src/command-internal/dotenv.*", "!apps/cli/src/command-internal/ensure-login.*", "!apps/cli/src/command-internal/experimental-feature.ts", "!apps/cli/src/command-internal/get-api-keys.*", @@ -37,6 +38,7 @@ "!apps/cli/src/command-internal/kong-*", "!apps/cli/src/command-internal/link-services-core.*", "!apps/cli/src/command-internal/linked-state.*", + "!apps/cli/src/command-internal/local-project-context.*", "!apps/cli/src/command-internal/login-*", "!apps/cli/src/command-internal/management-api-runtime.*", "!apps/cli/src/command-internal/parent-project-ref.*", @@ -47,6 +49,7 @@ "!apps/cli/src/command-internal/profile-load.*", "!apps/cli/src/command-internal/profile.*", "!apps/cli/src/command-internal/project-create-core.*", + "!apps/cli/src/command-internal/project-environment.*", "!apps/cli/src/command-internal/project-target.*", "!apps/cli/src/command-internal/raw-http.*", "!apps/cli/src/command-internal/ref-patterns.*", @@ -59,6 +62,8 @@ "!apps/cli/src/command-internal/stack-local-database.ts", "!apps/cli/src/command-internal/stack-shadow.ts", "!apps/cli/src/command-internal/stack-storage.ts", - "!apps/cli/src/command-internal/tenant-*" + "!apps/cli/src/command-internal/tenant-*", + "!apps/cli/src/command-internal/vault-decrypt.*", + "!apps/cli/src/command-internal/vault.*" ] } diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index 98487222a8..a9d2781cb5 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -5,7 +5,7 @@ import { type CliConfig, } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; -import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; +import { Config, Crypto, Effect, FileSystem, Option, Path, Schema } from "effect"; import { recordOrioleDbTelemetry } from "./db-image.ts"; import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; @@ -38,6 +38,14 @@ export const loadLocalProjectContext = ( FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto > => Effect.gen(function* () { + const supabaseEnv = + Option.getOrUndefined( + yield* Config.option(Config.String("SUPABASE_ENV")).pipe( + Effect.mapError(() => + mapConfigLoadError("failed to resolve environment variable: SUPABASE_ENV"), + ), + ), + ) || "development"; // `workdir` is already the fully-resolved chdir target, so `search: false` stops // `@supabase/config` from climbing ancestors and picking up an unrelated project's // config.toml when `workdir` has none of its own. @@ -47,7 +55,7 @@ export const loadLocalProjectContext = ( search: false, // Omits `.env.local` when `SUPABASE_ENV=test`, matching // `resolveProjectEnvironmentValues`'s gating for the project-root pass. - skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + skipEnvLocal: supabaseEnv === "test", }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); @@ -55,10 +63,13 @@ export const loadLocalProjectContext = ( // Must resolve before `loadCliConfig` decodes config.toml: an `env(...)`-valued `project_id` // needs these values available to the decoder already. `workdir` is passed through so dotenv // files under `/supabase` are still discovered even when `projectEnv` is `null`. - const projectEnvValues = yield* Effect.try({ - try: () => resolveProjectEnvironmentValues(projectEnv, workdir), - catch: (cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`), - }); + const projectEnvValues = yield* resolveProjectEnvironmentValues( + projectEnv, + workdir, + supabaseEnv, + ).pipe( + Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), + ); // An absent config.toml is not a failure — a project id still resolves from the workdir // basename default. Only a malformed file is a hard error. @@ -90,7 +101,16 @@ export const loadLocalProjectContext = ( resolveLocalProjectId( loaded?.appliedRemote !== undefined ? undefined - : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"]), + : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? + Option.getOrUndefined( + yield* Config.option(Config.String("SUPABASE_PROJECT_ID")).pipe( + Effect.mapError(() => + mapConfigLoadError( + "failed to resolve environment variable: SUPABASE_PROJECT_ID", + ), + ), + ), + )), config.project_id, workdir, projectRef, diff --git a/apps/cli/src/command-internal/local-project-context.unit.test.ts b/apps/cli/src/command-internal/local-project-context.unit.test.ts index 58ac1a6667..fbf3c9d9aa 100644 --- a/apps/cli/src/command-internal/local-project-context.unit.test.ts +++ b/apps/cli/src/command-internal/local-project-context.unit.test.ts @@ -1,10 +1,18 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { + Config, + ConfigProvider, + Data, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, +} from "effect"; -import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { useTempWorkdir, withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { loadLocalProjectContext } from "./local-project-context.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; @@ -14,65 +22,79 @@ const DOCKER_HOST_KEY = "DOCKER_HOST"; /** Unlike Docker-client keys, this one is read at container-spawn time, so a project dotenv file can still set it. */ const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; -function writeDotEnv(workdir: string, contents: string): void { - mkdirSync(workdir, { recursive: true }); - writeFileSync(join(workdir, ".env"), contents); -} +class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} -function writeConfigToml(workdir: string, contents: string): void { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), contents); -} +const writeDotEnv = Effect.fnUntraced(function* (workdir: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(workdir, { recursive: true }); + yield* fs.writeFileString(path.join(workdir, ".env"), contents); +}); + +const writeConfigToml = Effect.fnUntraced(function* (workdir: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), contents); +}); + +const processEnvValue = (name: string) => + Effect.suspend(() => + Config.option(Config.String(name)).pipe( + Effect.map(Option.getOrUndefined), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), + ), + ), + ); const tempRoot = useTempWorkdir("supabase-project-context-"); describe("loadLocalProjectContext", () => { - const previousDockerHost = process.env[DOCKER_HOST_KEY]; - const previousBitbucketCloneDir = process.env[BITBUCKET_CLONE_DIR_KEY]; - const previousProjectId = process.env["SUPABASE_PROJECT_ID"]; - - afterEach(() => { - if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; - else process.env[DOCKER_HOST_KEY] = previousDockerHost; - if (previousBitbucketCloneDir === undefined) delete process.env[BITBUCKET_CLONE_DIR_KEY]; - else process.env[BITBUCKET_CLONE_DIR_KEY] = previousBitbucketCloneDir; - if (previousProjectId === undefined) delete process.env["SUPABASE_PROJECT_ID"]; - else process.env["SUPABASE_PROJECT_ID"] = previousProjectId; - }); - it.effect( "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", () => { - process.env["SUPABASE_PROJECT_ID"] = "local"; const ref = "abcdefghijklmnopqrst"; const workdir = tempRoot.current; - writeConfigToml( - workdir, - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ); + return Effect.gen(function* () { + yield* writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); - return loadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( - Effect.map((context) => { - expect(context.loaded?.appliedRemote).toBe("prod"); - expect(context.projectId).toBe(ref); - }), + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ref, + ); + expect(context.loaded?.appliedRemote).toBe("prod"); + expect(context.projectId).toBe(ref); + }).pipe( + (body) => withEnvVar("SUPABASE_PROJECT_ID", "local", body), + (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "local" }, body), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }, ); it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { - process.env["SUPABASE_PROJECT_ID"] = "env-project"; const ref = "abcdefghijklmnopqrst"; const workdir = tempRoot.current; - writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + return Effect.gen(function* () { + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - return loadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( - Effect.map((context) => { - expect(context.loaded?.appliedRemote).toBeUndefined(); - expect(context.projectId).toBe("env-project"); - }), + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ref, + ); + expect(context.loaded?.appliedRemote).toBeUndefined(); + expect(context.projectId).toBe("env-project"); + }).pipe( + (body) => withEnvVar("SUPABASE_PROJECT_ID", "env-project", body), + (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "env-project" }, body), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }); @@ -80,14 +102,14 @@ describe("loadLocalProjectContext", () => { it.effect( "does NOT install a project .env's DOCKER_HOST into process.env, matching Go's Docker client being frozen at binary startup, before godotenv.Load ever runs", () => { - delete process.env[DOCKER_HOST_KEY]; const workdir = tempRoot.current; - writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[DOCKER_HOST_KEY]).toBeUndefined(); - }), + yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); + expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBeUndefined(); + }).pipe( + (body) => withEnvVar(DOCKER_HOST_KEY, undefined, body), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }, @@ -96,45 +118,209 @@ describe("loadLocalProjectContext", () => { it.effect( "leaves an already-set shell DOCKER_HOST untouched regardless of a conflicting project .env value", () => { - process.env[DOCKER_HOST_KEY] = "tcp://real-shell-host:2375"; const workdir = tempRoot.current; - writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[DOCKER_HOST_KEY]).toBe("tcp://real-shell-host:2375"); - }), + yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); + expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBe("tcp://real-shell-host:2375"); + }).pipe( + (body) => withEnvVar(DOCKER_HOST_KEY, "tcp://real-shell-host:2375", body), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }, ); it.effect("keeps a project's Bitbucket marker in its resolved environment", () => { - delete process.env[BITBUCKET_CLONE_DIR_KEY]; const workdir = tempRoot.current; - writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map((context) => { - expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe( - "/opt/atlassian/pipelines/agent/build", - ); - expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBeUndefined(); - }), + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe( + "/opt/atlassian/pipelines/agent/build", + ); + expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBeUndefined(); + }).pipe( + (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, undefined, body), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }); it.effect("keeps the shell Bitbucket marker ahead of the project value", () => { - process.env[BITBUCKET_CLONE_DIR_KEY] = "/real-shell-clone-dir"; const workdir = tempRoot.current; - writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); + expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBe("/real-shell-clone-dir"); + }).pipe( + (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, "/real-shell-clone-dir", body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect("falls back to config.toml's project_id when SUPABASE_PROJECT_ID is empty", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ); + expect(context.projectId).toBe("toml-project"); + }).pipe( + (body) => withEnvVar("SUPABASE_PROJECT_ID", "", body), + (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect( + "falls back to config.toml's project_id when only Config sees an empty SUPABASE_PROJECT_ID", + () => { + // Windows env lookups are case-insensitive, so a lowercase `supabase_project_id=` reaches the + // Config read with "" while the ambient values only carry the lowercase key. + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ); + expect(context.projectId).toBe("toml-project"); + }).pipe( + (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), + (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }, + ); + + it.effect("loads the development dotenv files when SUPABASE_ENV is empty", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + yield* fs.writeFileString( + path.join(workdir, "supabase", ".env.development"), + "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", + ); + + const context = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ); + expect(context.projectEnvValues["SUPABASE_AUTH_JWT_SECRET"]).toBe("dev-secret"); + }).pipe( + (body) => withEnvVar("SUPABASE_AUTH_JWT_SECRET", undefined, body), + (body) => withConfigEnv({ SUPABASE_ENV: "" }, body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect("skips an unparseable supabase/.env.local when SUPABASE_ENV is test", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); + + const exit = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe( + (body) => withConfigEnv({ SUPABASE_ENV: "test" }, body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect("fails on an unparseable supabase/.env.local outside the test environment", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); - return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map((context) => { - expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); - expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); - }), + const error = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ).pipe(Effect.flip); + expect(error.message).toMatch(/^failed to read config: /); + }).pipe( + (body) => withConfigEnv({ SUPABASE_ENV: "development" }, body), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect("keeps the Error: prefix when a project-root .env is malformed", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeDotEnv(workdir, "not a valid line\n"); + + const error = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ).pipe(Effect.flip); + expect(error.message).toMatch( + /^failed to read config: Error: failed to parse environment file: /, + ); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))); + }); + + it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const error = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestError); + expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ), + ), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); + + it.effect("fails with a typed error when SUPABASE_PROJECT_ID cannot be resolved", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const error = yield* loadLocalProjectContext( + workdir, + (message) => new TestError({ message }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestError); + expect(error.message).toBe("failed to resolve environment variable: SUPABASE_PROJECT_ID"); + }).pipe( + (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make((path) => + path[0] === "SUPABASE_PROJECT_ID" + ? Effect.fail(new ConfigProvider.SourceError({ message: "injected" })) + : ConfigProvider.fromEnvRecord({}).load(path), + ), + ), Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); }); diff --git a/apps/cli/src/command-internal/project-environment.ts b/apps/cli/src/command-internal/project-environment.ts index 389a3296d0..ebccf2a310 100644 --- a/apps/cli/src/command-internal/project-environment.ts +++ b/apps/cli/src/command-internal/project-environment.ts @@ -1,8 +1,12 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - import type { CliProjectEnvironment } from "@supabase/config"; +import { Config, Data, Effect, FileSystem, Option, Path } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; import { parseDotEnv } from "./dotenv.ts"; /** @@ -18,26 +22,42 @@ export function candidateDotenvFilenames(env: string): ReadonlyArray { return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; } +export class ProjectEnvironmentError extends Data.Error<{ readonly message: string }> { + static readonly [ErrorActionabilityFingerprintId] = "ProjectEnvironmentError"; + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** * Reads and parses a dotenv file, or `undefined` if it doesn't exist. Delegates to * {@link parseDotEnv} rather than a hand-rolled line scanner, so a quoted value spanning * physical lines (a PEM/private key) parses correctly. * - * @throws on a malformed line (not blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` + * Fails on a malformed line (not blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` * assignment) — the caller must fail rather than silently skip it. */ -function readDotEnvFile(path: string): Record | undefined { - if (!existsSync(path)) return undefined; +const readDotEnvFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)))) return undefined; - const contents = readFileSync(path, "utf8"); - try { - return parseDotEnv(contents); - } catch (cause) { - throw new Error( - `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, - ); - } -} + const bytes = yield* fs.readFile(path).pipe( + Effect.mapError( + ({ reason }) => + new ProjectEnvironmentError({ + message: reason.cause instanceof Error ? reason.cause.message : reason.message, + }), + ), + ); + const contents = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + return yield* Effect.try({ + try: () => parseDotEnv(contents), + catch: (cause) => + new ProjectEnvironmentError({ + message: `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + }), + }); +}); /** * Merged env-var map for `stop`/`status` to read `SUPABASE_*` overrides from, covering the @@ -52,16 +72,30 @@ function readDotEnvFile(path: string): Record | undefined { * that — fall back to deriving `/supabase` and `workdir` directly, with `process.env` * as the ambient layer. */ -export function resolveProjectEnvironmentValues( +export const resolveProjectEnvironmentValues = Effect.fn("ProjectEnvironment.resolve")(function* ( projectEnv: CliProjectEnvironment | null, workdir: string, supabaseEnv?: string, -): Record { - const env = supabaseEnv || process.env["SUPABASE_ENV"] || "development"; - const filenames = candidateDotenvFilenames(env); +) { + const path = yield* Path.Path; + const env = + supabaseEnv || + Option.getOrUndefined( + yield* Config.option(Config.String("SUPABASE_ENV")).pipe( + Effect.mapError( + () => + new ProjectEnvironmentError({ + message: "failed to resolve environment variable: SUPABASE_ENV", + }), + ), + ), + ) || + "development"; + const filenames = [...new Set(candidateDotenvFilenames(env))]; const merged: Record = {}; + let fileCount = 0; - const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); + const supabaseDir = projectEnv?.paths.supabaseDir ?? path.join(workdir, "supabase"); const projectRoot = projectEnv?.paths.projectRoot ?? workdir; // supabase/ dir first, then its parent (the project root). Within a directory, @@ -69,13 +103,15 @@ export function resolveProjectEnvironmentValues( // already present reproduces both orderings at once. for (const dir of [supabaseDir, projectRoot]) { for (const filename of filenames) { - const parsed = readDotEnvFile(join(dir, filename)); + const parsed = yield* readDotEnvFile(path.join(dir, filename)); if (parsed === undefined) continue; + fileCount += 1; for (const [key, value] of Object.entries(parsed)) { if (!(key in merged)) merged[key] = value; } } } + yield* Effect.annotateCurrentSpan("file.count", fileCount); const ambientOverrides: Record = {}; if (projectEnv !== null) { @@ -93,4 +129,4 @@ export function resolveProjectEnvironmentValues( } return { ...merged, ...ambientOverrides }; -} +}); diff --git a/apps/cli/src/command-internal/project-environment.unit.test.ts b/apps/cli/src/command-internal/project-environment.unit.test.ts index 3e6bdab5e7..51e9158099 100644 --- a/apps/cli/src/command-internal/project-environment.unit.test.ts +++ b/apps/cli/src/command-internal/project-environment.unit.test.ts @@ -1,28 +1,22 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, layer } from "@effect/vitest"; import type { CliProjectEnvironment } from "@supabase/config"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { resolveProjectEnvironmentValues } from "./project-environment.ts"; - -let root: string; -let supabaseDir: string; - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "supabase-project-env-")); - supabaseDir = join(root, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); -}); - -afterEach(() => { - rmSync(root, { recursive: true, force: true }); - delete process.env["SUPABASE_ENV"]; - delete process.env["SUPABASE_PROJECT_ID"]; +import { ConfigProvider, Effect, FileSystem, Path, Tracer } from "effect"; + +import { withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; +import { ProjectEnvironmentError, resolveProjectEnvironmentValues } from "./project-environment.ts"; + +const project = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-project-env-" }); + const supabaseDir = path.join(root, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + return { fs, path, root, supabaseDir }; }); function fakeProjectEnv( + { path, root, supabaseDir }: Effect.Success, values: Record = {}, sources: Record = {}, ): CliProjectEnvironment { @@ -30,9 +24,9 @@ function fakeProjectEnv( paths: { projectRoot: root, supabaseDir, - configPath: join(supabaseDir, "config.toml"), - envPath: join(supabaseDir, ".env"), - envLocalPath: join(supabaseDir, ".env.local"), + configPath: path.join(supabaseDir, "config.toml"), + envPath: path.join(supabaseDir, ".env"), + envLocalPath: path.join(supabaseDir, ".env.local"), }, values, loadedPaths: [], @@ -41,245 +35,576 @@ function fakeProjectEnv( }; } -describe("resolveProjectEnvironmentValues", () => { - it("returns just the already-loaded values when no extra dotenv files exist", () => { - const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); - expect(resolveProjectEnvironmentValues(projectEnv, root)).toEqual({ - SUPABASE_PROJECT_ID: "from-loader", - }); - }); - - it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); - }); - - it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }); - - it("lets already-resolved projectEnv.values win over anything discovered locally", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); - const merged = resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }); - - it("defaults SUPABASE_ENV to development when unset", () => { - writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }); - - it("selects the SUPABASE_ENV-named file over the bare .env file", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }); - - it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); - }); - - it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { - process.env["SUPABASE_ENV"] = "test"; - writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); - writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); - }); - - it("prefers an explicit supabaseEnv argument over process.env SUPABASE_ENV", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root, "test"); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("bare-env-project"); - }); - - it("falls back to process.env SUPABASE_ENV when no supabaseEnv argument is given", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }); - - it("strips quotes the same way the shared dotenv parser does", () => { - writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); - }); - - it("ignores blank lines and comments", () => { - writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); - }); - - it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { - writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); - }); - - it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { - writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); - }); - - it("strips a trailing comment after a quoted value, matching godotenv", () => { - writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }); +layer(BunServices.layer)("resolveProjectEnvironmentValues", (it) => { + it.effect("returns just the already-loaded values when no extra dotenv files exist", () => + Effect.gen(function* () { + const dirs = yield* project; + const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "from-loader" }); + expect(yield* resolveProjectEnvironmentValues(projectEnv, dirs.root)).toEqual({ + SUPABASE_PROJECT_ID: "from-loader", + }); + }), + ); + + it.effect("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }), + ); + + it.effect("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root, supabaseDir } = dirs; + yield* fs.writeFileString( + path.join(supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=supabase-dir-project\n", + ); + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }), + ); + + it.effect("lets already-resolved projectEnv.values win over anything discovered locally", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "ambient-project" }); + const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }), + ); + + it.effect("defaults SUPABASE_ENV to development when unset", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env.development"), + "SUPABASE_PROJECT_ID=dev-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), + ); + + it.effect("defaults an empty SUPABASE_ENV to development", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env.development"), + "SUPABASE_PROJECT_ID=dev-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "" }, body)), + ); + + it.effect("selects the SUPABASE_ENV-named file over the bare .env file", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + yield* fs.writeFileString( + path.join(root, ".env.production"), + "SUPABASE_PROJECT_ID=prod-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), + ); + + it.effect("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env.production"), + "SUPABASE_PROJECT_ID=prod-project\n", + ); + yield* fs.writeFileString( + path.join(root, ".env.production.local"), + "SUPABASE_PROJECT_ID=prod-local-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), + ); + + it.effect("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env.local"), + "SUPABASE_PROJECT_ID=local-project\n", + ); + yield* fs.writeFileString(path.join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "test" }, body)), + ); + + it.effect("prefers an explicit supabaseEnv argument over the configured SUPABASE_ENV", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + yield* fs.writeFileString( + path.join(root, ".env.local"), + "SUPABASE_PROJECT_ID=local-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root, "test"); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("bare-env-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), + ); + + it.effect("falls back to the configured SUPABASE_ENV when no supabaseEnv argument is given", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + yield* fs.writeFileString( + path.join(root, ".env.production"), + "SUPABASE_PROJECT_ID=prod-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), + ); + + it.effect("strips quotes the same way the shared dotenv parser does", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n', + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }), + ); + + it.effect("ignores blank lines and comments", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }), + ); + + it.effect( + "preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + "SUPABASE_AUTH_JWT_SECRET=long#secret\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }), + ); + + it.effect("still truncates an unquoted value at a whitespace-preceded inline comment", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=54323 # local\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); + }), + ); + + it.effect("strips a trailing comment after a quoted value, matching godotenv", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_PROJECT_ID="demo" # local\n'); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }), + ); + + it.effect( + "accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID: colon-project\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + }), + ); + + it.effect( + "prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", + () => + Effect.gen(function* () { + // Only an "ambient" source outranks the file precedence computed locally. + const dirs = yield* project; + const { fs, path, root, supabaseDir } = dirs; + yield* fs.writeFileString( + path.join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + dirs, + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ); + const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), + ); + + it.effect("still lets a truly ambient-sourced value win over any file", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root, supabaseDir } = dirs; + yield* fs.writeFileString( + path.join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + dirs, + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ); + const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), + ); + + it.effect( + "fails on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "not a valid line\n"); + const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(ProjectEnvironmentError); + expect(String(error)).toMatch(/^Error: failed to parse environment file: /); + }), + ); + + it.effect("keeps a leading BOM, so a BOM-prefixed .env fails to parse", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "\uFEFFSUPABASE_PROJECT_ID=bom-project\n"); + const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( + Effect.flip, + ); + expect(String(error)).toMatch(/^Error: failed to parse environment file: /); + }), + ); + + it.effect("fails with the native read error when a dotenv path is a directory", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.makeDirectory(path.join(root, ".env")); + const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( + Effect.flip, + ); + expect(String(error)).toMatch(/^Error: EISDIR/); + }), + ); + + it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => + Effect.gen(function* () { + const dirs = yield* project; + const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), dirs.root).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(ProjectEnvironmentError); + expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ), + ), + ), + ), + ); + + it.effect("skips a dotenv path that is a symlink loop, like a missing file", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.symlink(path.join(root, ".env"), path.join(root, ".env")); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged).toEqual({}); + }), + ); + + it.effect("expands an unquoted $VAR reference to an earlier value in the same file", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }), + ); + + it.effect("expands a braced ${VAR} reference in a double-quoted value", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n', + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }), + ); + + it.effect("does not expand variable references inside single-quoted values", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }), + ); + + it.effect( + "expands an unresolved bare reference to an empty string, matching Go's map zero-value", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }), + ); + + it.effect( + "expands an unresolved braced reference to an empty string, matching Go's map zero-value", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }), + ); + + it.effect( + "preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", + () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n", + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }), + ); + + it.effect("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n', + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }), + ); + + it.effect("treats a bare trailing $ with no variable name as a literal", () => + Effect.gen(function* () { + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }), + ); + + it.effect( + "preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", + () => + Effect.gen(function* () { + // A quoted value spanning physical lines (e.g. a pasted PEM key) must not break + // parsing of the rest of the file. + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + const dirs = yield* project; + const { fs, path, root } = dirs; + yield* fs.writeFileString( + path.join(root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }), + ); + + it.effect( + "records the parsed dotenv file count on one resolve span, and no span when tracing is off", + () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const dirs = yield* project; + const { fs, path, root, supabaseDir } = dirs; + yield* fs.writeFileString( + path.join(supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=supabase-dir-project\n", + ); + yield* fs.writeFileString( + path.join(root, ".env.development"), + "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", + ); + const resolve = resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( + Effect.withTracer(tracer), + ); + const expected = { + SUPABASE_PROJECT_ID: "supabase-dir-project", + SUPABASE_AUTH_JWT_SECRET: "dev-secret", + }; + + expect(yield* resolve.pipe(Effect.withTracerEnabled(false))).toEqual(expected); + expect(spans).toEqual([]); + + expect(yield* resolve.pipe(Effect.withTracerEnabled(true))).toEqual(expected); + expect(spans.map((span) => span.name)).toEqual(["ProjectEnvironment.resolve"]); + expect(spans[0]?.attributes.get("file.count")).toBe(2); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), + ); + + it.effect("counts a .env.local file once when SUPABASE_ENV is local", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const dirs = yield* project; + const { fs, path, root, supabaseDir } = dirs; + yield* fs.writeFileString( + path.join(supabaseDir, ".env.local"), + "SUPABASE_PROJECT_ID=local-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues( + fakeProjectEnv(dirs), + root, + "local", + ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("local-project"); + expect(spans[0]?.attributes.get("file.count")).toBe(1); + }), + ); - it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { - writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); - }); + describe("when no project was found (projectEnv is null)", () => { + // A missing config.toml must not skip dotenv loading; these cover the fallback + // that derives `/supabase` directly. - it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { - // Only an "ambient" source outranks the file precedence computed locally. - process.env["SUPABASE_ENV"] = "development"; - writeFileSync( - join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, - { SUPABASE_PROJECT_ID: ".env" }, + it.effect("still reads a supabase/-dir dotenv file directly under workdir", () => + Effect.gen(function* () { + const { fs, path, root, supabaseDir } = yield* project; + yield* fs.writeFileString( + path.join(supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=fallback-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), ); - const merged = resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); - }); - it("still lets a truly ambient-sourced value win over any file", () => { - process.env["SUPABASE_ENV"] = "development"; - writeFileSync( - join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - { SUPABASE_PROJECT_ID: "ambient-project" }, - { SUPABASE_PROJECT_ID: "ambient" }, + it.effect("still reads a project-root dotenv file directly under workdir", () => + Effect.gen(function* () { + const { fs, path, root } = yield* project; + yield* fs.writeFileString( + path.join(root, ".env"), + "SUPABASE_PROJECT_ID=root-fallback-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), ); - const merged = resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }); - it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { - writeFileSync(join(root, ".env"), "not a valid line\n"); - expect(() => resolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( - /failed to parse environment file/, + it.effect( + "prefers the supabase/-dir file over the project-root file, same as the non-null case", + () => + Effect.gen(function* () { + const { fs, path, root, supabaseDir } = yield* project; + yield* fs.writeFileString( + path.join(supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=supabase-dir-project\n", + ); + yield* fs.writeFileString( + path.join(root, ".env"), + "SUPABASE_PROJECT_ID=root-dir-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), ); - }); - - it("expands an unquoted $VAR reference to an earlier value in the same file", () => { - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }); - - it("expands a braced ${VAR} reference in a double-quoted value", () => { - writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); - }); - - it("does not expand variable references inside single-quoted values", () => { - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); - }); - it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); - }); - - it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { - writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); - }); - - it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); - }); - - it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { - writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); - }); - - it("treats a bare trailing $ with no variable name as a literal", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); - }); - - it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { - // A quoted value spanning physical lines (e.g. a pasted PEM key) must not break - // parsing of the rest of the file. - const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; - writeFileSync( - join(root, ".env"), - `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + it.effect("lets an ambient shell var win over a dotenv value, using process.env directly", () => + Effect.gen(function* () { + const { fs, path, root, supabaseDir } = yield* project; + yield* fs.writeFileString( + path.join(supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=dotenv-fallback-project\n", + ); + const merged = yield* resolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", "ambient-fallback-project", body)), ); - const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); - }); - - describe("when no project was found (projectEnv is null)", () => { - // A missing config.toml must not skip dotenv loading; these cover the fallback - // that derives `/supabase` directly. - it("still reads a supabase/-dir dotenv file directly under workdir", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); - const merged = resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); - }); - - it("still reads a project-root dotenv file directly under workdir", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); - const merged = resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); - }); - - it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }); - - it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { - process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); - const merged = resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); - }); - - it("returns an empty object when workdir has no dotenv files and no ambient value", () => { - const merged = resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); - }); + it.effect("returns an empty object when workdir has no dotenv files and no ambient value", () => + Effect.gen(function* () { + const { root } = yield* project; + const merged = yield* resolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), + ); }); }); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index e22c95691d..16f1557e39 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -30,6 +30,7 @@ import { mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, + withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { toDockerPath } from "../../../shared/functions/functions-docker.ts"; @@ -3779,7 +3780,10 @@ describe("functions serve integration", () => { projectId: "root-env-project", }, ]); - }).pipe((body) => withEnvVar("SUPABASE_ENV", "development", body)); + }).pipe( + (body) => withEnvVar("SUPABASE_ENV", "development", body), + (body) => withConfigEnv({ SUPABASE_ENV: "development" }, body), + ); }); it.live( @@ -3834,7 +3838,10 @@ describe("functions serve integration", () => { const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("SUPABASE_INTERNAL_HOST_PORT=5544"); - }).pipe((body) => withEnvVar("SUPABASE_ENV", "development", body)); + }).pipe( + (body) => withEnvVar("SUPABASE_ENV", "development", body), + (body) => withConfigEnv({ SUPABASE_ENV: "development" }, body), + ); }, ); diff --git a/apps/cli/src/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/commands/gen/gen.signing-keys-config.ts index bbf8975dad..9965daa2fe 100644 --- a/apps/cli/src/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/commands/gen/gen.signing-keys-config.ts @@ -340,10 +340,11 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* ( }).pipe( Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`)), ); - const projectEnvValues = yield* Effect.try({ - try: () => resolveProjectEnvironmentValues(projectEnv, cwd, supabaseEnvValue), - catch: (cause) => onConfigParseError(`failed to read config: ${String(cause)}`), - }); + const projectEnvValues = yield* resolveProjectEnvironmentValues( + projectEnv, + cwd, + supabaseEnvValue, + ).pipe(Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`))); const loaded = yield* loadCliConfig(cwd, { cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, goViperCompat: true,