diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 6d5f42ccbd..65f20f1b5a 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -89,7 +89,9 @@ Resolve opt-in booleans with `command-internal/experimental-feature.ts`: environ overrides the project setting, and an unset or empty value uses the config. Invalid environment values are typed failures on applicable command paths. Disabled families are absent from the command tree, help, and completion; enabled help is marked experimental and stays out of stable -generated command documentation. Environment opt-ins do not write project configuration. +generated command documentation. Environment opt-ins do not write project configuration, except +`supabase init` (and blank `bootstrap`) with `SUPABASE_EXPERIMENTAL_STACK=1`, which persists +`experimental.stack = true` and omits Docker-era default ports. Keep config-discovery failure policy explicit and cover TOML, JSON, precedence, and disabled behavior. diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 427701f663..a98537aca6 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -87,6 +87,11 @@ For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new b precedence over `experimental.stack`; an unset or empty value falls back to the file setting. Other values are rejected. The override is applied before reading the project configuration. +`SUPABASE_EXPERIMENTAL_STACK=1 supabase init` writes `[experimental] stack = true` into the new +project config and omits the Docker-era default ports so the stack is not pinned to them. +Without the environment variable, `init` still writes the established template with those ports +and without the stack flag. Blank `supabase bootstrap` uses the same scaffold. + When the flag is on, `--local` targets of the `db`, `migration`, `test db`, `gen types`, and `inspect` families use the project stack and provision throwaway shadow Postgres through `@supabase/stack` (`EphemeralPostgres`). Top-level `supabase pull` uses the same stack shadow diff --git a/apps/cli/src/command-internal/experimental-feature.ts b/apps/cli/src/command-internal/experimental-feature.ts index 22e975d134..cf43e986b0 100644 --- a/apps/cli/src/command-internal/experimental-feature.ts +++ b/apps/cli/src/command-internal/experimental-feature.ts @@ -1,5 +1,5 @@ import { CliConfigSchema, findCliProjectPaths } from "@supabase/config/effect"; -import { Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Config, ConfigProvider, Data, Effect, FileSystem, Option, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { resolveWorkdir } from "../config/command-settings.layer.ts"; import { rootFlagTokens } from "../shared/cli/run.ts"; @@ -71,6 +71,21 @@ export const readExperimentalFeatureConfig = (input: { return decoded.experimental?.[input.feature]; }).pipe(Effect.orElseSucceed(() => undefined)); +/** + * Env record for one experimental feature, read from ConfigProvider. + */ +export const experimentalFeatureEnv = ( + feature: string, +): Effect.Effect>> => + Effect.gen(function* () { + const envName = `SUPABASE_EXPERIMENTAL_${feature.toUpperCase()}`; + const provider = yield* ConfigProvider.ConfigProvider; + const override = yield* Config.option(Config.string(envName)) + .parse(provider) + .pipe(Effect.orElseSucceed(() => Option.none())); + return { [envName]: Option.getOrUndefined(override) }; + }); + /** Resolves one experimental boolean from its environment override and config fallback. */ export const resolveExperimentalFeature = (input: { readonly feature: string; diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index a7068afa9f..26e46f18c5 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -61,21 +61,22 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | +| Variable | Purpose | Required? | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | +| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_EXPERIMENTAL_STACK` | blank/`scratch` path only; when `1`, persists `[experimental] stack = true` and omits Docker-era default ports. Empty is unset; other non-empty values fail closed | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `0` | success | -| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; native push failure (missing local/remote migrations, cancelled confirmation, connect/apply failure); any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: ` and continues). | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; native push failure (missing local/remote migrations, cancelled confirmation, connect/apply failure); invalid `SUPABASE_EXPERIMENTAL_STACK` on the blank path; any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: ` and continues). | ## Telemetry diff --git a/apps/cli/src/commands/bootstrap/bootstrap.command.ts b/apps/cli/src/commands/bootstrap/bootstrap.command.ts index 4798a0fe84..d4228b5ba2 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.command.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.command.ts @@ -1,7 +1,9 @@ +import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; +import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { bootstrapRuntimeLayer } from "./bootstrap.layers.ts"; import { bootstrap } from "./bootstrap.handler.ts"; @@ -27,5 +29,5 @@ export const bootstrapCommand = Command.make("bootstrap", config).pipe( // Go marks no bootstrap flag `markFlagTelemetrySafe`, so no `safeFlags`. bootstrap(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), - Command.provide(bootstrapRuntimeLayer), + Command.provide(bootstrapRuntimeLayer.pipe(Layer.provideMerge(cliConfigProviderLayer))), ); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 9fe38cdd53..9d0387307e 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -33,6 +33,10 @@ import { projectCreateCore } from "../../command-internal/project-create-core.ts import { tempPaths } from "../../command-internal/temp-paths.ts"; import { extractServiceKeys } from "../../command-internal/tenant-keys.ts"; import { parseDotEnv } from "../../command-internal/dotenv.ts"; +import { + experimentalFeatureEnv, + resolveExperimentalFeature, +} from "../../command-internal/experimental-feature.ts"; import { initProject } from "../../shared/init/project-init.ts"; import { buildDotEnv, marshalDotEnv } from "./bootstrap.dotenv.ts"; import { @@ -126,6 +130,15 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( starter = allTemplates.find((t) => t.name === choice) ?? SCRATCH_TEMPLATE; } + const experimentalStack = + starter.url.length === 0 + ? yield* resolveExperimentalFeature({ + feature: "stack", + configValue: Effect.succeed(false), + env: yield* experimentalFeatureEnv("stack"), + }) + : false; + yield* fs.makeDirectory(workdir, { recursive: true }); const entries = yield* fs .readDirectory(workdir) @@ -167,6 +180,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( useOrioledb: false, withVscodeSettings: false, withIntellijSettings: false, + experimentalStack, }); } diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 2fd5e9b354..dea9a754ee 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -1,6 +1,17 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schedule } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schedule, +} from "effect"; import { mockAnalytics, @@ -95,6 +106,7 @@ interface SetupOpts { readonly dbPassword?: string; /** Raw `SUPABASE_WORKDIR` the settings captured; used verbatim, so no prompt fires. */ readonly workdirEnvValue?: string; + readonly env?: Readonly>; } function setup(path: Path.Path, opts: SetupOpts = {}) { @@ -237,6 +249,9 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), debugLoggerLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, opts.debug ?? false))), + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), + ), ); return { @@ -289,6 +304,46 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live( + "scratch scaffolding writes the stack-opt-in template when SUPABASE_EXPERIMENTAL_STACK=1", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const s = setup(path, { env: { SUPABASE_EXPERIMENTAL_STACK: "1" } }); + yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( + Effect.provide(s.layer), + ); + const content = yield* fs.readFileString(path.join(s.workdir, "supabase", "config.toml")); + expect(content).toContain("stack = true"); + expect(content).not.toMatch(/^port = 54321$/m); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "scratch scaffolding fails closed on an invalid SUPABASE_EXPERIMENTAL_STACK before writing config", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const s = setup(path, { env: { SUPABASE_EXPERIMENTAL_STACK: "yes" } }); + const exit = yield* Effect.exit( + bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( + Effect.provide(s.layer), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("ExperimentalFeatureFlagError"); + expect(Cause.pretty(exit.cause)).toContain( + "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + ); + } + expect(yield* fs.exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(false); + expect(s.out.stderrText).not.toContain("Created a new project at"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("downloads a named template matched by argument", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -304,6 +359,20 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("ignores SUPABASE_EXPERIMENTAL_STACK on a downloaded template", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const s = setup(path, { + samples: [NEXTJS_TEMPLATE], + env: { SUPABASE_EXPERIMENTAL_STACK: "yes" }, + }); + yield* bootstrap(flags({ template: Option.some("NextJS") }), FAST_BACKOFF).pipe( + Effect.provide(s.layer), + ); + expect(s.downloads).toHaveLength(1); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("rejects an unknown template argument", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 08e77bff83..8d16637e65 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -457,6 +457,14 @@ enabled = true }); }); + it.effect("leaves stack-opt-in init listeners automatic except disabled pooler", () => { + return Effect.gen(function* () { + const root = yield* project(renderCliConfigTemplate("stack-config-init", false, true)); + const config = yield* load(root); + expect(config.listeners).toEqual({ pooler: { enabled: false } }); + }); + }); + it.effect("ignores unresolved function env references when edge runtime is disabled", () => { return Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-disabled-functions-env" diff --git a/apps/cli/src/commands/init/SIDE_EFFECTS.md b/apps/cli/src/commands/init/SIDE_EFFECTS.md index 0d8dc247d0..c9cdca403a 100644 --- a/apps/cli/src/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/init/SIDE_EFFECTS.md @@ -12,13 +12,13 @@ ## Files Written -| Path | Format | When | -| ------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- | -| `supabase/config.toml` | TOML | always on success; created from default template | -| `supabase/.gitignore` | text | when inside a git repo and the template is not already present | -| `.vscode/settings.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | -| `.vscode/extensions.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | -| `.idea/deno.xml` | XML | when interactive IntelliJ setup is accepted, or when `--with-intellij-settings` is set | +| Path | Format | When | +| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML | always on success; created from the default template, or the stack-opt-in template when `SUPABASE_EXPERIMENTAL_STACK=1` | +| `supabase/.gitignore` | text | when inside a git repo and the template is not already present | +| `.vscode/settings.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.vscode/extensions.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.idea/deno.xml` | XML | when interactive IntelliJ setup is accepted, or when `--with-intellij-settings` is set | ## API Routes @@ -28,7 +28,10 @@ ## Environment Variables -None. +| Variable | Purpose | Required? | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_YES` | auto-accepts `-i` IDE prompts with the established stderr echo, same as `--yes` | no | +| `SUPABASE_EXPERIMENTAL_STACK` | when `1`, persist `[experimental] stack = true` and omit Docker-era default ports; `0`, unset, or empty writes the established template | no | ## Exit Codes @@ -36,6 +39,7 @@ None. | ---- | ------------------------------------------------------------------------------------ | | `0` | success - prints "Finished supabase init." | | `1` | `supabase/config.toml` already exists and `--force` was not provided | +| `1` | `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` | | `1` | permission denied writing config file | | `1` | an existing `.vscode/settings.json` / `.vscode/extensions.json` is not valid JSON(C) | @@ -76,11 +80,23 @@ required flag(s) "experimental" not set Try rerunning the command with --debug to troubleshoot the error. ``` +When `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` (stderr; the second line is the generic debug hint appended on error): + +``` +SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set +Try rerunning the command with --debug to troubleshoot the error. +``` + ## Notes - Uses the invocation cwd directly and does not recurse upward looking for an existing project. - The `--force` flag overwrites an existing `supabase/config.toml`. - The `--use-orioledb` flag sets `UseOrioleDB` in init params; requires `--experimental` flag. +- `SUPABASE_EXPERIMENTAL_STACK=1` opts the new project into the experimental stack backend: the + written config includes `[experimental] stack = true` and omits the Docker-era default ports + (API, database, shadow, pooler, Studio, mail UI, Functions inspector, and Analytics). + A non-empty value other than `0` or `1` fails closed. `0`, unset, or empty keeps the + established template. - The `--interactive` / `-i` flag enables IDE settings prompts (only effective in TTY). - The `--with-vscode-settings` and `--with-vscode-workspace` flags are hidden backward-compat aliases for the same VS Code helper and both write `.vscode/settings.json` and `.vscode/extensions.json`. - The `--with-intellij-settings` flag is a hidden backward-compat alias for generating `.idea/deno.xml`. diff --git a/apps/cli/src/commands/init/init.command.ts b/apps/cli/src/commands/init/init.command.ts index 659387c067..df44722c64 100644 --- a/apps/cli/src/commands/init/init.command.ts +++ b/apps/cli/src/commands/init/init.command.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; +import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -49,6 +50,9 @@ export const initCommand = Command.make("init", config).pipe( // `stdinLayer` satisfies `promptYesNo`'s `Stdin` requirement (via the // shared `initProject` IDE prompts). The prompts are gated on a TTY stdin, so // init never actually reads a piped line at runtime — the layer is here for - // the effect's type requirements only. - Command.provide(Layer.mergeAll(commandRuntimeLayer(["init"]), stdinLayer)), + // the effect's type requirements only. `cliConfigProviderLayer` satisfies + // experimental-stack env reads so ConfigProvider does not leak to the root. + Command.provide( + Layer.mergeAll(commandRuntimeLayer(["init"]), stdinLayer, cliConfigProviderLayer), + ), ); diff --git a/apps/cli/src/commands/init/init.handler.ts b/apps/cli/src/commands/init/init.handler.ts index ea1c8163d6..a10d0d2518 100644 --- a/apps/cli/src/commands/init/init.handler.ts +++ b/apps/cli/src/commands/init/init.handler.ts @@ -2,6 +2,10 @@ import { Effect, Option, Path } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { initProject } from "../../shared/init/project-init.ts"; import { Output } from "../../shared/output/output.service.ts"; +import { + experimentalFeatureEnv, + resolveExperimentalFeature, +} from "../../command-internal/experimental-feature.ts"; import { ExperimentalFlag, WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; import { InitConfigExistsError, InitExperimentalRequiredError } from "./init.errors.ts"; import type { InitFlags } from "./init.command.ts"; @@ -19,6 +23,12 @@ export const init = Effect.fn("init")(function* (flags: InitFlags) { }); } + const experimentalStack = yield* resolveExperimentalFeature({ + feature: "stack", + configValue: Effect.succeed(false), + env: yield* experimentalFeatureEnv("stack"), + }); + const result = yield* initProject({ cwd: Option.isSome(workdir) ? path.resolve(runtimeInfo.cwd, workdir.value) : runtimeInfo.cwd, force: flags.force, @@ -29,6 +39,7 @@ export const init = Effect.fn("init")(function* (flags: InitFlags) { yes: yield* resolveYes, withVscodeSettings: flags.withVscodeWorkspace || flags.withVscodeSettings, withIntellijSettings: flags.withIntellijSettings, + experimentalStack, }); if (!result.created) { diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index 8ab37d2a7e..bf9f451fca 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Stdio } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Stdio, +} from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { ExperimentalFlag, WorkdirFlag, YesFlag } from "../../command-internal/global-flags.ts"; import { normalizeCause } from "../../shared/output/normalize-error.ts"; @@ -31,6 +41,7 @@ function setup( /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ stdinInput?: string; platform?: NodeJS.Platform; + env?: Readonly>; } = {}, ) { const out = mockOutput({ format: "text", interactive: opts.interactive ?? false }); @@ -49,6 +60,9 @@ function setup( Layer.succeed(WorkdirFlag, opts.workdir ?? Option.none()), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(CliArgs, { args: [] }), + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), + ), ), }; } @@ -119,6 +133,8 @@ describe("init", () => { const content = yield* readTextFile(tempDir, "supabase", "config.toml"); expect(content).toContain("major_version = 17"); + expect(content).toContain("port = 54321"); + expect(content).not.toContain("stack = true"); expect(out.stdoutText).toBe("Finished supabase init.\n"); }); }); @@ -326,4 +342,57 @@ describe("init", () => { ); }); }); + + it.live("writes the stack-opt-in template when SUPABASE_EXPERIMENTAL_STACK=1", () => { + const tempDir = tempRoot.current; + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir, { env: { SUPABASE_EXPERIMENTAL_STACK: "1" } }); + + yield* init({ ...BASE_INIT_FLAGS, interactive: false }).pipe(Effect.provide(layer)); + + const content = yield* readTextFile(tempDir, "supabase", "config.toml"); + expect(content).toContain("stack = true"); + expect(content).not.toMatch(/^shadow_port = 54320$/m); + expect(content).not.toMatch(/^port = 54321$/m); + expect(content).not.toMatch(/^inspector_port = 8083$/m); + expect(out.stdoutText).toBe("Finished supabase init.\n"); + }); + }); + + it.live("keeps the established template when SUPABASE_EXPERIMENTAL_STACK=0", () => { + const tempDir = tempRoot.current; + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { env: { SUPABASE_EXPERIMENTAL_STACK: "0" } }); + + yield* init({ ...BASE_INIT_FLAGS, interactive: false }).pipe(Effect.provide(layer)); + + const content = yield* readTextFile(tempDir, "supabase", "config.toml"); + expect(content).not.toContain("stack = true"); + expect(content).toContain("port = 54321"); + }); + }); + + it.live("fails closed when SUPABASE_EXPERIMENTAL_STACK is not 0 or 1", () => { + const tempDir = tempRoot.current; + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { env: { SUPABASE_EXPERIMENTAL_STACK: "yes" } }); + + const exit = yield* init({ ...BASE_INIT_FLAGS, interactive: false }).pipe( + Effect.provide(layer), + Effect.exit, + ); + + const error = findFailure(exit); + expect(error["_tag"]).toBe("ExperimentalFeatureFlagError"); + expect(error["message"]).toBe("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + + expect(yield* renderFailureToStderr(exit)).toEqual([ + "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set\n", + "Try rerunning the command with --debug to troubleshoot the error.\n", + ]); + }); + }); }); diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index b114e50c39..683a44fc3a 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -466,9 +466,53 @@ export const INTELLIJ_DENO_TEMPLATE = ` const ORIOLE_DB_VERSION = "15.1.0.150"; -export function renderCliConfigTemplate(projectId: string, useOrioledb: boolean): string { - return CONFIG_TEMPLATE_RAW.replace("__PROJECT_ID__", projectId).replace( +const EXPERIMENTAL_STACK_INIT_FLAG = `# Use the new local stack backend for start, stop, and status, and for --local targets of db, migration, test db, gen types, inspect, and pull. +stack = true +`; + +// Default ports omitted so the stack is not pinned to Docker-era values. +const STACK_INIT_OMITTED_PORT_BLOCKS = [ + `# Port to use for the API URL. +port = 54321 +`, + `# Port to use for the local database URL. +port = 54322 +`, + `# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +`, + `# Port to use for the local connection pooler. +port = 54329 +`, + `# Port to use for Supabase Studio. +port = 54323 +`, + `# Port to use for the email testing server web interface. +port = 54324 +`, + `# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +`, + `port = 54327 +`, +] as const; + +function applyExperimentalStackInitTemplate(source: string): string { + let next = source.replace("[experimental]\n", `[experimental]\n${EXPERIMENTAL_STACK_INIT_FLAG}`); + for (const block of STACK_INIT_OMITTED_PORT_BLOCKS) { + next = next.replace(block, ""); + } + return next; +} + +export function renderCliConfigTemplate( + projectId: string, + useOrioledb: boolean, + experimentalStack = false, +): string { + const rendered = CONFIG_TEMPLATE_RAW.replace("__PROJECT_ID__", projectId).replace( "__ORIOLEDB_VERSION__", useOrioledb ? ORIOLE_DB_VERSION : "", ); + return experimentalStack ? applyExperimentalStackInitTemplate(rendered) : rendered; } diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 2a29af5bf7..6dbb7073d6 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -95,6 +95,23 @@ describe("project init templates", () => { expect(rendered).toContain("[experimental.pgdelta]\nenabled = true"); }); + it("opts the experimental stack template into stack=true without default listener ports", () => { + const rendered = renderCliConfigTemplate("demo-project", false, true); + expect(rendered).toMatch( + /\[experimental\]\n# Use the new local stack backend for start, stop, and status, and for --local targets of db, migration, test db, gen types, inspect, and pull.\nstack = true\n/, + ); + expect(rendered).toContain("# smtp_port = 54325"); + expect(rendered).toContain("[experimental.pgdelta]\nenabled = true"); + expect(rendered).not.toMatch(/^port = 54321$/m); + expect(rendered).not.toMatch(/^port = 54322$/m); + expect(rendered).not.toMatch(/^shadow_port = 54320$/m); + expect(rendered).not.toMatch(/^port = 54329$/m); + expect(rendered).not.toMatch(/^port = 54323$/m); + expect(rendered).not.toMatch(/^port = 54324$/m); + expect(rendered).not.toMatch(/^inspector_port = 8083$/m); + expect(rendered).not.toMatch(/^port = 54327$/m); + }); + it("matches the Go .gitignore scaffold", () => { expect(INIT_GITIGNORE_TEMPLATE).toBe(readVendoredTemplate("gitignore")); }); diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index 83a6afa168..a5d8e82b30 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -135,6 +135,8 @@ export interface ProjectInitOptions { readonly yes: boolean; readonly withVscodeSettings: boolean; readonly withIntellijSettings: boolean; + /** Persist `experimental.stack` and omit default stack listener ports. */ + readonly experimentalStack?: boolean; } // Files/directories are pinned to 0644/0755 explicitly rather than relying @@ -302,7 +304,7 @@ export const initProject = Effect.fnUntraced(function* (options: ProjectInitOpti yield* fs.makeDirectory(supabaseDir, { recursive: true, mode: INIT_DIR_MODE }); yield* fs.writeFileString( configTomlPath, - renderCliConfigTemplate(projectId, options.useOrioledb), + renderCliConfigTemplate(projectId, options.useOrioledb, options.experimentalStack === true), { mode: INIT_FILE_MODE }, ); yield* ensureSupabaseGitignore(options.cwd);