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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/command-internal/experimental-feature.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Readonly<Record<string, string | undefined>>> =>
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<string>()));
return { [envName]: Option.getOrUndefined(override) };
});

/** Resolves one experimental boolean from its environment override and config fallback. */
export const resolveExperimentalFeature = <E, R>(input: {
readonly feature: string;
Expand Down
25 changes: 13 additions & 12 deletions apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <err>` 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: <err>` and continues). |

## Telemetry

Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/bootstrap/bootstrap.command.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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))),
);
14 changes: 14 additions & 0 deletions apps/cli/src/commands/bootstrap/bootstrap.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -167,6 +180,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* (
useOrioledb: false,
withVscodeSettings: false,
withIntellijSettings: false,
experimentalStack,
});
}

Expand Down
71 changes: 70 additions & 1 deletion apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Record<string, string | undefined>>;
}

function setup(path: Path.Path, opts: SetupOpts = {}) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading