diff --git a/AGENTS.md b/AGENTS.md index ffa5c1c6da..9b3f5e64ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ These workspaces should generally follow this structure: - Standard scripts: `test`, `types:check` - Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript` -Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting is scoped to `packages/stack` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. +Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting covers `packages/stack` and all files under `apps/cli/src/commands/experimental/stack` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. Expected exceptions: diff --git a/apps/cli/package.json b/apps/cli/package.json index adb4c11221..3c1909d587 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -58,6 +58,7 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.49", "@supabase/pg-topo": "1.0.0-alpha.6", + "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.23.1", diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index bb1e1998a6..24609a253f 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -181,6 +181,10 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "link", "issue-type", "improvement", + // experimental stack start flags + "stack", + "stack-id", + "preparation", ]); /** diff --git a/apps/cli/src/commands/experimental/experimental.command.ts b/apps/cli/src/commands/experimental/experimental.command.ts index 7a1762121d..14c18a42b8 100644 --- a/apps/cli/src/commands/experimental/experimental.command.ts +++ b/apps/cli/src/commands/experimental/experimental.command.ts @@ -1,5 +1,6 @@ import { Command } from "effect/unstable/cli"; import { legacyWorkersCommand } from "./workers/workers.command.ts"; +import { legacyExperimentalStackCommand } from "./stack/stack.command.ts"; /** * `supabase experimental` — the parent for command families that are not yet @@ -17,6 +18,6 @@ export const legacyExperimentalCommand = Command.make("experimental").pipe( "Experimental commands. These are unstable: their flags, output, and invocation path can change or be removed in any release, and they are excluded from the CLI's compatibility promise.", ), Command.withShortDescription("Experimental, unstable commands"), - Command.withSubcommands([legacyWorkersCommand]), + Command.withSubcommands([legacyWorkersCommand, legacyExperimentalStackCommand]), Command.unlisted, ); diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md new file mode 100644 index 0000000000..ad3ecf6e88 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -0,0 +1,16 @@ +# `supabase experimental stack list` + +Lists persisted managed local stacks discovered in the global stack registry. +The command includes stopped stacks and performs no config loading, owner RPC, +activation, or lifecycle mutation. Registry discovery is currently fail-fast: a +corrupt or unsupported entry can prevent other entries from being listed. + +Entries are sorted by project root, stack name, and id. Text output includes the +identity, project root, branch context, runtime, and desired lifecycle. Structured +output returns the same fields under `stacks`. The legacy `-o/--output` flag is +rejected; use `--output-format json`. + +The command reads `${SUPABASE_HOME ?? $HOME/.supabase}/managed/stacks//`, +including each persisted state document and state remnant metadata. It consumes +`SUPABASE_HOME`, falling back to `HOME/.supabase`. Exit code 0 indicates success; +exit code 1 indicates a registry read error or rejected legacy output flag. diff --git a/apps/cli/src/commands/experimental/stack/list/list.command.ts b/apps/cli/src/commands/experimental/stack/list/list.command.ts new file mode 100644 index 0000000000..8a9b343e22 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -0,0 +1,12 @@ +import { Command } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackList } from "./list.handler.ts"; + +export const legacyExperimentalStackListCommand = Command.make("list").pipe( + Command.withDescription("List persisted managed local Supabase stacks."), + Command.withShortDescription("List managed local stacks"), + Command.withHandler(() => + legacyExperimentalStackList().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/list/list.errors.ts b/apps/cli/src/commands/experimental/stack/list/list.errors.ts new file mode 100644 index 0000000000..ebd272dea6 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.errors.ts @@ -0,0 +1,20 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackListError extends Data.TaggedError( + "LegacyExperimentalStackListError", +)<{ + readonly message: string; + readonly reason: "flags" | "invalid-config"; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "flags") return actionability.provideFlags; + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/commands/experimental/stack/list/list.handler.ts b/apps/cli/src/commands/experimental/stack/list/list.handler.ts new file mode 100644 index 0000000000..a486da108a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.handler.ts @@ -0,0 +1,81 @@ +import { Effect, Match, Option } from "effect"; +import { + type StackDescriptor, + type StackDiscoveryError, + type StackRuntime, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { LegacyExperimentalStackListError } from "./list.errors.ts"; + +const entry = (descriptor: StackDescriptor) => ({ + id: descriptor.id, + project_root: descriptor.projectRoot, + name: descriptor.name, + branch_context: descriptor.branchContext, + runtime: descriptor.runtime, + desired_lifecycle: descriptor.desiredLifecycle, +}); + +const compareCodeunit = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +const compareEntries = ( + left: ReturnType, + right: ReturnType, +): number => { + const project = compareCodeunit(left.project_root, right.project_root); + if (project !== 0) return project; + const name = compareCodeunit(left.name, right.name); + return name !== 0 ? name : compareCodeunit(left.id, right.id); +}; + +const mapStackError = (error: StackDiscoveryError) => + new LegacyExperimentalStackListError({ + reason: "invalid-config", + message: error.message, + suggestion: + "Inspect the managed stack registry under $SUPABASE_HOME/managed/stacks or ~/.supabase/managed/stacks.", + cause: error, + }); + +const renderRuntime = (runtime: StackRuntime): string => + Match.value(runtime).pipe( + Match.when({ kind: "native" }, () => "native"), + Match.when({ kind: "container" }, ({ engine }) => `container (${engine})`), + Match.exhaustive, + ); + +const render = (stacks: ReadonlyArray>): string => { + if (stacks.length === 0) return "No managed stacks found.\n"; + const lines = stacks.flatMap((stack, index) => [ + ...(index === 0 ? [] : [""]), + `${stack.name} (${stack.id})`, + ` Project: ${stack.project_root}`, + ` Branch: ${stack.branch_context}`, + ` Runtime: ${renderRuntime(stack.runtime)}`, + ` Desired lifecycle: ${stack.desired_lifecycle}`, + ]); + return `${lines.join("\n")}\n`; +}; + +export const legacyExperimentalStackList = Effect.fn("legacy.experimental.stack.list")( + function* () { + const output = yield* Output; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackListError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json or --output-format text.", + }); + const api = yield* LegacyExperimentalStackApi; + const stacks = (yield* api.listStacks().pipe(Effect.mapError(mapStackError))) + .map(entry) + .sort(compareEntries); + if (output.format === "text") yield* output.raw(render(stacks)); + else yield* output.success("", { stacks }); + return stacks; + }, +); diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts new file mode 100644 index 0000000000..3462b38409 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + StackIdSchema, + StackStateFormatUnsupportedError, + type StackDescriptor, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackList } from "./list.handler.ts"; +import { LegacyExperimentalStackListError } from "./list.errors.ts"; +import { legacyExperimentalStackListCommand } from "./list.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; + +const descriptor = ( + id: string, + projectRoot: string, + name: string, + lifecycle: StackDescriptor["desiredLifecycle"], +) => ({ + id: StackIdSchema.make(id.repeat(64)), + projectRoot, + name, + branchContext: `${name}-branch`, + runtime: { kind: "native" as const }, + desiredLifecycle: lifecycle, +}); + +const runList = ( + stacks: ReadonlyArray, + options: { readonly legacyOutput?: boolean; readonly format?: "text" | "json" } = {}, +) => { + const out = mockOutput({ format: options.format }); + let listCalls = 0; + let otherApiCalls = 0; + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => { + otherApiCalls++; + return Effect.die("create must not run"); + }, + findStack: () => { + otherApiCalls++; + return Effect.succeed(Option.none()); + }, + listStacks: () => + Effect.sync(() => { + listCalls++; + return stacks; + }), + openStack: () => { + otherApiCalls++; + return Effect.die("open must not run"); + }, + inspectStack: () => { + otherApiCalls++; + return Effect.die("inspect must not run"); + }, + }); + const layer = Layer.mergeAll( + out.layer, + api, + BunServices.layer, + ...(options.legacyOutput ? [Layer.succeed(LegacyOutputFlag, Option.some("json"))] : []), + ); + return { + out, + get listCalls() { + return listCalls; + }, + get otherApiCalls() { + return otherApiCalls; + }, + effect: legacyExperimentalStackList().pipe(Effect.provide(layer)), + }; +}; + +describe("experimental stack list", () => { + it.effect( + "sorts distinct persisted roots and reports stopped lifecycle without live claims", + () => { + const run = runList([ + descriptor("e", "/work/z", "zeta", "stopped"), + descriptor("a", "/work/a", "beta", "running"), + descriptor("c", "/work/a", "alpha", "unconfigured"), + descriptor("b", "/work/a", "alpha", "stopped"), + ]); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.listCalls).toBe(1); + expect(run.otherApiCalls).toBe(0); + expect(run.out.stdoutText.indexOf("alpha")).toBeLessThan( + run.out.stdoutText.indexOf("beta"), + ); + expect(run.out.stdoutText).toContain("Desired lifecycle: stopped"); + expect(run.out.stdoutText).not.toContain("Readiness"); + expect(run.out.stdoutText).toContain("/work/z"); + expect(run.out.stdoutText).toContain(`alpha (${"b".repeat(64)})`); + expect(run.out.stdoutText).toContain(`alpha (${"c".repeat(64)})`); + expect(run.out.stdoutText.indexOf(`alpha (${"b".repeat(64)})`)).toBeLessThan( + run.out.stdoutText.indexOf(`alpha (${"c".repeat(64)})`), + ); + }), + ), + ); + }, + ); + + it.effect("emits structured identity fields without secrets", () => { + const run = runList([descriptor("d", "/work/secret", "safe", "stopped")], { format: "json" }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.messages[0]?.data).toEqual({ + stacks: [ + { + id: "d".repeat(64), + project_root: "/work/secret", + name: "safe", + branch_context: "safe-branch", + runtime: { kind: "native" }, + desired_lifecycle: "stopped", + }, + ], + }); + }), + ), + ); + }); + + it.effect("renders the container engine in text output", () => { + const run = runList([ + { + ...descriptor("d", "/work/container", "podman", "stopped"), + runtime: { kind: "container", engine: "podman" }, + }, + ]); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => expect(run.out.stdoutText).toContain("Runtime: container (podman)")), + ), + ); + }); + + it.effect("reports an empty registry", () => { + const run = runList([]); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => expect(run.out.stdoutText).toBe("No managed stacks found.\n")), + ), + ); + }); + + it.effect("rejects legacy output without listing", () => { + const run = runList([], { legacyOutput: true }); + return run.effect.pipe( + Effect.exit, + Effect.tap((legacyExit) => + Effect.sync(() => { + expect(Exit.isFailure(legacyExit)).toBe(true); + expect(run.listCalls).toBe(0); + if (Exit.isFailure(legacyExit)) { + const error = Cause.findErrorOption(legacyExit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error) && error.value instanceof LegacyExperimentalStackListError) { + expect(error.value.message).toContain("legacy -o/--output flag"); + expect(error.value.suggestion).toContain("--output-format"); + expect(error.value[ErrorActionabilityId]).toEqual(actionability.provideFlags); + } + } + }), + ), + ); + }); + + it.effect("preserves registry errors with actionable diagnostics", () => { + const errorOut = mockOutput(); + const errorLayer = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), + listStacks: () => + Effect.fail(new StackStateFormatUnsupportedError({ message: "registry unreadable" })), + openStack: () => Effect.die("unused"), + inspectStack: () => Effect.die("unused"), + }); + return legacyExperimentalStackList().pipe( + Effect.provide(Layer.mergeAll(errorOut.layer, errorLayer, BunServices.layer)), + Effect.exit, + Effect.tap((errorExit) => + Effect.sync(() => { + expect(Exit.isFailure(errorExit)).toBe(true); + if (Exit.isFailure(errorExit)) { + const error = Cause.findErrorOption(errorExit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error) && error.value instanceof LegacyExperimentalStackListError) { + expect(error.value.message).toBe("registry unreadable"); + expect(error.value.suggestion).toContain("managed stack registry"); + expect(error.value.cause).toBeInstanceOf(StackStateFormatUnsupportedError); + expect(error.value[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + } + }), + ), + ); + }); + + it.live("parses the list command through the command runner", () => { + let called = false; + const command = legacyExperimentalStackListCommand.pipe( + Command.withHandler(() => + Effect.sync(() => { + called = true; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([]); + expect(called).toBe(true); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); +}); 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 new file mode 100644 index 0000000000..3fb5fa0739 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -0,0 +1,408 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option, Redacted } from "effect"; +import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; + +import { LegacyStackConfigError, legacyLoadStackConfig } from "./stack-config.ts"; + +const load = (projectRoot: string) => + legacyLoadStackConfig(projectRoot).pipe(Effect.provide(BunServices.layer)); +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function project(contents: string, signingKeys?: string): string { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-config-")); + roots.push(root); + mkdirSync(join(root, "supabase"), { recursive: true }); + mkdirSync(join(root, "supabase", "functions", "hello"), { recursive: true }); + mkdirSync(join(root, "supabase", "functions", "world"), { recursive: true }); + mkdirSync(join(root, "supabase", "functions", "plain"), { recursive: true }); + mkdirSync(join(root, "supabase", "functions", "old.backup"), { recursive: true }); + mkdirSync(join(root, "supabase", "functions", "_shared"), { recursive: true }); + writeFileSync(join(root, "supabase", "config.toml"), contents); + writeFileSync(join(root, "supabase", ".env"), "CONFIG_FN=config-value\n"); + writeFileSync( + join(root, "supabase", "functions", ".env"), + 'SHARED=shared\nOVERRIDE=shared\nQUOTED="hello # world" # comment\n', + ); + writeFileSync( + join(root, "supabase", "functions", "hello", ".env"), + "LOCAL=local\nOVERRIDE=local\n", + ); + writeFileSync(join(root, "supabase", "functions", "world", ".env"), "WORLD=yes\n"); + if (signingKeys !== undefined) + writeFileSync(join(root, "supabase", "signing-keys.json"), signingKeys); + return root; +} + +describe("legacyLoadStackConfig", () => { + it.effect("maps service settings, secrets, function files, and explicit ports", () => { + const root = project(` +project_id = "stack-config-test" +[api] +port = 55421 +schemas = ["public", "private"] +[db] +port = 55422 +[db.pooler] +enabled = true +pool_mode = "session" +default_pool_size = 33 +max_client_conn = 222 +[auth] +jwt_secret = "01234567890123456789012345678901" +[auth.email.smtp] +enabled = true +host = "smtp.example.test" +port = 2525 +user = "smtp-user" +pass = "smtp-secret" +admin_email = "admin@example.test" +sender_name = "Test" +[auth.external.github] +enabled = true +client_id = "client" +secret = "secret" +[auth.hook.custom_access_token] +enabled = true +uri = "pg-functions://custom" +[edge_runtime] +enabled = true +inspector_port = 58083 +[functions.hello] +verify_jwt = false +import_map = "./functions/import_map.json" +entrypoint = "./functions/hello/index.ts" +env = { API_KEY = "env(CONFIG_FN)" } +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners).toMatchObject({ + api: { port: 55421 }, + database: { port: 55422 }, + functionsInspector: { port: 58083 }, + }); + expect(config.listeners?.studio).toBeUndefined(); + if (config.capabilities?.rest === undefined || !("settings" in config.capabilities.rest)) + throw new Error("REST settings missing"); + expect(config.capabilities.rest.settings?.schemas).toEqual(["public", "private"]); + if ( + config.capabilities?.functions === undefined || + !("settings" in config.capabilities.functions) + ) + throw new Error("Functions settings missing"); + expect(config.capabilities.functions.settings?.functions?.hello).toMatchObject({ + verify_jwt: false, + import_map: "../import_map.json", + entrypoint: "index.ts", + env: { + API_KEY: expect.anything(), + SHARED: expect.anything(), + LOCAL: expect.anything(), + OVERRIDE: expect.anything(), + }, + }); + expect(config.capabilities.functions.settings?.functions?.world?.env).toMatchObject({ + WORLD: expect.anything(), + }); + const helloEnv = config.capabilities.functions.settings?.functions?.hello?.env; + const plainEnv = config.capabilities.functions.settings?.functions?.plain?.env; + expect(config.capabilities.functions.settings?.functions?.["old.backup"]).toBeUndefined(); + expect(config.capabilities.functions.settings?.functions?._shared).toBeUndefined(); + expect(helloEnv).toBeDefined(); + expect(plainEnv).toBeDefined(); + if (helloEnv === undefined || plainEnv === undefined) throw new Error("function env missing"); + expect(Redacted.value(helloEnv.API_KEY!)).toBe("config-value"); + expect(Redacted.value(helloEnv.OVERRIDE!)).toBe("local"); + expect(Redacted.value(plainEnv.SHARED!)).toBe("shared"); + expect(Redacted.value(plainEnv.QUOTED!)).toBe("hello # world"); + if (config.capabilities.auth === undefined || !("settings" in config.capabilities.auth)) + throw new Error("auth settings missing"); + expect(config.capabilities.auth.settings?.email?.smtp).toMatchObject({ + enabled: true, + host: "smtp.example.test", + }); + expect(config.capabilities.auth.settings?.external?.github).toMatchObject({ + enabled: true, + client_id: "client", + }); + expect(config.capabilities.auth.settings?.hook?.custom_access_token).toMatchObject({ + enabled: true, + uri: "pg-functions://custom", + }); + if (config.capabilities.pooler === undefined || !("settings" in config.capabilities.pooler)) + throw new Error("pooler settings missing"); + expect(config.capabilities.pooler.settings).toMatchObject({ + pool_mode: "session", + default_pool_size: 33, + max_client_conn: 222, + }); + expect(config.security?.jwt?.signing?.kind).toBe("symmetric"); + }); + }); + + it.effect("rejects an enabled provider the stack cannot represent", () => { + const root = project(`project_id = "stack-config-figma" +[auth.external.figma] +enabled = true +client_id = "figma-client" +secret = "figma-secret" +`); + return Effect.gen(function* () { + const exit = yield* load(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("auth.external.figma"); + }); + }); + + it.effect("keeps disabled unsupported providers harmless", () => { + const root = project(`project_id = "stack-config-disabled-figma" +[auth.external.figma] +enabled = false +`); + return Effect.gen(function* () { + const config = yield* load(root); + if (config.capabilities?.auth === undefined || !("settings" in config.capabilities.auth)) + throw new Error("auth settings missing"); + expect(Object.hasOwn(config.capabilities.auth.settings?.external ?? {}, "figma")).toBe(false); + }); + }); + + it.effect("rejects an unset function env reference without dropping it", () => { + const root = project(`project_id = "stack-config-missing-env" +[functions.hello] +env = { TOKEN = "env(SUPABASE_STACK_TEST_MISSING_ENV)" } +`); + return Effect.gen(function* () { + const exit = yield* load(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("functions.hello.env"); + }); + }); + + it.effect("reports unsupported dotenv keys with the file and key only", () => { + const root = project('project_id = "stack-config-invalid-env-key"\n'); + writeFileSync(join(root, "supabase", "functions", ".env"), "lowercase=value\nSECRET=value\n"); + return Effect.gen(function* () { + const exit = yield* load(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) expect(failure.value).toBeInstanceOf(LegacyStackConfigError); + const message = String(exit.cause); + expect(message).toContain("functions/.env"); + expect(message).toContain("lowercase"); + expect(message).not.toContain("value"); + } + }); + }); + + it.effect("rejects function paths outside the function root", () => { + const root = project(`project_id = "stack-config-outside-function" + +[functions.hello] +import_map = "./import_map.json" +`); + return Effect.gen(function* () { + const exit = yield* load(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("functions.hello.import_map"); + }); + }); + + it.effect( + "rejects supabase-prefixed function paths that resolve outside the project root", + () => { + const root = project(`project_id = "stack-config-nested-supabase" + +[functions.hello] +entrypoint = "supabase/functions/hello/index.ts" +`); + return Effect.gen(function* () { + const exit = yield* load(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("functions.hello.entrypoint"); + }); + }, + ); + + it.effect("resolves supabase-prefixed signing paths beneath the config directory", () => { + const root = project( + `project_id = "stack-config-signing-path" +[auth] +signing_keys_path = "supabase/signing-keys.json" +`, + ); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.security?.jwt?.signing).toEqual({ + kind: "jwks-file", + path: "supabase/supabase/signing-keys.json", + }); + }); + }); + + it.effect("keeps the gateway listener when API service is disabled for auth", () => { + const root = project(`project_id = "stack-config-gateway" +[api] +enabled = false +port = 55430 +[auth] +enabled = true +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners?.api).toEqual({ port: 55430 }); + }); + }); + + it.effect("keeps the gateway listener when analytics is enabled", () => { + const root = project(`project_id = "stack-config-analytics-gateway" +[api] +enabled = false +port = 55431 +[auth] +enabled = false +[realtime] +enabled = false +[storage] +enabled = false +[edge_runtime] +enabled = false +[analytics] +enabled = true +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners?.api).toEqual({ port: 55431 }); + }); + }); + + it.effect("keeps disabled functions capability free of settings", () => { + const root = project(`project_id = "stack-config-disabled-functions" +[edge_runtime] +enabled = false +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.capabilities?.functions).toEqual({ enabled: false }); + }); + }); + + it.effect("leaves listeners absent when ports are omitted", () => { + const root = project(`project_id = "stack-config-defaults" +[edge_runtime] +enabled = true +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners).toEqual({}); + expect(config.listeners?.functionsInspector).toBeUndefined(); + if (config.capabilities?.database !== undefined && "settings" in config.capabilities.database) + expect(config.capabilities.database.settings?.health_timeout).toBe("2m"); + }); + }); + + it.effect("loads the actual initialized stack config template", () => { + const root = project(renderCliConfigTemplate("stack-config-init", false)); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners?.api).toEqual({ port: 54321 }); + expect(config.listeners?.database).toEqual({ port: 54322 }); + expect(config.listeners?.pooler).toEqual({ enabled: false }); + expect(config.listeners?.smtp).toBeUndefined(); + expect(config.listeners?.pop3).toBeUndefined(); + expect(config.listeners?.functionsInspector).toEqual({ port: 8083 }); + }); + }); + + it.effect("ignores unresolved function env references when edge runtime is disabled", () => { + const root = project(`project_id = "stack-config-disabled-functions-env" +[edge_runtime] +enabled = false +[functions.hello] +env = { TOKEN = "env(SUPABASE_STACK_TEST_DISABLED_MISSING_ENV)" } +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.capabilities?.functions).toEqual({ enabled: false }); + }); + }); + + it.effect("does not read disabled edge runtime dotenv files", () => { + const root = project(`project_id = "stack-config-disabled-dotenv" +[edge_runtime] +enabled = false +`); + writeFileSync(join(root, "supabase", "functions", ".env"), "lowercase=value\n"); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.capabilities?.functions).toEqual({ enabled: false }); + }); + }); + + it.effect("accepts the initialized disabled service listeners with explicit ports", () => { + const root = project(`project_id = "stack-config-disabled-listeners" +[api] +enabled = false +port = 55431 +[auth] +enabled = false +[realtime] +enabled = false +[storage] +enabled = false +[db] +port = 55432 +[db.pooler] +enabled = false +port = 55433 +[studio] +enabled = false +port = 55434 +[local_smtp] +enabled = false +port = 55435 +smtp_port = 55436 +pop3_port = 55437 +[edge_runtime] +enabled = false +inspector_port = 55438 +[analytics] +enabled = false +`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners).toEqual({ + api: { enabled: false }, + database: { port: 55432 }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }); + }); + }); + + it.effect("fails clearly when the project has not been initialized", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-config-empty-")); + roots.push(root); + return Effect.gen(function* () { + const exit = yield* legacyLoadStackConfig(root).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("supabase init"); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts new file mode 100644 index 0000000000..0b85412819 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -0,0 +1,810 @@ +import type { CliConfig } from "@supabase/config"; +import { Effect, Data, FileSystem, Option, Path, Redacted, Schema } from "effect"; +import { parse as parseDotenv } from "dotenv"; +import { StackConfigSchema, type StackConfig } from "@supabase/stack/effect"; + +import { legacyLoadLocalProjectContext } from "../../../command-internal/legacy-local-project-context.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** A config error suitable for an experimental stack command's user-facing boundary. */ +export class LegacyStackConfigError extends Data.TaggedError("LegacyStackConfigError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +type LegacyStackConfigEffect = Effect.Effect< + StackConfig, + LegacyStackConfigError, + FileSystem.FileSystem | Path.Path +>; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const withoutUndefined = (value: unknown): unknown => { + if (Redacted.isRedacted(value)) return value; + if (Array.isArray(value)) return value.map(withoutUndefined); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => [key, withoutUndefined(item)]), + ); +}; + +const secret = (value: unknown): Redacted.Redacted | undefined => { + if (Redacted.isRedacted(value)) { + const unwrapped = Redacted.value(value); + return typeof unwrapped === "string" ? Redacted.make(unwrapped) : undefined; + } + return typeof value === "string" ? Redacted.make(value) : undefined; +}; + +const parseEnv = (contents: string): Record> => + Object.fromEntries( + Object.entries(parseDotenv(contents)).map(([key, value]) => [key, Redacted.make(value)]), + ); + +const envKeyPattern = /^[A-Z_][A-Z0-9_]*$/u; + +const validateEnvKeys = ( + values: Readonly>>, + file: string, +) => { + const invalid = Object.keys(values).find((key) => !envKeyPattern.test(key)); + return invalid === undefined + ? Effect.succeed(values) + : Effect.fail( + new LegacyStackConfigError({ + message: `Invalid environment variable key ${invalid} in ${file}; use uppercase letters, digits, and underscores.`, + }), + ); +}; + +const legacyReadFunctionEnvironments = ( + projectRoot: string, + disabledFunctions: ReadonlySet = new Set(), + skip = false, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join(projectRoot, "supabase", "functions"); + if (skip) return { shared: {}, functions: {} }; + const read = (file: string) => + fs.exists(file).pipe( + Effect.flatMap((exists) => + exists + ? fs.readFileString(file).pipe( + Effect.map(parseEnv), + Effect.flatMap((values) => validateEnvKeys(values, file)), + ) + : Effect.succeed({}), + ), + ); + const shared = yield* read(path.join(root, ".env")); + const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + const result: Record>>> = {}; + for (const entry of entries.filter((name) => !name.startsWith(".") && !name.startsWith("_"))) { + if (!/^[A-Za-z0-9_-]+$/u.test(entry)) continue; + const info = yield* fs.stat(path.join(root, entry)).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "Directory") continue; + if (disabledFunctions.has(entry)) continue; + const functionEnv = yield* read(path.join(root, entry, ".env")); + result[entry] = { ...shared, ...functionEnv }; + } + return { shared, functions: result }; + }); + +const section = (document: Readonly> | undefined, name: string) => { + const value = document?.[name]; + return isRecord(value) ? value : undefined; +}; + +const explicitPort = ( + document: Readonly> | undefined, + sectionName: string, + key: string, +): number | undefined => { + const value = section(document, sectionName)?.[key]; + return typeof value === "number" ? value : undefined; +}; + +const pick = ( + value: unknown, + keys: ReadonlyArray, + secretKeys: ReadonlySet = new Set(), +): Record => { + if (!isRecord(value)) return {}; + const result: Record = {}; + for (const key of keys) { + if (!(key in value) || value[key] === undefined) continue; + result[key] = secretKeys.has(key) ? secret(value[key]) : value[key]; + } + return result; +}; + +const pickRecord = ( + value: unknown, + keys: ReadonlyArray, + secretKeys: ReadonlySet = new Set(), +): Record> => { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value).map(([name, item]) => [name, pick(item, keys, secretKeys)]), + ); +}; + +const authProviderKeys = [ + "enabled", + "client_id", + "secret", + "url", + "redirect_uri", + "skip_nonce_check", + "email_optional", +]; +const authProviderNames = [ + "apple", + "azure", + "bitbucket", + "discord", + "facebook", + "github", + "gitlab", + "google", + "kakao", + "keycloak", + "linkedin_oidc", + "notion", + "twitch", + "twitter", + "x", + "slack_oidc", + "spotify", + "workos", + "zoom", +] as const; + +const legacyStackProjectPath = (value: string): string => + value.length === 0 || value.startsWith("/") + ? value + : `supabase/${value.startsWith("./") ? value.slice(2) : value}`; + +/** Converts a CLI function path (relative to supabase/) to the stack resolver's + * function-directory-relative form. */ +const legacyFunctionRelativePath = ( + path: Path.Path, + projectRoot: string, + value: string, + name: string, +): string => { + if (value.length === 0) return value; + const functionsRoot = path.join(projectRoot, "supabase", "functions"); + const functionRoot = path.join(functionsRoot, name); + const target = path.isAbsolute(value) + ? path.normalize(value) + : path.normalize( + path.join(projectRoot, "supabase", value.startsWith("./") ? value.slice(2) : value), + ); + return path.relative(functionRoot, target).replaceAll(path.sep, "/"); +}; + +const legacyFunctionPathError = ( + path: Path.Path, + projectRoot: string, + name: string, + field: string, + value: string, +): string | undefined => { + if (value.length === 0) return undefined; + const functionsRoot = path.join(projectRoot, "supabase", "functions"); + const target = path.isAbsolute(value) + ? path.normalize(value) + : path.normalize( + path.join(projectRoot, "supabase", value.startsWith("./") ? value.slice(2) : value), + ); + const relative = path.relative(functionsRoot, target); + if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) + return `functions.${name}.${field} path must be inside supabase/functions`; + return undefined; +}; + +const apiListener = ( + document: Readonly> | undefined, + config: CliConfig, +) => { + const listener = listenerFromSection(document, "api", "port"); + const gatewayEnabled = + config.api.enabled || + config.auth.enabled || + config.realtime.enabled || + config.storage.enabled || + config.edge_runtime.enabled || + config.analytics.enabled; + if (listener === undefined) return listener; + if (gatewayEnabled) { + const port = explicitPort(document, "api", "port"); + return port === undefined ? {} : { port }; + } + return { ...listener, enabled: false }; +}; + +const listenerFromSection = ( + document: Readonly> | undefined, + sectionName: string, + portKey: string, +) => { + const raw = section(document, sectionName); + if (raw === undefined) return undefined; + const enabled = raw["enabled"]; + const port = explicitPort(document, sectionName, portKey); + if (enabled === false) return { enabled: false }; + return port === undefined ? undefined : { port }; +}; + +const nestedPort = ( + document: Readonly> | undefined, + sectionName: string, + nestedSection: string, + portKey: string, +): number | undefined => { + const nested = section(section(document, sectionName), nestedSection); + const value = nested?.[portKey]; + return typeof value === "number" ? value : undefined; +}; + +const nestedListener = ( + document: Readonly> | undefined, + sectionName: string, + nestedSection: string, + portKey: string, +) => { + const parent = section(document, sectionName); + const nested = section(parent, nestedSection); + if (nested === undefined) return undefined; + const port = nestedPort(document, sectionName, nestedSection, portKey); + if (nested.enabled === false) return { enabled: false }; + return port === undefined ? undefined : { port }; +}; + +const legacyAuthSettings = (auth: CliConfig["auth"]) => ({ + ...(auth.site_url === undefined ? {} : { site_url: auth.site_url }), + ...(auth.additional_redirect_urls === undefined + ? {} + : { additional_redirect_urls: auth.additional_redirect_urls }), + ...(auth.jwt_expiry === undefined ? {} : { jwt_expiry: auth.jwt_expiry }), + ...(auth.jwt_issuer === undefined ? {} : { jwt_issuer: auth.jwt_issuer }), + ...(auth.signing_keys_path === undefined ? {} : { signing_keys_path: auth.signing_keys_path }), + ...(auth.enable_refresh_token_rotation === undefined + ? {} + : { enable_refresh_token_rotation: auth.enable_refresh_token_rotation }), + ...(auth.refresh_token_reuse_interval === undefined + ? {} + : { refresh_token_reuse_interval: auth.refresh_token_reuse_interval }), + ...(auth.enable_manual_linking === undefined + ? {} + : { enable_manual_linking: auth.enable_manual_linking }), + ...(auth.enable_signup === undefined ? {} : { enable_signup: auth.enable_signup }), + ...(auth.enable_anonymous_sign_ins === undefined + ? {} + : { enable_anonymous_sign_ins: auth.enable_anonymous_sign_ins }), + ...(auth.minimum_password_length === undefined + ? {} + : { minimum_password_length: auth.minimum_password_length }), + ...(auth.password_requirements === undefined + ? {} + : { password_requirements: auth.password_requirements }), + ...(secret(auth.publishable_key) === undefined + ? {} + : { publishable_key: secret(auth.publishable_key) }), + ...(secret(auth.secret_key) === undefined ? {} : { secret_key: secret(auth.secret_key) }), + ...(secret(auth.jwt_secret) === undefined ? {} : { jwt_secret: secret(auth.jwt_secret) }), + ...(secret(auth.anon_key) === undefined ? {} : { anon_key: secret(auth.anon_key) }), + ...(secret(auth.service_role_key) === undefined + ? {} + : { service_role_key: secret(auth.service_role_key) }), + ...(auth.rate_limit === undefined + ? {} + : { + rate_limit: pick(auth.rate_limit, [ + "email_sent", + "sms_sent", + "anonymous_users", + "token_refresh", + "sign_in_sign_ups", + "token_verifications", + "web3", + ]), + }), + ...(auth.captcha === undefined + ? {} + : { captcha: pick(auth.captcha, ["enabled", "provider", "secret"], new Set(["secret"])) }), + ...(auth.hook === undefined + ? {} + : { + hook: pickRecord(auth.hook, ["enabled", "uri", "secrets"], new Set(["secrets"])), + }), + ...(auth.mfa === undefined + ? {} + : { + mfa: { + ...pick(auth.mfa, ["max_enrolled_factors"]), + ...(auth.mfa.totp === undefined + ? {} + : { totp: pick(auth.mfa.totp, ["enroll_enabled", "verify_enabled"]) }), + ...(auth.mfa.phone === undefined + ? {} + : { + phone: pick(auth.mfa.phone, [ + "enroll_enabled", + "verify_enabled", + "otp_length", + "template", + "max_frequency", + ]), + }), + ...(auth.mfa.web_authn === undefined + ? {} + : { web_authn: pick(auth.mfa.web_authn, ["enroll_enabled", "verify_enabled"]) }), + }, + }), + ...(auth.sessions === undefined + ? {} + : { sessions: pick(auth.sessions, ["timebox", "inactivity_timeout"]) }), + ...(auth.email === undefined + ? {} + : { + email: { + ...pick(auth.email, [ + "enable_signup", + "double_confirm_changes", + "enable_confirmations", + "secure_password_change", + "max_frequency", + "otp_length", + "otp_expiry", + ]), + ...(auth.email.smtp === undefined + ? {} + : { + smtp: pick( + auth.email.smtp, + ["enabled", "host", "port", "user", "pass", "admin_email", "sender_name"], + new Set(["pass"]), + ), + }), + ...(auth.email.template === undefined + ? {} + : { template: pickRecord(auth.email.template, ["subject", "content_path"]) }), + ...(auth.email.notification === undefined + ? {} + : { + notification: pickRecord(auth.email.notification, [ + "enabled", + "subject", + "content_path", + ]), + }), + }, + }), + ...(auth.sms === undefined + ? {} + : { + sms: { + ...pick(auth.sms, ["enable_signup", "enable_confirmations", "template", "max_frequency"]), + ...(auth.sms.twilio === undefined + ? {} + : { + twilio: pick( + auth.sms.twilio, + ["enabled", "account_sid", "message_service_sid", "auth_token"], + new Set(["auth_token"]), + ), + }), + ...(auth.sms.twilio_verify === undefined + ? {} + : { + twilio_verify: pick( + auth.sms.twilio_verify, + ["enabled", "account_sid", "message_service_sid", "auth_token"], + new Set(["auth_token"]), + ), + }), + ...(auth.sms.messagebird === undefined + ? {} + : { + messagebird: pick( + auth.sms.messagebird, + ["enabled", "originator", "access_key"], + new Set(["access_key"]), + ), + }), + ...(auth.sms.textlocal === undefined + ? {} + : { + textlocal: pick( + auth.sms.textlocal, + ["enabled", "sender", "api_key"], + new Set(["api_key"]), + ), + }), + ...(auth.sms.vonage === undefined + ? {} + : { + vonage: pick( + auth.sms.vonage, + ["enabled", "from", "api_key", "api_secret"], + new Set(["api_key", "api_secret"]), + ), + }), + ...(auth.sms.test_otp === undefined ? {} : { test_otp: auth.sms.test_otp }), + }, + }), + ...(auth.external === undefined + ? {} + : { + external: Object.fromEntries( + authProviderNames + .filter((name) => isRecord(auth.external) && auth.external[name] !== undefined) + .map((name) => [ + name, + pick(auth.external[name], authProviderKeys, new Set(["secret"])), + ]), + ), + }), + ...(auth.web3 === undefined ? {} : { web3: pickRecord(auth.web3, ["enabled"]) }), + ...(auth.oauth_server === undefined + ? {} + : { + oauth_server: pick(auth.oauth_server, [ + "enabled", + "authorization_url_path", + "allow_dynamic_registration", + ]), + }), + ...(auth.third_party === undefined + ? {} + : { + third_party: { + ...(auth.third_party.firebase === undefined + ? {} + : { firebase: pick(auth.third_party.firebase, ["enabled", "project_id"]) }), + ...(auth.third_party.auth0 === undefined + ? {} + : { auth0: pick(auth.third_party.auth0, ["enabled", "tenant", "tenant_region"]) }), + ...(auth.third_party.aws_cognito === undefined + ? {} + : { + aws_cognito: pick(auth.third_party.aws_cognito, [ + "enabled", + "user_pool_id", + "user_pool_region", + ]), + }), + ...(auth.third_party.clerk === undefined + ? {} + : { clerk: pick(auth.third_party.clerk, ["enabled", "domain"]) }), + ...(auth.third_party.workos === undefined + ? {} + : { workos: pick(auth.third_party.workos, ["enabled", "issuer_url"]) }), + }, + }), +}); + +const legacyFunctionsSettings = ( + projectRoot: string, + path: Path.Path, + config: CliConfig, + document?: Record, + projectEnvValues: Readonly> = {}, +) => { + const edge = config.edge_runtime; + const documentFunctions = section(document, "functions"); + const functions = Object.fromEntries( + Object.entries(config.functions).map(([name, value]) => [ + name, + { + ...(value.enabled === undefined ? {} : { enabled: value.enabled }), + ...(value.verify_jwt === undefined ? {} : { verify_jwt: value.verify_jwt }), + ...(value.import_map === undefined + ? {} + : { import_map: legacyFunctionRelativePath(path, projectRoot, value.import_map, name) }), + ...(value.entrypoint === undefined + ? {} + : { entrypoint: legacyFunctionRelativePath(path, projectRoot, value.entrypoint, name) }), + ...(value.static_files === undefined + ? {} + : { + static_files: value.static_files.map((filePath) => + legacyFunctionRelativePath(path, projectRoot, filePath, name), + ), + }), + env: Object.fromEntries( + Object.entries( + isRecord(documentFunctions?.[name]) && isRecord(documentFunctions[name].env) + ? documentFunctions[name].env + : value.env, + ).flatMap(([key, item]) => { + const resolvedValue = + typeof item === "string" && /^env\([A-Za-z_][A-Za-z0-9_]*\)$/.test(item) + ? projectEnvValues[item.slice(4, -1)] + : item; + const resolved = secret(resolvedValue); + return resolved === undefined ? [] : [[key, resolved]]; + }), + ), + }, + ]), + ); + return { + functions_root: "supabase/functions", + edge_runtime: { + ...(edge.policy === undefined ? {} : { policy: edge.policy }), + ...(edge.deno_version === undefined ? {} : { deno_version: edge.deno_version }), + ...(edge.secrets === undefined + ? {} + : { + secrets: Object.fromEntries( + Object.entries(edge.secrets).flatMap(([key, item]) => { + const resolved = secret(item); + return resolved === undefined ? [] : [[key, resolved]]; + }), + ), + }), + }, + functions, + }; +}; + +const legacyConfigInput = ( + projectRoot: string, + path: Path.Path, + config: CliConfig, + document?: Record, + projectEnvValues: Readonly> = {}, +) => { + const db = config.db; + const api = config.api; + const auth = config.auth; + const storage = config.storage; + const realtime = config.realtime; + const studio = config.studio; + const analytics = config.analytics; + const mail = config.local_smtp; + const pooler = db.pooler; + const capability = (enabled: boolean, settings: unknown) => + enabled ? { settings } : { enabled: false as const }; + return { + capabilities: { + database: { + version: String(db.major_version), + settings: { health_timeout: db.health_timeout, settings: db.settings }, + }, + rest: capability(api.enabled, { + schemas: api.schemas, + extra_search_path: api.extra_search_path, + max_rows: api.max_rows, + auto_expose_new_tables: api.auto_expose_new_tables, + tls: api.tls, + external_url: api.external_url, + }), + auth: capability(auth.enabled, legacyAuthSettings(auth)), + realtime: capability(realtime.enabled, { + ip_version: realtime.ip_version, + max_header_length: realtime.max_header_length, + }), + storage: capability(storage.enabled, { + file_size_limit: storage.file_size_limit, + image_transformation: storage.image_transformation, + buckets: storage.buckets, + s3_protocol: storage.s3_protocol, + analytics: storage.analytics, + vector: storage.vector, + }), + functions: capability( + config.edge_runtime.enabled, + legacyFunctionsSettings(projectRoot, path, config, document, projectEnvValues), + ), + studio: capability(studio.enabled, { + api_url: studio.api_url, + openai_api_key: secret(studio.openai_api_key), + }), + mail: capability(mail.enabled, { + admin_email: mail.admin_email, + sender_name: mail.sender_name, + }), + analytics: capability(analytics.enabled, { + backend: analytics.backend, + vector_port: analytics.vector_port, + gcp_project_id: analytics.gcp_project_id, + gcp_project_number: analytics.gcp_project_number, + gcp_jwt_path: analytics.gcp_jwt_path, + }), + pooler: capability(pooler.enabled, { + pool_mode: pooler.pool_mode, + default_pool_size: pooler.default_pool_size, + max_client_conn: pooler.max_client_conn, + }), + }, + listeners: { + api: apiListener(document, config), + database: listenerFromSection(document, "db", "port"), + pooler: nestedListener(document, "db", "pooler", "port"), + studio: listenerFromSection(document, "studio", "port"), + mailUi: listenerFromSection(document, "local_smtp", "port"), + smtp: listenerFromSection(document, "local_smtp", "smtp_port"), + pop3: listenerFromSection(document, "local_smtp", "pop3_port"), + functionsInspector: listenerFromSection(document, "edge_runtime", "inspector_port"), + }, + security: { + jwt: { + ...(auth.jwt_issuer === undefined ? {} : { issuer: auth.jwt_issuer }), + ...(auth.signing_keys_path !== undefined + ? { signing: { kind: "jwks-file", path: legacyStackProjectPath(auth.signing_keys_path) } } + : secret(auth.jwt_secret) === undefined + ? {} + : { signing: { kind: "symmetric", secret: secret(auth.jwt_secret) } }), + }, + }, + }; +}; + +const legacyConfigValidationError = ( + path: Path.Path, + projectRoot: string, + config: CliConfig, + projectEnvValues: Readonly>, +): string | undefined => { + const figma = config.auth.external.figma; + if (figma?.enabled === true) + return "auth.external.figma is enabled but unsupported by the experimental stack"; + if (config.edge_runtime.enabled === false) return undefined; + for (const [name, functionConfig] of Object.entries(config.functions)) { + if (functionConfig.enabled === false) continue; + for (const [field, value] of [ + ["import_map", functionConfig.import_map], + ["entrypoint", functionConfig.entrypoint], + ...functionConfig.static_files.map((path) => ["static_files", path] as const), + ] as const) { + if (typeof value !== "string") continue; + const pathError = legacyFunctionPathError(path, projectRoot, name, field, value); + if (pathError !== undefined) return pathError; + } + for (const value of Object.values(functionConfig.env)) { + if (typeof value !== "string") continue; + const match = /^env\(([A-Za-z_][A-Za-z0-9_]*)\)$/.exec(value); + const variable = match?.[1]; + if (variable !== undefined && projectEnvValues[variable] === undefined) + return `functions.${name}.env references an unset environment variable ${variable}`; + } + } + return undefined; +}; + +/** Loads and translates the effective project config for all experimental stack commands. */ +export const legacyLoadStackConfig = (projectRoot: string): LegacyStackConfigEffect => + Effect.gen(function* () { + const path = yield* Path.Path; + return yield* legacyLoadLocalProjectContext( + projectRoot, + (message) => new LegacyStackConfigError({ message }), + ).pipe( + Effect.flatMap((context) => + context.loaded === null + ? Effect.fail( + new LegacyStackConfigError({ + message: `No Supabase project configuration found in ${projectRoot}. Run supabase init first.`, + }), + ) + : legacyReadFunctionEnvironments( + projectRoot, + new Set( + Object.entries(context.config.functions) + .filter(([, functionConfig]) => functionConfig.enabled === false) + .map(([name]) => name), + ), + context.config.edge_runtime.enabled === false, + ).pipe( + Effect.mapError((cause) => new LegacyStackConfigError({ message: String(cause) })), + Effect.flatMap( + ( + environments: Readonly<{ + readonly shared: Readonly>>; + readonly functions: Readonly< + Record>>> + >; + }>, + ) => { + const validationError = legacyConfigValidationError( + path, + projectRoot, + context.config, + context.projectEnvValues, + ); + if (validationError !== undefined) + return Effect.fail(new LegacyStackConfigError({ message: validationError })); + const input = legacyConfigInput( + projectRoot, + path, + context.config, + context.loaded?.document, + context.projectEnvValues, + ); + if (input.capabilities.functions.enabled === false) return Effect.succeed(input); + const functionSettings = isRecord(input.capabilities.functions.settings) + ? input.capabilities.functions.settings + : {}; + const functions = isRecord(functionSettings.functions) + ? functionSettings.functions + : {}; + const allFunctions = { + ...Object.fromEntries( + Object.keys(environments.functions).map((name) => [ + name, + { env: environments.functions[name] }, + ]), + ), + ...functions, + }; + return Effect.succeed({ + ...input, + capabilities: { + ...input.capabilities, + functions: { + ...input.capabilities.functions, + settings: { + ...functionSettings, + edge_runtime: { + ...(isRecord(functionSettings.edge_runtime) + ? functionSettings.edge_runtime + : {}), + secrets: { + ...environments.shared, + ...(isRecord(functionSettings.edge_runtime) && + isRecord(functionSettings.edge_runtime.secrets) + ? functionSettings.edge_runtime.secrets + : {}), + }, + }, + functions: Object.fromEntries( + Object.entries(allFunctions).map(([name, value]) => [ + name, + { + ...(isRecord(value) ? value : {}), + env: { + ...environments.functions[name], + ...(isRecord(value) && isRecord(value.env) ? value.env : {}), + }, + }, + ]), + ), + }, + }, + }, + }); + }, + ), + Effect.flatMap((input) => + Schema.decodeUnknownEffect(StackConfigSchema)(withoutUndefined(input), { + onExcessProperty: "error", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStackConfigError({ + message: `invalid stack config: ${String(cause)}`, + }), + ), + ), + ), + ), + ), + ); + }); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts new file mode 100644 index 0000000000..9a4a4183e6 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -0,0 +1,26 @@ +import { Layer } from "effect"; +import { Command } from "effect/unstable/cli"; +import { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; +import { legacyExperimentalStackStartCommand } from "./start/start.command.ts"; +import { legacyExperimentalStackStopCommand } from "./stop/stop.command.ts"; +import { legacyExperimentalStackStatusCommand } from "./status/status.command.ts"; +import { legacyExperimentalStackListCommand } from "./list/list.command.ts"; +import { + legacyExperimentalStackApiLayer, + legacyExperimentalStackTargetResolverLayer, +} from "./stack.shared.ts"; + +export const legacyExperimentalStackCommand = Command.make("stack").pipe( + Command.withDescription("Manage an experimental managed local Supabase stack."), + Command.withShortDescription("Manage a managed local stack"), + Command.withSubcommands([ + legacyExperimentalStackStartCommand, + legacyExperimentalStackStopCommand, + legacyExperimentalStackStatusCommand, + legacyExperimentalStackListCommand, + ]), + Command.provide(legacyExperimentalStackTargetResolverLayer), + Command.provide(legacyExperimentalStackApiLayer), + Command.provide(legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer))), +); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts new file mode 100644 index 0000000000..3d42fefb7f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -0,0 +1,182 @@ +import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect"; +import { + createStack, + findStack, + inspectStack, + listStacks, + isStackId, + openStack, + type StackRuntimePreference, +} from "@supabase/stack/effect"; +import type { StackId } from "@supabase/stack"; +import { StackNotFoundError } from "@supabase/stack/effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** The target selected by the CLI adapter for one experimental stack command. */ +interface LegacyExperimentalStackTarget { + readonly projectRoot: string; + readonly id?: StackId; + readonly name?: string; + readonly runtime?: StackRuntimePreference; +} + +export class LegacyExperimentalStackTargetError extends Data.TaggedError( + "LegacyExperimentalStackTargetError", +)<{ + readonly message: string; + readonly reason: "flags" | "invalid-config"; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.reason === "flags" ? actionability.provideFlags : actionability.invalidConfig; + } +} + +/** + * Configuration and targeting are deliberately supplied by the CLI adapter. + * Keeping this boundary independent of command handlers lets the later stack + * commands reuse exactly the same project, name, id, and environment rules. + */ +interface LegacyExperimentalStackTargetResolverShape { + readonly resolve: (input: { + readonly projectRoot: string; + readonly name?: string; + readonly id?: string; + readonly runtime: "auto" | "docker" | "native"; + }) => Effect.Effect< + LegacyExperimentalStackTarget, + LegacyExperimentalStackTargetError, + LegacyExperimentalStackApi + >; +} + +export class LegacyExperimentalStackTargetResolver extends Context.Service< + LegacyExperimentalStackTargetResolver, + LegacyExperimentalStackTargetResolverShape +>()("supabase/experimental-stack/TargetResolver") {} + +export class LegacyExperimentalStackApi extends Context.Service< + LegacyExperimentalStackApi, + { + readonly createStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly findStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly listStacks: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly openStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + readonly inspectStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; + } +>()("supabase/experimental-stack/StackApi") {} + +export const legacyExperimentalStackApiLayer = Layer.effect( + LegacyExperimentalStackApi, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const childProcess = yield* ChildProcessSpawner.ChildProcessSpawner; + const provideServices = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess), + ); + return { + createStack: (...args: Parameters) => + provideServices(createStack(...args)), + findStack: (...args: Parameters) => provideServices(findStack(...args)), + listStacks: (...args: Parameters) => provideServices(listStacks(...args)), + openStack: (...args: Parameters) => provideServices(openStack(...args)), + inspectStack: (...args: Parameters) => + provideServices(inspectStack(...args)), + }; + }), +); + +/** Runtime configuration for the first stack command. Later commands reuse this layer. */ +export const legacyExperimentalStackTargetResolverLayer = Layer.succeed( + LegacyExperimentalStackTargetResolver, + { + resolve: (input) => + Effect.gen(function* () { + if (input.id !== undefined && !isStackId(input.id)) { + return yield* new LegacyExperimentalStackTargetError({ + message: "--stack-id must be a lowercase SHA-256 stack id", + reason: "flags", + }); + } + const id = input.id; + const stackApi = yield* LegacyExperimentalStackApi; + const inspection = + id === undefined + ? undefined + : yield* stackApi.inspectStack(id).pipe( + Effect.mapError( + (error) => + new LegacyExperimentalStackTargetError({ + message: `Unable to inspect stack ${id}: ${error.message}`, + reason: error instanceof StackNotFoundError ? "flags" : "invalid-config", + cause: error, + }), + ), + ); + const projectRoot = inspection?.descriptor.projectRoot ?? input.projectRoot; + const requestedRuntime = + input.runtime === "auto" + ? undefined + : input.runtime === "native" + ? { kind: "native" as const } + : { kind: "container" as const, engine: "docker" as const }; + if ( + inspection !== undefined && + requestedRuntime !== undefined && + (inspection.descriptor.runtime.kind !== requestedRuntime.kind || + (requestedRuntime.kind === "container" && + inspection.descriptor.runtime.kind === "container" && + inspection.descriptor.runtime.engine !== requestedRuntime.engine)) + ) { + return yield* new LegacyExperimentalStackTargetError({ + message: "The requested runtime does not match the existing stack", + reason: "flags", + }); + } + return { + projectRoot, + ...(id === undefined ? {} : { id }), + ...(input.name === undefined ? {} : { name: input.name }), + ...(id === undefined && requestedRuntime !== undefined + ? { runtime: requestedRuntime } + : {}), + }; + }), + }, +); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md new file mode 100644 index 0000000000..745a94675f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -0,0 +1,30 @@ +# `supabase experimental stack start` + +This command creates or resumes the managed stack identified by the current +project and optional `--stack`, or opens an existing stack with `--stack-id`. +The `@supabase/stack` Effect API owns persistent state, the detached +Supervisor, runtime resources, readiness, and cleanup. The CLI only resolves +the project configuration and renders the resulting status. + +`SUPABASE_HOME` controls the package's durable stack state through its normal +runtime composition boundary. The stack owner is deliberately detached from +the command waiter, so returning from a successful start leaves the stack +running for later commands. An interrupted start is handled by the package's +owner cleanup contract. + +Text output includes the stack id, lifecycle, endpoints, and dormant +capabilities. Structured output includes the same status fields. The command reads configured +credentials and function/provider secrets to pass them to the stack runtime, but +never emits those values. + +`--stack` and `--stack-id` are mutually exclusive. `--runtime auto` uses the +package default; `docker` selects the Docker container runtime; `native` +selects the native runtime. `--preparation` controls background versus +on-demand artifact preparation, and `--eager` requests enabled capabilities be +activated before the command returns. + +The command owns only the start request. Once the package reports readiness, +the detached stack owner remains alive after the CLI process exits. If the CLI +caller is interrupted while waiting, the package's owner lifecycle decides +whether the start can complete or must clean up; the CLI does not call stop or +destroy as a cancellation handler. diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts new file mode 100644 index 0000000000..9d8cf59924 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -0,0 +1,48 @@ +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 { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackStart } from "./start.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Open an existing stack by id."), + Flag.optional, + ), + runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( + Flag.withDescription("Runtime to use for a new stack."), + Flag.withDefault("auto" as const), + ), + preparation: Flag.choice("preparation", ["background", "on-demand"] as const).pipe( + Flag.withDescription("Artifact preparation policy."), + Flag.withDefault("background" as const), + ), + eager: Flag.boolean("eager").pipe( + Flag.withDescription("Activate all enabled capabilities before returning."), + Flag.withDefault(false), + ), +} as const; + +export type LegacyExperimentalStackStartFlags = CliCommand.Command.Config.Infer; + +export const legacyExperimentalStackStartCommand = Command.make("start", config).pipe( + Command.withDescription("Create or resume a managed local Supabase stack."), + Command.withShortDescription("Start a managed local stack"), + Command.withExamples([ + { + command: "supabase experimental stack start", + description: "Start the current project stack", + }, + { + command: "supabase experimental stack start --stack feature-a --runtime docker", + description: "Start a named Docker stack", + }, + ]), + Command.withHandler((flags) => + legacyExperimentalStackStart(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts new file mode 100644 index 0000000000..dd0d3b8c17 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -0,0 +1,164 @@ +// This is a compiled CLI boundary test. It deliberately starts the native owner through the +// built binary, then uses the package's public Promise API only to inspect and destroy that exact +// stack after the CLI process has exited. +// oxlint-disable-next-line effecttsgo/process-env -- package runtime composition is scoped below. + +// oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs +import { access, mkdir, mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs +import { execFile as execFileCallback } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const START_TIMEOUT_MS = 15 * 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const execFile = promisify(execFileCallback); +const nativeSupported = + (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || + (process.platform === "darwin" && process.arch === "arm64"); + +const minimalConfig = `project_id = "compiled-stack-start-e2e" + +[api] +enabled = false + +[auth] +enabled = false + +[db.pooler] +enabled = false + +[edge_runtime] +enabled = false + +[realtime] +enabled = false + +[storage] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[local_smtp] +enabled = false +`; + +// oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary +async function inspectAndDestroyStack(home: string, stackId: string) { + const script = ` + import { inspectStack, openStack, StackIdSchema } from "@supabase/stack"; + const id = StackIdSchema.make(process.argv.at(-1)); + const inspection = await inspectStack(id); + const stack = await openStack(id); + const status = await stack.status(); + await stack.destroy(); + console.log(JSON.stringify({ + owner: inspection.owner, + projectRoot: inspection.descriptor.projectRoot, + runtime: status.runtime, + lifecycle: status.lifecycle, + database: status.capabilities.find(({ name }) => name === "database")?.state, + })); + `; + const result = await execFile("bun", ["--bun", "-e", script, stackId], { + cwd: process.cwd(), + env: { + ...process.env, + SUPABASE_HOME: home, + SUPABASE_NO_KEYRING: "1", + SUPABASE_TELEMETRY_DISABLED: "1", + }, + timeout: CLEANUP_TIMEOUT_MS, + }); + const line = result.stdout.trim().split("\n").at(-1); + if (line === undefined) throw new Error(`Stack probe returned no result:\n${result.stderr}`); + return JSON.parse(line) as { + readonly owner: string; + readonly projectRoot: string; + readonly runtime: { readonly kind: string }; + readonly lifecycle: string; + readonly database: string | undefined; + }; +} + +describe("experimental stack start (compiled e2e)", () => { + let home: ReturnType | undefined; + let projectDir: string | undefined; + let stackId: string | undefined; + let stackDestroyed = false; + + // oxlint-disable-next-line effecttsgo/async-function -- Vitest cleanup callback is a Promise boundary + afterEach(async () => { + let cleanupComplete = stackDestroyed; + if (!cleanupComplete && home !== undefined) { + const candidates = await readdir(path.join(home.dir, "managed", "stacks")).catch( + () => [] as Array, + ); + const discovered = candidates.filter((entry) => /^[0-9a-f]{64}$/u.test(entry)); + const ownedId = stackId ?? (discovered.length === 1 ? discovered[0] : undefined); + if (ownedId !== undefined) { + await inspectAndDestroyStack(home.dir, ownedId); + cleanupComplete = true; + } else if (discovered.length > 1) { + throw new Error(`Could not identify one owned stack for cleanup: ${discovered.join(", ")}`); + } else { + cleanupComplete = true; + } + } + if (!cleanupComplete) return; + if (projectDir !== undefined) await rm(projectDir, { recursive: true, force: true }); + home?.[Symbol.dispose](); + home = undefined; + projectDir = undefined; + stackId = undefined; + stackDestroyed = false; + }, CLEANUP_TIMEOUT_MS); + + test.skipIf(!nativeSupported)( + "starts a detached native owner and leaves a ready database after CLI exit", + { timeout: START_TIMEOUT_MS + CLEANUP_TIMEOUT_MS }, + // oxlint-disable-next-line effecttsgo/async-function -- compiled CLI e2e callback is a Promise boundary + async () => { + home = makeTempHome(); + projectDir = await mkdtemp(path.join("/tmp", "supabase-compiled-stack-start-e2e-")); + await mkdir(path.join(projectDir, "supabase"), { recursive: true }); + await writeFile(path.join(projectDir, "supabase", "config.toml"), minimalConfig); + + const result = await runSupabase( + ["experimental", "stack", "start", "--runtime", "native", "--eager"], + { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }, + ); + expect(result.exitCode, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + const idMatch = result.stdout.match(/Stack ([0-9a-f]{64})/u); + expect(idMatch, `stdout:\n${result.stdout}`).not.toBeNull(); + stackId = idMatch?.[1]; + const idText = stackId; + const homeDir = home; + const projectRoot = projectDir; + if (idText === undefined || homeDir === undefined || projectRoot === undefined) + throw new Error("compiled start did not return a stack id"); + + const observed = await inspectAndDestroyStack(homeDir.dir, idText); + stackDestroyed = true; + expect(observed.owner).toBe("running"); + expect(observed.projectRoot).toBe(await realpath(projectRoot)); + expect(observed.runtime).toEqual({ kind: "native" }); + expect(observed.lifecycle).toBe("running"); + expect(observed.database).toBe("ready"); + + await expect(access(path.join(homeDir.dir, "managed", "stacks", idText))).rejects.toThrow(); + }, + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/start/start.errors.ts b/apps/cli/src/commands/experimental/stack/start/start.errors.ts new file mode 100644 index 0000000000..e4b844aff1 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.errors.ts @@ -0,0 +1,52 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackTargetFlagsError extends Data.TaggedError( + "LegacyExperimentalStackTargetFlagsError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class LegacyExperimentalStackStartError extends Data.TaggedError( + "LegacyExperimentalStackStartError", +)<{ + readonly reason: + | "invalid-config" + | "flags" + | "runtime" + | "registry" + | "port" + | "artifact" + | "lifecycle" + | "unknown"; + readonly message: string; + readonly detail?: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "invalid-config": + return actionability.invalidConfig; + case "flags": + return actionability.provideFlags; + case "runtime": + return actionability.dockerNotRunning; + case "registry": + case "artifact": + return actionability.externalNetwork; + case "port": + return actionability.invalidConfig; + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts new file mode 100644 index 0000000000..008f81c047 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -0,0 +1,241 @@ +import { Effect, Match, Option } from "effect"; +import { + isStackError, + type StackStatus, + type StackRuntimePreference, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; +import { + LegacyExperimentalStackApi, + LegacyExperimentalStackTargetResolver, +} from "../stack.shared.ts"; +import { legacyLoadStackConfig } from "../stack-config.ts"; +import type { LegacyExperimentalStackStartFlags } from "./start.command.ts"; +import { + LegacyExperimentalStackStartError, + LegacyExperimentalStackTargetFlagsError, +} from "./start.errors.ts"; + +const statusPayload = (status: StackStatus) => ({ + id: status.id, + lifecycle: status.lifecycle, + desired_lifecycle: status.desiredLifecycle, + runtime: status.runtime, + endpoints: status.endpoints, + versions: status.versions, + capabilities: status.capabilities, + artifacts: status.artifacts, +}); + +const renderStatus = (status: StackStatus): string => { + const lines = [ + `Stack ${status.id}`, + `Runtime: ${status.runtime.kind}`, + `Lifecycle: ${status.lifecycle}`, + ]; + const endpoints = Object.entries(status.endpoints); + if (endpoints.length > 0) { + lines.push("Endpoints:"); + for (const [name, endpoint] of endpoints) { + if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); + } + } + const dormant = status.capabilities.filter((capability) => capability.state === "dormant"); + if (dormant.length > 0) + lines.push(`Dormant capabilities: ${dormant.map(({ name }) => name).join(", ")}`); + return `${lines.join("\n")}\n`; +}; + +const eagerlyActivate = < + T extends { readonly enabled?: boolean; readonly activation?: "eager" | "lazy" }, +>( + value: T, +): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); + +export const legacyValidateExperimentalStackStartTarget = ( + flags: Pick, +) => + Option.isSome(flags.stack) && Option.isSome(flags.stackId) + ? Effect.fail( + new LegacyExperimentalStackTargetFlagsError({ + message: "--stack and --stack-id cannot be used together", + }), + ) + : Effect.void; + +export const legacyExperimentalStackStart = Effect.fn("legacy.experimental.stack.start")(function* ( + flags: LegacyExperimentalStackStartFlags, +) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const resolver = yield* LegacyExperimentalStackTargetResolver; + const stackApi = yield* LegacyExperimentalStackApi; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackStartError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json or --output-format text.", + }); + yield* legacyValidateExperimentalStackStartTarget(flags); + + const target = yield* resolver.resolve({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), + runtime: flags.runtime, + }); + const config = yield* legacyLoadStackConfig(target.projectRoot).pipe( + Effect.mapError( + (error) => + new LegacyExperimentalStackStartError({ + reason: "invalid-config", + message: error.message, + cause: error, + }), + ), + ); + const startConfig = flags.eager + ? { + ...config, + capabilities: { + ...config.capabilities, + ...(config.capabilities?.rest === undefined + ? {} + : { rest: eagerlyActivate(config.capabilities.rest) }), + ...(config.capabilities?.auth === undefined + ? {} + : { auth: eagerlyActivate(config.capabilities.auth) }), + ...(config.capabilities?.realtime === undefined + ? {} + : { realtime: eagerlyActivate(config.capabilities.realtime) }), + ...(config.capabilities?.storage === undefined + ? {} + : { storage: eagerlyActivate(config.capabilities.storage) }), + ...(config.capabilities?.functions === undefined + ? {} + : { functions: eagerlyActivate(config.capabilities.functions) }), + ...(config.capabilities?.studio === undefined + ? {} + : { studio: eagerlyActivate(config.capabilities.studio) }), + ...(config.capabilities?.mail === undefined + ? {} + : { mail: eagerlyActivate(config.capabilities.mail) }), + ...(config.capabilities?.analytics === undefined + ? {} + : { analytics: eagerlyActivate(config.capabilities.analytics) }), + ...(config.capabilities?.pooler === undefined + ? {} + : { pooler: eagerlyActivate(config.capabilities.pooler) }), + }, + preparation: flags.preparation, + } + : { ...config, preparation: flags.preparation }; + const runtime: StackRuntimePreference | undefined = target.runtime; + // The package's public Effect API reads SUPABASE_HOME only at its runtime + // composition boundary and launches the detached owner through the compiled + // dispatch sentinel. The CLI adapter resolves the target and config; it does + // not recreate package lifecycle or runtime ownership here. + const stack = + target.id !== undefined + ? yield* stackApi.openStack(target.id).pipe(Effect.mapError(legacyStackStartError)) + : yield* stackApi + .createStack({ + projectRoot: target.projectRoot, + ...(target.name === undefined ? {} : { name: target.name }), + ...(runtime === undefined ? {} : { runtime }), + }) + .pipe(Effect.mapError(legacyStackStartError)); + const starting = yield* output.task("Starting local Supabase stack..."); + const status = yield* stack.start({ config: startConfig }).pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.tap(() => starting.succeed("Stack is ready.")), + Effect.mapError(legacyStackStartError), + ); + if (output.format === "text") { + yield* output.raw(renderStatus(status)); + } else { + yield* output.success("", statusPayload(status)); + } + return status; +}); + +const legacyStackStartError = (error: unknown) => { + const stackError = isStackError(error) ? error : undefined; + const message = stackError === undefined ? String(error) : stackError.message; + const classification = + stackError === undefined + ? { reason: "unknown" as const } + : Match.value(stackError).pipe( + Match.tag("ContainerEngineError", () => ({ + reason: "runtime" as const, + suggestion: "Ensure the selected container engine is running and retry the command.", + })), + Match.tag("ContainerPullError", () => ({ + reason: "registry" as const, + suggestion: + "Check registry connectivity and image availability, then retry the command.", + })), + Match.tag("PortUnavailableError", "PortAllocationError", () => ({ + reason: "port" as const, + suggestion: + "Free the conflicting port or update the local stack port configuration, then retry.", + })), + Match.tag("StackPreparationError", "ArtifactIntegrityError", () => ({ + reason: "artifact" as const, + suggestion: "Retry the stack start with --debug if the artifact cannot be prepared.", + })), + Match.tag("StackRuntimeMismatchError", () => ({ + reason: "flags" as const, + suggestion: + "Omit --runtime to reuse the existing runtime, or choose a different --stack name.", + })), + Match.tag( + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "InvalidStackIdentityError", + "InvalidProjectRootError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + () => ({ reason: "invalid-config" as const }), + ), + Match.tag("StackStateInvalidError", () => ({ + reason: "invalid-config" as const, + suggestion: + "Inspect the reported state error and restore a valid state record before retrying.", + })), + Match.tag("StackStateFormatUnsupportedError", () => ({ + reason: "invalid-config" as const, + suggestion: "Use a CLI version compatible with the persisted stack state.", + })), + Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackUpgradeRequiredError", + () => ({ + reason: "lifecycle" as const, + suggestion: "Stop the stack before starting it again.", + }), + ), + Match.tag("StackRuntimeError", () => ({ + reason: "unknown" as const, + suggestion: "Retry the stack start with --debug and inspect the runtime diagnostics.", + })), + Match.tag("StackCleanupError", () => ({ + reason: "unknown" as const, + suggestion: "Retry the stack start with --debug and inspect cleanup diagnostics.", + })), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new LegacyExperimentalStackStartError({ + ...classification, + message, + ...("suggestion" in classification ? { suggestion: classification.suggestion } : {}), + cause: error, + }); +}; diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts new file mode 100644 index 0000000000..f53f9a5e22 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -0,0 +1,593 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + ContainerEngineError, + ContainerPullError, + StackIdSchema, + StackRuntimeError, + StackStateInvalidError, +} from "@supabase/stack/effect"; +import type { EffectStack, StackStartError, StackStatus } from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + legacyExperimentalStackApiLayer, + LegacyExperimentalStackTargetError, + LegacyExperimentalStackTargetResolver, + legacyExperimentalStackTargetResolverLayer, + LegacyExperimentalStackApi, +} from "../stack.shared.ts"; +import { + legacyExperimentalStackStart, + legacyValidateExperimentalStackStartTarget, +} from "./start.handler.ts"; +import { LegacyExperimentalStackStartError } from "./start.errors.ts"; +import { legacyExperimentalStackStartCommand } from "./start.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +const project = (): string => { + const root = mkdtempSync(join(tmpdir(), "supabase-experimental-stack-start-")); + mkdirSync(join(root, "supabase"), { recursive: true }); + writeFileSync(join(root, "supabase", "config.toml"), 'project_id = "start-test"\n'); + return root; +}; + +const resolverLayer = legacyExperimentalStackTargetResolverLayer.pipe( + Layer.provideMerge(legacyExperimentalStackApiLayer), + Layer.provide(BunServices.layer), +); + +const status = (id: string, runtime: "native" | "container" = "native") => + ({ + id: StackIdSchema.make(id), + lifecycle: "running", + desiredLifecycle: "running", + runtime: runtime === "native" ? { kind: "native" } : { kind: "container", engine: "docker" }, + endpoints: {}, + versions: {}, + capabilities: ( + [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ] as const + ).map((name) => ({ + name, + activation: name === "database" ? ("eager" as const) : ("lazy" as const), + state: "ready" as const, + })), + artifacts: [], + }) satisfies StackStatus; + +function fakeStack( + id: string, + start: (config: unknown) => Effect.Effect, +) { + return { + id: StackIdSchema.make(id), + status: () => Effect.succeed(status(id)), + credentials: () => Effect.die("credentials not used in start test"), + prepare: () => Effect.die("prepare not used in start test"), + start, + stop: () => Effect.void, + destroy: () => Effect.die("destroy not used in start test"), + logs: () => Effect.die("logs not used in start test"), + followLogs: () => Stream.empty, + } satisfies EffectStack; +} + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + runtime: "auto" as const, + preparation: "background" as const, + eager: false, + ...overrides, +}); + +function handlerLayer(opts: { + root: string; + target: { projectRoot: string; name?: string; id?: string; runtime?: { kind: "native" } }; + stack: EffectStack; + onCreate?: (options: unknown) => void; + onOpen?: () => void; +}) { + const out = mockOutput(); + const { id, ...targetWithoutId } = opts.target; + const targetLayer = Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => + Effect.succeed( + id === undefined ? targetWithoutId : { ...targetWithoutId, id: StackIdSchema.make(id) }, + ), + }); + const apiLayer = Layer.succeed(LegacyExperimentalStackApi, { + createStack: (options) => { + opts.onCreate?.(options); + return Effect.succeed(opts.stack); + }, + findStack: () => Effect.succeed(Option.none()), + listStacks: () => Effect.succeed([]), + openStack: () => { + opts.onOpen?.(); + return Effect.succeed(opts.stack); + }, + inspectStack: () => Effect.die("inspect not used in handler test"), + }); + return { + out, + layer: Layer.mergeAll( + out.layer, + mockLegacyCliSettings({ workdir: opts.root }), + targetLayer, + apiLayer, + BunServices.layer, + ), + }; +} + +describe("experimental stack start targeting", () => { + it.effect("resolves the current project target", () => { + const root = project(); + return Effect.gen(function* () { + const resolver = yield* LegacyExperimentalStackTargetResolver; + const target = yield* resolver.resolve({ + projectRoot: root, + runtime: "auto", + }); + expect(target.projectRoot).toBe(root); + expect(target.id).toBeUndefined(); + expect(target.name).toBeUndefined(); + expect(target.runtime).toBeUndefined(); + }).pipe( + Effect.provide(resolverLayer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("keeps a named native stack target distinct", () => { + const root = project(); + return Effect.gen(function* () { + const resolver = yield* LegacyExperimentalStackTargetResolver; + const target = yield* resolver.resolve({ + projectRoot: root, + name: "feature-a", + runtime: "native", + }); + expect(target.name).toBe("feature-a"); + expect(target.runtime).toEqual({ kind: "native" }); + }).pipe( + Effect.provide(resolverLayer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects a malformed stack id before loading project configuration", () => + Effect.gen(function* () { + const resolver = yield* LegacyExperimentalStackTargetResolver; + const failure = yield* resolver + .resolve({ projectRoot: "/does/not/exist", id: "invalid", runtime: "auto" }) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackTargetError); + expect(failure.message).toContain("lowercase SHA-256"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + }).pipe(Effect.provide(resolverLayer)), + ); + + it.effect("classifies an existing stack runtime mismatch as provided flags", () => { + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), + listStacks: () => Effect.succeed([]), + openStack: () => Effect.die("unused"), + inspectStack: () => + Effect.succeed({ + descriptor: { + id: StackIdSchema.make("b".repeat(64)), + projectRoot: "/tmp/existing-stack", + name: "existing", + branchContext: "ordinary-workspace", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }, + owner: "running", + }), + }); + return Effect.gen(function* () { + const resolver = yield* LegacyExperimentalStackTargetResolver; + const failure = yield* resolver + .resolve({ projectRoot: "/tmp/project", id: "b".repeat(64), runtime: "docker" }) + .pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + }).pipe( + Effect.provide( + legacyExperimentalStackTargetResolverLayer.pipe( + Layer.provideMerge(api), + Layer.provide(BunServices.layer), + ), + ), + ); + }); + + it.effect("rejects mutually exclusive stack targets", () => + legacyValidateExperimentalStackStartTarget({ + stack: Option.some("feature-a"), + stackId: Option.some("a".repeat(64)), + }).pipe( + Effect.flip, + Effect.tap((failure) => + Effect.sync(() => expect(failure.message).toContain("cannot be used together")), + ), + ), + ); + + it.live("creates a named native stack with eager on-demand configuration", () => { + const root = project(); + let createOptions: unknown; + let startConfig: unknown; + const stack = fakeStack("a".repeat(64), (config) => { + startConfig = config; + return Effect.succeed(status("a".repeat(64))); + }); + const setup = handlerLayer({ + root, + target: { projectRoot: root, name: "feature-a", runtime: { kind: "native" } }, + stack, + onCreate: (options) => { + createOptions = options; + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart( + flags({ + stack: Option.some("feature-a"), + runtime: "native", + preparation: "on-demand", + eager: true, + }), + ); + expect(createOptions).toEqual({ + projectRoot: root, + name: "feature-a", + runtime: { kind: "native" }, + }); + expect(startConfig).toMatchObject({ config: { preparation: "on-demand" } }); + expect(startConfig).toMatchObject({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); + expect(setup.out.stdoutText).toContain("Stack"); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("opens an addressed existing stack using its own project root", () => { + const settingsRoot = project(); + const targetRoot = project(); + writeFileSync( + join(targetRoot, "supabase", "config.toml"), + 'project_id = "target"\n[api]\nport = 55421\n', + ); + let opened = false; + let startConfig: unknown; + const stack = fakeStack("b".repeat(64), (config) => { + startConfig = config; + return Effect.succeed(status("b".repeat(64))); + }); + const setup = handlerLayer({ + root: settingsRoot, + target: { projectRoot: targetRoot, id: "b".repeat(64) }, + stack, + onOpen: () => { + opened = true; + }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart(flags({ stackId: Option.some("b".repeat(64)) })); + expect(opened).toBe(true); + expect(startConfig).toMatchObject({ config: { listeners: { api: { port: 55421 } } } }); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring( + Effect.sync(() => { + rmSync(settingsRoot, { recursive: true, force: true }); + rmSync(targetRoot, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live("reports a typed runtime failure without success output or cleanup calls", () => { + const root = project(); + let stopped = false; + let destroyed = false; + const stack = { + ...fakeStack("c".repeat(64), () => + Effect.fail(new ContainerEngineError({ message: "Docker is unavailable" })), + ), + stop: () => { + stopped = true; + return Effect.void; + }, + destroy: () => + Effect.sync(() => { + destroyed = true; + }), + } satisfies EffectStack; + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("runtime"); + expect(failure.suggestion).toContain("container engine"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.dockerNotRunning); + } + expect(stopped).toBe(false); + expect(destroyed).toBe(false); + expect(setup.out.messages.filter((message) => message.type === "success")).toHaveLength(0); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("does not clean up a stack after a successful start", () => { + const root = project(); + let stopped = false; + let destroyed = false; + const stack = { + ...fakeStack("f".repeat(64), () => Effect.succeed(status("f".repeat(64)))), + stop: () => { + stopped = true; + return Effect.void; + }, + destroy: () => + Effect.sync(() => { + destroyed = true; + }), + } satisfies EffectStack; + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStart(flags()); + expect(stopped).toBe(false); + expect(destroyed).toBe(false); + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("classifies registry pull failures separately from engine failures", () => { + const root = project(); + const stack = fakeStack("8".repeat(64), () => + Effect.fail( + new ContainerPullError({ + message: "registry refused the workload image", + image: "example.test/workload:dev", + }), + ), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("registry"); + expect(failure.suggestion).toContain("registry connectivity"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.externalNetwork); + } + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("reports runtime start failures with operational guidance", () => { + const root = project(); + const stack = fakeStack("b".repeat(64), () => + Effect.fail(new StackRuntimeError({ message: "runtime crashed" })), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("unknown"); + expect(failure.suggestion).toContain("runtime diagnostics"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.unknown); + } + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("classifies persisted state failures with recovery guidance", () => { + const root = project(); + const stack = fakeStack("c".repeat(64), () => + Effect.fail(new StackStateInvalidError({ message: "persisted state is invalid" })), + ); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("invalid-config"); + expect(failure.suggestion).toContain("restore a valid state record"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("classifies a missing project configuration as invalid config", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-experimental-stack-start-invalid-")); + const stack = fakeStack("9".repeat(64), () => Effect.succeed(status("9".repeat(64)))); + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("invalid-config"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + }).pipe( + Effect.provide(setup.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("keeps ownership with the package when the CLI caller is interrupted", () => { + const root = project(); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + let stopped = false; + let destroyed = false; + const stack = { + ...fakeStack("7".repeat(64), () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never.pipe(Effect.as(status("7".repeat(64)))); + }), + ), + stop: () => { + stopped = true; + return Effect.void; + }, + destroy: () => + Effect.sync(() => { + destroyed = true; + }), + } satisfies EffectStack; + const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); + const fiber = yield* Effect.forkChild( + Effect.provide(legacyExperimentalStackStart(flags()), setup.layer), + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(stopped).toBe(false); + expect(destroyed).toBe(false); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); + }); + + it.live("rejects invalid target flags before resolving or mutating a stack", () => { + const root = project(); + let resolved = false; + let created = false; + const setup = handlerLayer({ + root, + target: { projectRoot: root }, + stack: fakeStack("d".repeat(64), () => Effect.succeed(status("d".repeat(64)))), + }); + const layer = Layer.mergeAll( + setup.out.layer, + mockLegacyCliSettings({ workdir: root }), + Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => { + resolved = true; + return Effect.die("resolver should not run"); + }, + }), + Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => { + created = true; + return Effect.die("create should not run"); + }, + findStack: () => Effect.succeed(Option.none()), + listStacks: () => Effect.succeed([]), + openStack: () => Effect.die("open should not run"), + inspectStack: () => Effect.die("inspect should not run"), + }), + BunServices.layer, + ); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart( + flags({ stack: Option.some("feature"), stackId: Option.some("e".repeat(64)) }), + ).pipe(Effect.flip); + expect(failure.message).toContain("cannot be used together"); + expect(resolved).toBe(false); + expect(created).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); +}); + +describe("experimental stack start parser", () => { + it.live("parses --stack and --runtime through the command", () => { + let parsed: { stack: Option.Option; runtime: string } | undefined; + const command = legacyExperimentalStackStartCommand.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed = { stack: flags.stack, runtime: flags.runtime }; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--stack", + "feature-a", + "--runtime", + "native", + ]); + expect(parsed?.stack).toEqual(Option.some("feature-a")); + expect(parsed?.runtime).toBe("native"); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.live("rejects a root legacy output value before resolving the stack target", () => { + const root = project(); + let resolved = false; + const setup = handlerLayer({ + root, + target: { projectRoot: root }, + stack: fakeStack("a".repeat(64), () => Effect.succeed(status("a".repeat(64)))), + }); + const target = Layer.succeed(LegacyExperimentalStackTargetResolver, { + resolve: () => + Effect.sync(() => { + resolved = true; + return { projectRoot: root }; + }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStart(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); + if (failure instanceof LegacyExperimentalStackStartError) { + expect(failure.reason).toBe("flags"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + } + expect(resolved).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll(setup.layer, target, Layer.succeed(LegacyOutputFlag, Option.some("json"))), + ), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..e0581c4206 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -0,0 +1,23 @@ +# `supabase experimental stack status` + +Reports the persisted identity and current owner state of a managed local stack. +The command is read-only: it never creates, starts, prepares, stops, destroys, or +opens a stack handle. + +Target selection accepts the current project, `--stack `, or +`--stack-id `. `--stack` and `--stack-id` are mutually exclusive. An explicit +legacy `-o/--output` flag is rejected; use `--output-format json` for structured +output. + +When the project configuration can be loaded, status includes redacted config +drift paths. Missing or invalid configuration is reported as a warning while the +persisted stack inspection remains available. Drift output contains statuses and +paths only; secret values are never emitted. + +Text output includes identity, runtime, owner, lifecycle, readiness, endpoints, +and config drift. JSON output contains the same fields under `identity`, with +`config_drift` and a warning message in `config_drift` when configuration could +not be loaded. Drift compares the persisted effective stack definition with the +configuration-derived candidate, so explicit start policies such as `--eager` +or `--preparation on-demand` remain visible as intentional policy drift on a +later status check. diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts new file mode 100644 index 0000000000..8188e516f3 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -0,0 +1,26 @@ +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 { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackStatus } from "./status.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe(Flag.withDescription("Inspect a named stack."), Flag.optional), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Inspect an existing stack by id."), + Flag.optional, + ), +} as const; + +export type LegacyExperimentalStackStatusFlags = CliCommand.Command.Config.Infer; + +export const legacyExperimentalStackStatusCommand = Command.make("status", config).pipe( + Command.withDescription("Show the state of a managed local Supabase stack."), + Command.withShortDescription("Show stack status"), + Command.withHandler((flags) => + legacyExperimentalStackStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/status/status.errors.ts b/apps/cli/src/commands/experimental/stack/status/status.errors.ts new file mode 100644 index 0000000000..38b68df482 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -0,0 +1,20 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackStatusError extends Data.TaggedError( + "LegacyExperimentalStackStatusError", +)<{ + readonly message: string; + readonly reason: "flags" | "not-found" | "invalid-config" | "runtime"; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "flags" || this.reason === "not-found") return actionability.provideFlags; + return this.reason === "runtime" ? actionability.externalNetwork : actionability.invalidConfig; + } +} diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts new file mode 100644 index 0000000000..e93e1bfb57 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -0,0 +1,210 @@ +import { Effect, Match, Option } from "effect"; +import { + isStackError, + isStackId, + type StackError, + type StackInspection, + type StackStatus, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyLoadStackConfig } from "../stack-config.ts"; +import type { LegacyExperimentalStackStatusFlags } from "./status.command.ts"; +import { LegacyExperimentalStackStatusError } from "./status.errors.ts"; + +const validateFlags = (flags: LegacyExperimentalStackStatusFlags) => + Option.isSome(flags.stack) && Option.isSome(flags.stackId) + ? Effect.fail( + new LegacyExperimentalStackStatusError({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }), + ) + : Effect.void; + +const classifyStackError = (error: StackError) => + Match.value(error).pipe( + Match.tag("StackNotFoundError", () => ({ + reason: "not-found" as const, + suggestion: + "Choose an existing --stack-id, or run supabase experimental stack start without --stack-id to create one.", + })), + Match.tag( + "InvalidStackIdentityError", + "InvalidProjectRootError", + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackUpgradeRequiredError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + () => ({ reason: "invalid-config" as const }), + ), + Match.orElse(() => ({ + reason: "runtime" as const, + suggestion: "Retry the command and use --debug if the stack state remains unavailable.", + })), + ); + +const mapStackError = (error: StackError) => { + const classification = classifyStackError(error); + return new LegacyExperimentalStackStatusError({ + ...classification, + message: error.message, + cause: error, + }); +}; + +const catchStackError = (effect: Effect.Effect) => + effect.pipe(Effect.catchIf(isStackError, (error) => Effect.fail(mapStackError(error)))); + +const readiness = (status: StackStatus | undefined): string => { + if (status === undefined) return "unknown"; + if (status.lifecycle !== "running") return status.lifecycle; + if (status.capabilities.some(({ state }) => state === "failed")) return "degraded"; + if (status.capabilities.some(({ state }) => state === "starting")) return "starting"; + if (status.capabilities.some(({ state }) => state === "stopped")) return "stopped"; + if (status.capabilities.some(({ state }) => state === "dormant")) return "dormant"; + return "ready"; +}; + +const configUnavailableWarning = + "Project configuration could not be loaded; fix it before checking drift."; + +const payload = (inspection: StackInspection, configWarning?: string) => ({ + identity: { + id: inspection.descriptor.id, + name: inspection.descriptor.name, + project_root: inspection.descriptor.projectRoot, + branch_context: inspection.descriptor.branchContext, + }, + runtime: inspection.descriptor.runtime, + owner: inspection.owner, + lifecycle: inspection.status?.lifecycle ?? null, + desired_lifecycle: inspection.status?.desiredLifecycle ?? inspection.descriptor.desiredLifecycle, + readiness: readiness(inspection.status), + ...(inspection.status === undefined ? {} : { endpoints: inspection.status.endpoints }), + ...(inspection.status === undefined ? {} : { capabilities: inspection.status.capabilities }), + config_drift: + inspection.configDrift ?? + ({ + status: "unavailable", + message: configWarning ?? "Configuration was not compared.", + } as const), +}); + +const comparedInspection = ( + inspection: StackInspection, +): { + readonly inspection: StackInspection; + readonly warning?: string; +} => ({ inspection }); + +const render = (inspection: StackInspection, configWarning?: string): string => { + const descriptor = inspection.descriptor; + const lines = [ + `Stack ${descriptor.name} (${descriptor.id})`, + `Project: ${descriptor.projectRoot}`, + `Branch: ${descriptor.branchContext}`, + `Runtime: ${descriptor.runtime.kind}`, + `Owner: ${inspection.owner}`, + `Lifecycle: ${inspection.status?.lifecycle ?? "unavailable"}`, + `Desired lifecycle: ${inspection.status?.desiredLifecycle ?? descriptor.desiredLifecycle}`, + `Readiness: ${readiness(inspection.status)}`, + ]; + if (inspection.status !== undefined) { + const endpoints = Object.entries(inspection.status.endpoints); + if (endpoints.length > 0) { + lines.push("Endpoints:"); + for (const [name, endpoint] of endpoints) + if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); + } + } + const drift = inspection.configDrift; + lines.push(`Config drift: ${drift?.status ?? "unavailable"}`); + if (drift !== undefined) for (const path of drift.paths) lines.push(` ${path}`); + if (configWarning !== undefined) lines.push(`Config warning: ${configWarning}`); + return `${lines.join("\n")}\n`; +}; + +const findDescriptor = (projectRoot: string, name: string | undefined, id: string | undefined) => + Effect.gen(function* () { + const api = yield* LegacyExperimentalStackApi; + if (id !== undefined) { + if (!isStackId(id)) + return yield* new LegacyExperimentalStackStatusError({ + reason: "flags", + message: "--stack-id must be a lowercase SHA-256 stack id", + }); + const inspection = yield* catchStackError(api.inspectStack(id)); + return { + descriptor: inspection.descriptor, + id, + projectRoot: inspection.descriptor.projectRoot, + inspection, + }; + } + const found = yield* catchStackError( + api.findStack({ projectRoot, ...(name === undefined ? {} : { name }) }), + ); + if (Option.isNone(found)) + return yield* new LegacyExperimentalStackStatusError({ + reason: "not-found", + message: "No managed stack exists for the selected project.", + suggestion: "Run supabase experimental stack start first.", + }); + return { descriptor: found.value, id: found.value.id, projectRoot: found.value.projectRoot }; + }); + +export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stack.status")( + function* (flags: LegacyExperimentalStackStatusFlags) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackStatusError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json or --output-format text.", + }); + yield* validateFlags(flags); + const target = yield* findDescriptor( + settings.workdir, + Option.getOrUndefined(flags.stack), + Option.getOrUndefined(flags.stackId), + ); + const api = yield* LegacyExperimentalStackApi; + const loaded = yield* legacyLoadStackConfig(target.projectRoot).pipe( + Effect.map((config) => ({ config, warning: undefined })), + Effect.catchTag("LegacyStackConfigError", () => + Effect.succeed({ config: undefined, warning: configUnavailableWarning }), + ), + ); + const comparison = + loaded.config === undefined + ? target.inspection === undefined + ? yield* catchStackError(api.inspectStack(target.id)).pipe(Effect.map(comparedInspection)) + : { inspection: target.inspection } + : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( + Effect.map(comparedInspection), + Effect.catchTags({ + InvalidStackConfigError: () => + Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), + StackVersionUnsupportedError: () => + Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), + }), + catchStackError, + ); + const inspection = + comparison.inspection === undefined + ? (target.inspection ?? (yield* catchStackError(api.inspectStack(target.id)))) + : comparison.inspection; + const inspectionWarning = loaded.warning ?? comparison.warning; + if (output.format === "text") yield* output.raw(render(inspection, inspectionWarning)); + else yield* output.success("", payload(inspection, inspectionWarning)); + return inspection; + }, +); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts new file mode 100644 index 0000000000..2c20e0b093 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -0,0 +1,422 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + InvalidStackConfigError, + StackNotFoundError, + StackIdSchema, + StackStateFormatUnsupportedError, + type StackInspection, + type StackStatus, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackStatus } from "./status.handler.ts"; +import { legacyExperimentalStackStatusCommand } from "./status.command.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; + +const id = StackIdSchema.make("a".repeat(64)); +const capabilityNames = [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; +const flags = (stack = Option.none(), stackId = Option.none()) => ({ + stack, + stackId, +}); + +const makeStatus = ( + stackId: typeof id, + desiredLifecycle: StackStatus["desiredLifecycle"] = "running", +): StackStatus => ({ + id: stackId, + lifecycle: "running", + desiredLifecycle, + runtime: { kind: "native" }, + endpoints: { + api: { protocol: "http", address: "127.0.0.1", port: 54321, url: "http://127.0.0.1:54321" }, + }, + versions: {}, + capabilities: capabilityNames.map((name) => ({ + name, + activation: "lazy" as const, + state: "dormant" as const, + })), + artifacts: [], +}); + +const runStatus = (options: { + readonly config?: "valid" | "missing" | "invalid"; + readonly owner?: StackInspection["owner"]; + readonly status?: StackStatus; + readonly drift?: StackInspection["configDrift"]; + readonly flags?: ReturnType; + readonly compareFailure?: "typed" | "defect"; + readonly missingTarget?: boolean; + readonly legacyOutput?: boolean; + readonly outputFormat?: "text" | "json"; +}) => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-status-")); + const projectRoot = join(root, "project"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + if (options.config !== "missing") + writeFileSync( + join(projectRoot, "supabase", "config.toml"), + options.config === "invalid" + ? 'project_id = "ok"\n\n[auth]\njwt_secret = "FAKE_STATUS_SECRET\n' + : 'project_id = "status-test"\n\n[auth]\njwt_secret = "candidate-secret"\n', + ); + const descriptor = { + id, + projectRoot, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const inspection: StackInspection = { + descriptor, + owner: options.owner ?? "running", + ...(options.status === undefined ? {} : { status: options.status }), + ...(options.drift === undefined ? {} : { configDrift: options.drift }), + }; + const out = mockOutput({ format: options.outputFormat ?? "text" }); + const findInputs: unknown[] = []; + const inspectInputs: unknown[] = []; + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("create must not run"), + findStack: (input) => { + findInputs.push(input); + return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); + }, + listStacks: () => Effect.succeed([]), + openStack: () => Effect.die("open must not run"), + inspectStack: (_stackId, inspectOptions) => { + inspectInputs.push(inspectOptions); + if (options.missingTarget === true) + return Effect.fail(new StackNotFoundError({ message: "stack id not found" })); + if (inspectOptions?.config !== undefined && options.compareFailure === "typed") + return Effect.fail(new InvalidStackConfigError({ message: "candidate config is invalid" })); + if (inspectOptions?.config !== undefined && options.compareFailure === "defect") + return Effect.die("comparison defect"); + return Effect.succeed(inspection); + }, + }); + const layer = Layer.mergeAll( + out.layer, + api, + mockLegacyCliSettings({ workdir: root }), + ...(options.legacyOutput === true + ? [Layer.succeed(LegacyOutputFlag, Option.some("json"))] + : []), + BunServices.layer, + ); + const effect = legacyExperimentalStackStatus(options.flags ?? flags()).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + return { effect, out, findInputs, inspectInputs, projectRoot, root }; +}; + +describe("experimental stack status", () => { + it.effect( + "reports configured identity, dormant readiness, endpoint, drift, and target config", + () => { + const run = runStatus({ + status: makeStatus(id), + drift: { status: "changed", paths: ["definition.listeners.api.port"] }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.findInputs).toEqual([{ projectRoot: expect.any(String) }]); + expect(run.inspectInputs).toHaveLength(1); + expect(run.inspectInputs[0]).toEqual({ config: expect.any(Object) }); + expect(run.out.stdoutText).toContain("Runtime: native"); + expect(run.out.stdoutText).toContain("Readiness: dormant"); + expect(run.out.stdoutText).toContain("http://127.0.0.1:54321"); + expect(run.out.stdoutText).toContain("definition.listeners.api.port"); + expect(run.out.stdoutText).not.toContain("candidate-secret"); + }), + ), + ); + }, + ); + + it.effect("forwards a named stack target with the settings project root", () => { + const run = runStatus({ flags: flags(Option.some("feature-a")), status: makeStatus(id) }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.findInputs).toEqual([{ projectRoot: run.root, name: "feature-a" }]); + }), + ), + ); + }); + + it.effect("uses the persisted project root for an explicit id from another cwd", () => { + const run = runStatus({ flags: flags(Option.none(), Option.some(id)), status: makeStatus(id) }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.inspectInputs).toHaveLength(2); + expect(run.inspectInputs[1]).toEqual({ config: expect.any(Object) }); + }), + ), + ); + }); + + it.effect("reuses the explicit id inspection when config is missing", () => { + const run = runStatus({ + config: "missing", + flags: flags(Option.none(), Option.some(id)), + status: makeStatus(id), + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.inspectInputs).toHaveLength(1); + expect(run.inspectInputs[0]).toBeUndefined(); + }), + ), + ); + }); + + it.effect("reports stopped and unreachable stacks without claiming live readiness", () => { + const run = runStatus({ owner: "absent" }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.stdoutText).toContain("Lifecycle: unavailable"); + expect(run.out.stdoutText).toContain("Desired lifecycle: running"); + expect(run.out.stdoutText).toContain("Readiness: unknown"); + }), + ), + ); + }); + + it.effect("does not claim ready when a running stack has stopped capabilities", () => { + const base = makeStatus(id); + const run = runStatus({ + status: { + ...base, + capabilities: base.capabilities.map((capability, index) => + index === 0 ? { ...capability, state: "stopped" as const } : capability, + ), + }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => expect(run.out.stdoutText).toContain("Readiness: stopped")), + ), + ); + }); + + it.effect("emits the structured unavailable inspection for missing config", () => { + const run = runStatus({ + config: "missing", + flags: flags(Option.none(), Option.some(id)), + outputFormat: "json", + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.stdoutText).toBe(""); + const success = run.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + identity: { + id, + name: "feature-a", + project_root: run.projectRoot, + branch_context: "ordinary-workspace", + }, + owner: "running", + readiness: "unknown", + lifecycle: null, + desired_lifecycle: "running", + config_drift: { + status: "unavailable", + message: expect.any(String), + }, + }); + }), + ), + ); + }); + + it.effect("uses the live desired lifecycle consistently in text and JSON", () => { + const text = runStatus({ status: makeStatus(id, "stopped") }); + const json = runStatus({ status: makeStatus(id, "stopped"), outputFormat: "json" }); + return Effect.all([text.effect, json.effect]).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(text.out.stdoutText).toContain("Desired lifecycle: stopped"); + const success = json.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ desired_lifecycle: "stopped" }); + }), + ), + ); + }); + + it.effect("reports unavailable drift for missing or invalid config and keeps inspection", () => { + const missing = runStatus({ config: "missing", status: makeStatus(id) }); + const invalid = runStatus({ config: "invalid", status: makeStatus(id) }); + const invalidJson = runStatus({ + config: "invalid", + status: makeStatus(id), + outputFormat: "json", + }); + return Effect.all([missing.effect, invalid.effect, invalidJson.effect]).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(missing.out.stdoutText).toContain("Config drift: unavailable"); + expect(invalid.out.stdoutText).toContain("Config drift: unavailable"); + expect(invalid.out.stdoutText).not.toContain("FAKE_STATUS_SECRET"); + const success = invalidJson.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + config_drift: { + status: "unavailable", + message: "Project configuration could not be loaded; fix it before checking drift.", + }, + }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output + expect(JSON.stringify(success?.data)).not.toContain("FAKE_STATUS_SECRET"); + }), + ), + ); + }); + + it.effect("points an empty current context to the start command", () => { + const run = runStatus({ missingTarget: true }); + return run.effect.pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.suggestion).toBe("Run supabase experimental stack start first."); + expect(run.inspectInputs).toEqual([]); + }), + ), + ); + }); + + it.effect("gives actionable guidance when an explicit stack id is missing", () => { + const run = runStatus({ + flags: flags(Option.none(), Option.some(id)), + missingTarget: true, + }); + return run.effect.pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value.suggestion).toContain("existing --stack-id"); + expect(error.value[ErrorActionabilityId]).toEqual(actionability.provideFlags); + } + } + }), + ), + ); + }); + + it.effect("falls back only for typed comparison errors and preserves defects", () => { + const typed = runStatus({ compareFailure: "typed", status: makeStatus(id) }); + const defect = runStatus({ compareFailure: "defect", status: makeStatus(id) }); + return Effect.gen(function* () { + yield* typed.effect; + expect(typed.inspectInputs).toHaveLength(2); + expect(typed.out.stdoutText).toContain("Config drift: unavailable"); + const exit = yield* defect.effect.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(defect.inspectInputs).toHaveLength(1); + }); + }); + + it.effect("rejects invalid flags and legacy output before discovery", () => { + const invalid = runStatus({ flags: flags(Option.some("feature-a"), Option.some(id)) }); + const legacy = runStatus({ legacyOutput: true }); + return Effect.gen(function* () { + expect(Exit.isFailure(yield* invalid.effect.pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* legacy.effect.pipe(Effect.exit))).toBe(true); + expect(invalid.findInputs).toHaveLength(0); + expect(legacy.findInputs).toHaveLength(0); + }); + }); + + it.effect("does not retry discovery failures", () => { + const run = runStatus({}); + const discovery = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("create must not run"), + findStack: () => + Effect.fail(new StackStateFormatUnsupportedError({ message: "discovery failed" })), + listStacks: () => Effect.succeed([]), + openStack: () => Effect.die("open must not run"), + inspectStack: () => Effect.die("inspect must not run"), + }); + const effect = legacyExperimentalStackStatus(flags()).pipe( + Effect.provide( + Layer.mergeAll( + run.out.layer, + discovery, + mockLegacyCliSettings({ workdir: run.projectRoot }), + BunServices.layer, + ), + ), + Effect.ensuring(Effect.sync(() => rmSync(run.root, { recursive: true, force: true }))), + Effect.exit, + ); + return effect.pipe( + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) + expect(error.value[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + }), + ), + ); + }); + + it.live("parses stack name and stack id through the command", () => { + let parsed: { stack: Option.Option; stackId: Option.Option } | undefined; + const command = legacyExperimentalStackStatusCommand.pipe( + Command.withHandler((parsedFlags) => + Effect.sync(() => { + parsed = { stack: parsedFlags.stack, stackId: parsedFlags.stackId }; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--stack", "feature-a"]); + expect(parsed).toEqual({ stack: Option.some("feature-a"), stackId: Option.none() }); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md new file mode 100644 index 0000000000..fd67a07bac --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -0,0 +1,27 @@ +# `supabase experimental stack stop` + +This command stops the managed stack identified by the current project, an optional `--stack` +name, or `--stack-id`. It uses the public `@supabase/stack` API to stop the owner while +preserving the stack's persistent state and data volumes. It never destroys the stack. + +## Files read and written + +The stack package reads and updates its durable state under `` +and the selected stack's lifecycle state. The CLI reads its normal workdir settings. The +command does not load `supabase/config.toml`, so a missing or invalid project config does not +prevent stopping an addressed stack. + +No project files, credentials, or runtime configuration files are written. The package owns +the supervisor teardown and state transition; the CLI does not remove containers, volumes, +or stack state itself. If persisted state says the stack is running but its owner is +unreachable, the package may launch a short-lived Supervisor to arbitrate teardown before +returning. That process is package-owned and is not managed directly by the CLI. + +## Output and telemetry + +Text mode reports the selected stack and stopped outcome. Structured modes include the selected +stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit +no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a +missing named stack or any typed stop failure, and `130` if the command is interrupted before +the stop completes. Standard command instrumentation records command +metadata; stack data and credentials are not emitted as telemetry properties. diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts new file mode 100644 index 0000000000..8df1870733 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -0,0 +1,32 @@ +import { Command, Flag } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyExperimentalStackStop } from "./stop.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe( + Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), + Flag.optional, + ), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Stop an existing stack by id."), + Flag.optional, + ), +} as const; + +export const legacyExperimentalStackStopCommand = Command.make("stop", config).pipe( + Command.withDescription("Stop a managed local Supabase stack while preserving its data."), + Command.withShortDescription("Stop a managed local stack"), + Command.withExamples([ + { + command: "supabase experimental stack stop --stack feature-a", + description: "Stop the existing feature-a stack", + }, + ]), + Command.withHandler((flags) => + legacyExperimentalStackStop(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), +); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts new file mode 100644 index 0000000000..f199a3f359 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class LegacyExperimentalStackStopError extends Data.TaggedError( + "LegacyExperimentalStackStopError", +)<{ + readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; + readonly message: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "flags": + return actionability.provideFlags; + case "invalid-config": + return actionability.invalidConfig; + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts new file mode 100644 index 0000000000..930b19a06b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -0,0 +1,125 @@ +import { Effect, Match, Option } from "effect"; +import { + isStackError, + isStackId, + StackIdSchema, + type StackDescriptor, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { LegacyExperimentalStackStopError } from "./stop.errors.ts"; + +export interface LegacyExperimentalStackStopFlags { + readonly stack: Option.Option; + readonly stackId: Option.Option; +} + +export const legacyValidateExperimentalStackStopTarget = ( + flags: Pick, +) => + Option.isSome(flags.stack) && Option.isSome(flags.stackId) + ? Effect.fail( + new LegacyExperimentalStackStopError({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }), + ) + : Effect.void; + +const stopError = (error: unknown): LegacyExperimentalStackStopError => { + const stackError = isStackError(error) ? error : undefined; + const classification = + stackError === undefined + ? { reason: "unknown" as const } + : Match.value(stackError).pipe( + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackRuntimeError", + "StackCleanupError", + () => ({ reason: "lifecycle" as const }), + ), + Match.tag( + "InvalidStackConfigError", + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => ({ reason: "invalid-config" as const }), + ), + Match.tag("StackUpgradeRequiredError", () => ({ reason: "lifecycle" as const })), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new LegacyExperimentalStackStopError({ + ...classification, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +const stoppedPayload = (id: StackDescriptor["id"]) => ({ found: true, id, lifecycle: "stopped" }); + +export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack.stop")(function* ( + flags: LegacyExperimentalStackStopFlags, +) { + const output = yield* Output; + const settings = yield* LegacyCliSettings; + const stackApi = yield* LegacyExperimentalStackApi; + const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); + if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) + return yield* new LegacyExperimentalStackStopError({ + reason: "flags", + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + suggestion: "Use --output-format json, --output-format text, or --output-format stream-json.", + }); + yield* legacyValidateExperimentalStackStopTarget(flags); + + const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; + const targetOption = + id === undefined + ? yield* stackApi + .findStack({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(stopError)) + : yield* isStackId(id) + ? Effect.succeed( + Option.some({ + id: StackIdSchema.make(id), + projectRoot: settings.workdir, + }), + ) + : Effect.fail( + new LegacyExperimentalStackStopError({ + reason: "flags", + message: "--stack-id must be a lowercase SHA-256 stack id", + }), + ); + if (Option.isNone(targetOption)) { + if (Option.isSome(flags.stack)) + return yield* new LegacyExperimentalStackStopError({ + reason: "flags", + message: `No managed stack named "${flags.stack.value}" was found for this project.`, + suggestion: "Choose an existing --stack name or omit --stack for the current project.", + }); + yield* output.success("No managed stack found for this context.", { found: false }); + return; + } + const target = targetOption.value; + const stack = yield* stackApi.openStack(target.id).pipe(Effect.mapError(stopError)); + const stopping = yield* output.task(`Stopping stack ${target.id}...`); + yield* stack.stop().pipe( + Effect.tapError((error) => stopping.fail(error.message)), + Effect.tap(() => stopping.clear()), + Effect.mapError(stopError), + ); + if (output.format === "text") yield* output.raw(`Stack ${target.id} stopped.\n`); + else yield* output.success("", stoppedPayload(target.id)); +}); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts new file mode 100644 index 0000000000..2337c88a64 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -0,0 +1,420 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + InvalidStackIdentityError, + StackIdSchema, + StackNotFoundError, + StackOwnershipConflictError, + StackRuntimeMismatchError, + StackStateFormatUnsupportedError, + StackStateInvalidError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import type { + EffectStack, + OpenStackError, + StackDiscoveryError, + StackStatus, + StackStopError, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { + legacyExperimentalStackStop, + legacyValidateExperimentalStackStopTarget, +} from "./stop.handler.ts"; +import { LegacyExperimentalStackStopError } from "./stop.errors.ts"; +import { legacyExperimentalStackStopCommand } from "./stop.command.ts"; + +const status = (id: string): StackStatus => ({ + id: StackIdSchema.make(id), + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}); + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + ...overrides, +}); + +function setup(opts: { + root: string; + found?: { id: string; name?: string }; + stop?: () => Effect.Effect; + openFailure?: OpenStackError; + findFailure?: StackDiscoveryError; +}) { + const out = mockOutput(); + const state = { + findInputs: [] as Array<{ projectRoot: string; name?: string }>, + openedIds: [] as string[], + stopCalls: 0, + destroyCalled: false, + }; + const id = opts.found?.id ?? "a".repeat(64); + const stack = { + id: StackIdSchema.make(id), + status: () => Effect.succeed(status(id)), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: + opts.stop ?? + (() => + Effect.sync(() => { + state.stopCalls += 1; + })), + destroy: () => + Effect.sync(() => { + state.destroyCalled = true; + }), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + } satisfies EffectStack; + const descriptor = opts.found + ? { + id: stack.id, + projectRoot: opts.root, + name: opts.found.name ?? "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + } + : undefined; + const layer = Layer.mergeAll( + out.layer, + mockLegacyCliSettings({ workdir: opts.root }), + Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("must not create"), + listStacks: () => Effect.succeed([]), + findStack: (input) => + Effect.sync(() => { + state.findInputs.push(input); + return descriptor === undefined ? Option.none() : Option.some(descriptor); + }).pipe( + Effect.flatMap((value) => + opts.findFailure === undefined ? Effect.succeed(value) : Effect.fail(opts.findFailure), + ), + ), + openStack: (stackId) => { + if (opts.openFailure !== undefined) return Effect.fail(opts.openFailure); + return Effect.sync(() => { + state.openedIds.push(stackId); + return stack; + }); + }, + inspectStack: () => Effect.die("must not inspect"), + }), + BunServices.layer, + ); + return { layer, out, state }; +} + +describe("experimental stack stop", () => { + it.effect("stops a named stack without destroying its data", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-")); + const setupResult = setup({ + root, + found: { id: "a".repeat(64), name: "feature-a" }, + }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags({ stack: Option.some("feature-a") })); + expect(setupResult.state.findInputs).toEqual([{ projectRoot: root, name: "feature-a" }]); + expect(setupResult.state.openedIds).toEqual(["a".repeat(64)]); + expect(setupResult.state.stopCalls).toBe(1); + expect(setupResult.out.stdoutText).toContain("stopped"); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("opens an explicit id without finding or reading the current project", () => { + // oxlint-disable-next-line effecttsgo/global-date -- unique fixture directory identity + const root = join(tmpdir(), `supabase-stack-stop-id-${Date.now()}`); + const id = "c".repeat(64); + const setupResult = setup({ root, found: { id } }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags({ stackId: Option.some(id) })); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([id]); + expect(setupResult.state.stopCalls).toBe(1); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("stops an already stopped stack repeatedly without destroying data", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-repeat-")); + const setupResult = setup({ root, found: { id: "d".repeat(64) } }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags()); + yield* legacyExperimentalStackStop(flags()); + expect(setupResult.state.stopCalls).toBe(2); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies an addressed missing stack as actionable flags", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-open-missing-")); + const setupResult = setup({ + root, + found: { id: "e".repeat(64) }, + openFailure: new StackNotFoundError({ message: "Stack state was not found" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop( + flags({ stackId: Option.some("e".repeat(64)) }), + ).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("emits a self-describing JSON stopped result", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-json-")); + const setupResult = setup({ root, found: { id: "f".repeat(64) } }); + const output = mockOutput({ format: "json" }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags()); + expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + found: true, + id: "f".repeat(64), + lifecycle: "stopped", + }); + }).pipe( + Effect.provide(Layer.mergeAll(setupResult.layer, output.layer)), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("reports a missing named stack without opening or stopping anything", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-named-missing-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop( + flags({ stack: Option.some("missing") }), + ).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.findInputs).toEqual([{ projectRoot: root, name: "missing" }]); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies invalid stack names as actionable flags", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-invalid-name-")); + const setupResult = setup({ + root, + findFailure: new InvalidStackIdentityError({ message: "The stack name must not be blank" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags({ stack: Option.some("") })).pipe( + Effect.flip, + ); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects malformed ids before opening or stopping anything", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-malformed-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop( + flags({ stackId: Option.some("invalid") }), + ).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("is idempotent when no current stack exists and does not read config", () => { + // oxlint-disable-next-line effecttsgo/global-date -- unique fixture directory identity + const root = join(tmpdir(), `supabase-stack-stop-missing-${Date.now()}`); + const setupResult = setup({ root }); + return Effect.gen(function* () { + yield* legacyExperimentalStackStop(flags()); + expect( + setupResult.out.messages.some((message) => + message.message.includes("No managed stack found"), + ), + ).toBe(true); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects explicit legacy output and mutually exclusive targets", () => + Effect.gen(function* () { + const targetFailure = yield* legacyValidateExperimentalStackStopTarget({ + stack: Option.some("feature-a"), + stackId: Option.some("a".repeat(64)), + }).pipe(Effect.flip); + expect(targetFailure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(targetFailure.message).toContain("cannot be used together"); + }), + ); + + it.effect("does not report success when package stop fails", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-failure-")); + const setupResult = setup({ + root, + found: { id: "b".repeat(64) }, + stop: () => Effect.fail(new StackStateInvalidError({ message: "stop failed" })), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStopError); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies an ownership conflict as a lifecycle failure", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-conflict-")); + const setupResult = setup({ + root, + found: { id: "7".repeat(64) }, + stop: () => Effect.fail(new StackOwnershipConflictError({ message: "stack is owned" })), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("lifecycle"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies persisted state format failures as invalid config", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-format-")); + const setupResult = setup({ + root, + found: { id: "8".repeat(64) }, + openFailure: new StackStateFormatUnsupportedError({ + format: "future", + message: "Unsupported stack state format", + }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("invalid-config"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies stack upgrade requirements as lifecycle failures", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-upgrade-")); + const setupResult = setup({ + root, + found: { id: "9".repeat(64) }, + openFailure: new StackUpgradeRequiredError({ + expectedRelease: "next", + actualRelease: "current", + message: "Stack upgrade required", + }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("lifecycle"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("classifies an unknown stack error as unknown actionability", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-runtime-")); + const setupResult = setup({ + root, + found: { id: "a".repeat(64) }, + openFailure: new StackRuntimeMismatchError({ message: "Runtime mismatch" }), + }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("unknown"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.unknown); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe( + Effect.provide(setupResult.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.effect("rejects the legacy output flag with actionable guidance", () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-output-")); + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* legacyExperimentalStackStop(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(LegacyExperimentalStackStopError); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + }).pipe( + Effect.provide( + Layer.mergeAll(setupResult.layer, Layer.succeed(LegacyOutputFlag, Option.some("json"))), + ), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); +}); + +describe("experimental stack stop parser", () => { + it.live("parses a named existing stack", () => { + let parsed: Option.Option | undefined; + const command = legacyExperimentalStackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags.stack))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--stack", "feature-a"]); + expect(parsed).toEqual(Option.some("feature-a")); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); +}); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 8e4cce9e0d..f70502d2d3 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1,2 +1,13 @@ #!/usr/bin/env bun -import "./cli/main.ts"; +import { + runNativeProcessIfDispatched, + runSupervisorProcessIfDispatched, +} from "@supabase/stack/internal/supervisor"; + +const argv = process.argv.slice(2); +if ( + !(await runSupervisorProcessIfDispatched(argv)) && + !(await runNativeProcessIfDispatched(argv)) +) { + await import("./cli/main.ts"); +} diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index e42216bea5..e0170392dc 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -3,6 +3,7 @@ import { CliConfigStore } from "@supabase/config/effect"; import { Cause, Console, + Crypto, Effect, Exit, FileSystem, @@ -73,6 +74,7 @@ type AllowedRunCliServices = | CliSettings | CommandRuntime | FileSystem.FileSystem + | Crypto.Crypto | Path.Path | ProcessControl | ProjectLinkState diff --git a/knip.json b/knip.json index b86b959961..ac95e99fc3 100644 --- a/knip.json +++ b/knip.json @@ -39,7 +39,6 @@ "entry": ["src/**/*.test.ts"] }, "packages/stack": { - "ignoreBinaries": ["mkfifo"], "ignoreDependencies": ["@types/ws", "ws"] }, "packages/cli-test-helpers": { diff --git a/package.json b/package.json index b8d8ede135..59e9422d73 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack apps/cli/src/commands/experimental/stack", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack apps/cli/src/commands/experimental/stack", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 9f9ca11994..561d2be33f 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -8,6 +8,7 @@ export { export type { PromiseStack, PromiseStackConfig, + PromiseInspectStackOptions, PromiseStartStackOptions, PromisePrepareStackOptions, CreateStackOptions, diff --git a/packages/stack/src/internal/dispatch-markers.ts b/packages/stack/src/internal/dispatch-markers.ts new file mode 100644 index 0000000000..1ec20e6898 --- /dev/null +++ b/packages/stack/src/internal/dispatch-markers.ts @@ -0,0 +1,5 @@ +/** Private argv marker used when a compiled CLI dispatches an embedded Supervisor. */ +export const SUPERVISOR_DISPATCH_SENTINEL = "__supabase_stack_supervisor__" as const; + +/** Private argv marker used when a compiled CLI dispatches its embedded native launcher. */ +export const NATIVE_PROCESS_DISPATCH_SENTINEL = "__supabase_stack_native__" as const; diff --git a/packages/stack/src/internal/supervisor-process.ts b/packages/stack/src/internal/supervisor-process.ts index 257f62cd9a..ea04f26e40 100644 --- a/packages/stack/src/internal/supervisor-process.ts +++ b/packages/stack/src/internal/supervisor-process.ts @@ -1,9 +1,12 @@ -import { SUPERVISOR_DISPATCH_SENTINEL } from "../supervisor/Launcher.ts"; -import { runSupervisorProcess } from "../entrypoints/supervisor-node.ts"; -import { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../runtime/NativeProcess.ts"; -import { runNativeLauncher } from "../runtime/native-launcher.ts"; +import { + NATIVE_PROCESS_DISPATCH_SENTINEL, + SUPERVISOR_DISPATCH_SENTINEL, +} from "./dispatch-markers.ts"; -export { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../runtime/NativeProcess.ts"; +export { + NATIVE_PROCESS_DISPATCH_SENTINEL, + SUPERVISOR_DISPATCH_SENTINEL, +} from "./dispatch-markers.ts"; /** * Supported process-entrypoint seam for embedders such as the CLI binary. @@ -12,7 +15,9 @@ export { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../runtime/NativeProcess.ts"; */ export const runSupervisorProcessIfDispatched = (argv: ReadonlyArray): Promise => { if (argv[0] !== SUPERVISOR_DISPATCH_SENTINEL) return Promise.resolve(false); - return runSupervisorProcess(argv.slice(1)).then(() => true); + return import("../entrypoints/supervisor-node.ts") + .then(({ runSupervisorProcess }) => runSupervisorProcess(argv.slice(1))) + .then(() => true); }; /** @@ -22,6 +27,7 @@ export const runSupervisorProcessIfDispatched = (argv: ReadonlyArray): P */ export const runNativeProcessIfDispatched = (argv: ReadonlyArray): Promise => { if (argv[0] !== NATIVE_PROCESS_DISPATCH_SENTINEL) return Promise.resolve(false); - runNativeLauncher(); - return Promise.resolve(true); + return import("../runtime/native-launcher.ts") + .then(({ runNativeLauncher }) => runNativeLauncher()) + .then(() => true); }; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index a346f0aa6c..29be8ececa 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -447,7 +447,23 @@ const releaseFor = ( const selected = extract(raw, "version"); const selector = typeof selected === "string" ? selected : (previousVersion ?? module.defaultVersion); - const release = module.releases[selector]; + // The CLI config exposes PostgreSQL as a major selector (for example `15` + // or `17`), while the stack catalog persists a concrete release. Resolve a + // major against the catalog here so the CLI never needs to know artifact + // patch IDs. Exact selectors keep their existing behavior for every module. + const majorSelector = module.name === "database" && /^\d+$/.test(selector); + const resolvedSelector = majorSelector + ? (() => { + const sameMajor = (version: string | undefined): boolean => + version !== undefined && version.split(".", 1)[0] === selector; + if (sameMajor(previousVersion) && previousVersion !== undefined) return previousVersion; + if (sameMajor(module.defaultVersion)) return module.defaultVersion; + return Object.keys(module.releases).find( + (version) => version.includes(".") && sameMajor(version), + ); + })() + : selector; + const release = resolvedSelector === undefined ? undefined : module.releases[resolvedSelector]; if (release !== undefined) return Effect.succeed(release.version); return Effect.fail( new StackVersionUnsupportedError({ diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index e652fcfb27..641c19db75 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -669,6 +669,27 @@ describe("closed capability compiler", () => { }), ); + it.live("prefers a compatible previous database release for a major selector", () => + Effect.gen(function* () { + const previous = yield* compile({ + capabilities: { database: { version: "15.14.1.168" } }, + }); + const selected = yield* compile( + { capabilities: { database: { version: "15" } } }, + { kind: "native" }, + previous, + ); + expect(selected.definition.capabilities.database.version).toBe("15.14.1.168"); + }), + ); + + it.live("uses the default release when a supported major matches it", () => + Effect.gen(function* () { + const result = yield* compile({ capabilities: { database: { version: "17" } } }); + expect(result.definition.capabilities.database.version).toBe("17.6.1.168"); + }), + ); + it.live("rejects an unknown non-database release", () => Effect.gen(function* () { const result = yield* compile({ capabilities: { rest: { version: "not-real" } } }).pipe( diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts index b7ba5f0ac9..a155839112 100644 --- a/packages/stack/src/public/Config.ts +++ b/packages/stack/src/public/Config.ts @@ -45,6 +45,9 @@ const optionalCapability = (settings: S) => ]); export const DatabaseCapabilityConfigSchema = Schema.Struct({ + // PostgreSQL accepts an exact catalog release or a major selector such as + // "15"/"17". Major selectors are resolved to a concrete catalog release by + // the compiler, preserving a compatible previous pin when one exists. version: Schema.optionalKey(Schema.String), settings: Schema.optionalKey(DatabaseSettingsSchema), }); diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 8ba62a1c55..39bf9b0816 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -7,9 +7,11 @@ import { Exit, FileSystem, Fiber, + Match, Option, Path, Predicate, + Redacted, Schedule, Schema, Stream, @@ -20,7 +22,13 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; -import { compileStack, rebuildExecutionPlan, type StackDefinition } from "../model/Compiler.ts"; +import { + compileStack, + rebuildExecutionPlan, + sameDefinition, + type SecretSlotInput, + type StackDefinition, +} from "../model/Compiler.ts"; import { dependencyClosure, type ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; @@ -140,6 +148,10 @@ export interface FindStackOptions { export interface ListStacksOptions { readonly projectRoot?: string; } + +export interface InspectStackOptions { + readonly config?: StackConfig; +} export interface PreparedCapability { readonly capability: CapabilityName; readonly version: string; @@ -1086,9 +1098,48 @@ export const listStacks = ( const result: StackDescriptor[] = []; for (const entry of entries) { if (!Schema.is(StackIdSchema)(entry)) continue; - const state = yield* store - .read(entry) - .pipe(Effect.catchIf(isMissingStateRemnantError, () => Effect.void)); + const state = yield* store.read(entry).pipe( + Effect.mapError((error) => { + const message = `Failed to read managed stack ${entry}: ${error.message}`; + return Match.value(error).pipe( + Match.tag( + "InvalidProjectRootError", + (error) => + new InvalidProjectRootError({ + projectRoot: error.projectRoot, + stateRoot: error.stateRoot, + message, + cause: error, + }), + ), + Match.tag( + "StackStateInvalidError", + (error) => + new StackStateInvalidError({ + stackId: entry, + path: error.path, + code: error.code, + slot: error.slot, + message, + cause: error, + }), + ), + Match.tag( + "StackStateFormatUnsupportedError", + (error) => + new StackStateFormatUnsupportedError({ + format: error.format, + message, + cause: error, + }), + ), + Match.exhaustive, + ); + }), + Effect.catchTag("StackStateInvalidError", (error) => + isMissingStateRemnantError(error) ? Effect.void : Effect.fail(error), + ), + ); if ( state !== undefined && (projectRoot === undefined || state.identity.projectRoot === projectRoot) @@ -1098,11 +1149,103 @@ export const listStacks = ( return result; }); +type ConfigDrift = NonNullable; + +const isPlainRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const definitionDiffPaths = ( + left: unknown, + right: unknown, + prefix: string, + paths: string[], +): void => { + if (Object.is(left, right)) return; + if ((left === undefined || left === null) && (right === undefined || right === null)) return; + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) { + paths.push(prefix); + return; + } + for (let index = 0; index < left.length; index++) { + definitionDiffPaths(left[index], right[index], `${prefix}.${index}`, paths); + } + return; + } + if (Array.isArray(left) || Array.isArray(right)) { + paths.push(prefix); + return; + } + if (isPlainRecord(left) && isPlainRecord(right)) { + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + definitionDiffPaths(left[key], right[key], `${prefix}.${key}`, paths); + } + return; + } + paths.push(prefix); +}; + +const secretDriftPaths = ( + candidate: ReadonlyArray, + persisted: PersistedStackState["secrets"], +): ReadonlyArray => { + const paths: string[] = []; + const supplied = new Map(candidate.map((entry) => [entry.slot, entry])); + for (const entry of candidate) { + const old = persisted[entry.slot]; + if (old === undefined) { + if (entry.policy === "passthrough" || entry.value !== undefined) + paths.push(`secrets.${entry.slot}`); + continue; + } + if (old.policy !== entry.policy) { + paths.push(`secrets.${entry.slot}`); + continue; + } + if (entry.policy === "passthrough" || entry.value !== undefined) { + const value = entry.value === undefined ? undefined : Redacted.value(entry.value); + if (value !== old.value) paths.push(`secrets.${entry.slot}`); + } + } + for (const [slot, old] of Object.entries(persisted)) { + if (old.policy === "passthrough" && !supplied.has(slot)) paths.push(`secrets.${slot}`); + } + return paths; +}; + +const inspectConfigDrift = ( + state: PersistedStackState, + config: StackConfig, +): Effect.Effect => + Effect.gen(function* () { + const compiled = yield* compileStack( + { + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config, + }, + state.definition === undefined ? undefined : { definition: state.definition }, + ); + if (state.definition === undefined) + return { status: "unconfigured", paths: [] } satisfies ConfigDrift; + const paths: string[] = []; + if (!sameDefinition(state.definition, compiled.definition)) + definitionDiffPaths(state.definition, compiled.definition, "definition", paths); + paths.push(...secretDriftPaths(compiled.secrets, state.secrets)); + const uniquePaths = [...new Set(paths)].sort(); + return { + status: uniquePaths.length === 0 ? "unchanged" : "changed", + paths: uniquePaths, + } satisfies ConfigDrift; + }); + export const inspectStack = ( id: StackId, + options: InspectStackOptions = {}, ): Effect.Effect< StackInspection, - StackNotFoundError | StackDiscoveryError, + StackNotFoundError | StackDiscoveryError | InvalidStackConfigError | StackVersionUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto > => Effect.gen(function* () { @@ -1111,14 +1254,21 @@ export const inspectStack = ( const state = yield* store.read(id); if (state === undefined) return yield* new StackNotFoundError({ stackId: id, message: "Stack state was not found" }); + const configDrift = + options.config === undefined ? undefined : yield* inspectConfigDrift(state, options.config); const metadata = yield* readOwnerMetadata(env.stateRoot, id, env); if (metadata === undefined) return { descriptor: descriptor(state), owner: (yield* ownerLockExists(env.stateRoot, id)) ? "unreachable" : "absent", + ...(configDrift === undefined ? {} : { configDrift }), }; if (metadata.rpcRelease !== STACK_RPC_RELEASE) - return { descriptor: descriptor(state), owner: "incompatible" }; + return { + descriptor: descriptor(state), + owner: "incompatible", + ...(configDrift === undefined ? {} : { configDrift }), + }; const status = yield* Effect.scoped( Effect.gen(function* () { const client = makeControlClient(metadata.endpoint, { @@ -1132,8 +1282,21 @@ export const inspectStack = ( if (Exit.isFailure(status)) { const failure = Cause.findErrorOption(status.cause); if (Option.isSome(failure) && isOwnerUnreachable(failure.value)) - return { descriptor: descriptor(state), owner: "unreachable" }; - return { descriptor: descriptor(state), owner: "running" }; + return { + descriptor: descriptor(state), + owner: "unreachable", + ...(configDrift === undefined ? {} : { configDrift }), + }; + return { + descriptor: descriptor(state), + owner: "running", + ...(configDrift === undefined ? {} : { configDrift }), + }; } - return { descriptor: descriptor(state), owner: "running", status: status.value }; + return { + descriptor: descriptor(state), + owner: "running", + status: status.value, + ...(configDrift === undefined ? {} : { configDrift }), + }; }); diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 069302b491..12c5b7d67a 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -42,6 +42,10 @@ export type PromiseStackConfig = Unredacted; export type PromiseStartStackOptions = Omit & { readonly config?: PromiseStackConfig; }; + +export interface PromiseInspectStackOptions { + readonly config?: PromiseStackConfig; +} export type PromisePrepareStackOptions = Omit & { readonly config?: PromiseStackConfig; }; @@ -63,7 +67,10 @@ interface PromiseStackApi { readonly openStack: (id: StackId) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; - readonly inspectStack: (id: StackId) => Promise; + readonly inspectStack: ( + id: StackId, + options?: PromiseInspectStackOptions, + ) => Promise; } type PlatformLayer = typeof NodeServices.layer; @@ -177,7 +184,14 @@ export const makePromiseApi = ( findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), - inspectStack: (id) => run(inspectEffectStack(id)), + inspectStack: (id, options) => + run( + options?.config === undefined + ? inspectEffectStack(id) + : decodePromiseConfig(options.config).pipe( + Effect.flatMap((config) => inspectEffectStack(id, { config })), + ), + ), }; }; diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index b20f257f5d..75a41005f6 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -162,6 +162,12 @@ export const StackInspectionSchema = Schema.Struct({ descriptor: StackDescriptorSchema, owner: Schema.Literals(["running", "absent", "unreachable", "incompatible"] as const), status: Schema.optionalKey(StackStatusSchema), + configDrift: Schema.optionalKey( + Schema.Struct({ + status: Schema.Literals(["unchanged", "changed", "unconfigured"] as const), + paths: Schema.Array(Schema.String), + }), + ), }); export type StackInspection = Schema.Schema.Type; diff --git a/packages/stack/src/public/config-drift.integration.test.ts b/packages/stack/src/public/config-drift.integration.test.ts new file mode 100644 index 0000000000..f8235a564d --- /dev/null +++ b/packages/stack/src/public/config-drift.integration.test.ts @@ -0,0 +1,203 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; +import { makePromiseApi } from "./PromiseStack.ts"; +import { createStack, inspectStack } from "./EffectStack.ts"; +import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; +import { compileStack } from "../model/Compiler.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import type { StackConfig } from "./Config.ts"; +import { StackVersionUnsupportedError, InvalidStackConfigError } from "./Errors.ts"; + +const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-config-drift-" }); + yield* Effect.addFinalizer(() => + fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), + ); + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const runtime = { + ...defaultRuntimeEnvironment(), + stateRoot: path.join(root, "managed", "stacks"), + tempRoot: "/tmp", + platform: "posix" as const, + }; + return yield* effect(project).pipe(Effect.provideService(StackRuntimeEnvironment, runtime)); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +const seedConfiguredStack = (projectRoot: string, config: StackConfig) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot, runtime: { kind: "native" } }); + const env = yield* StackRuntimeEnvironment; + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(stack.id); + if (state === undefined) return yield* Effect.die("stack state was not initialized"); + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config, + }); + const secrets = Object.fromEntries( + compiled.secrets.map((entry) => [ + entry.slot, + { + policy: entry.policy, + value: entry.value === undefined ? "generated" : String(Redacted.value(entry.value)), + }, + ]), + ); + yield* store.replace(stack.id, { ...state, definition: compiled.definition, secrets }); + return stack; + }); + +const baseConfig = (secret: string): StackConfig => ({ + capabilities: { + functions: { + settings: { + functions_root: "supabase/functions", + edge_runtime: { secrets: { TOKEN: Redacted.make(secret) } }, + }, + }, + }, + listeners: { api: { port: 55431 } }, +}); + +describe("inspectStack config drift", () => { + it.live( + "reports unchanged and changed settings, preparation, listeners, and secret paths without values", + () => + withRuntimeRoot((projectRoot) => + Effect.gen(function* () { + const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); + const unchanged = yield* inspectStack(stack.id, { config: baseConfig("old-secret") }); + expect(unchanged.configDrift).toEqual({ + status: "unchanged", + paths: [], + }); + + const changed = yield* inspectStack(stack.id, { + config: { + ...baseConfig("new-secret"), + preparation: "on-demand", + capabilities: { + functions: { + settings: { + functions_root: "supabase/functions", + edge_runtime: { + policy: "oneshot", + secrets: { TOKEN: Redacted.make("new-secret") }, + }, + }, + }, + }, + listeners: { api: { port: 55432 } }, + }, + }); + expect(changed.configDrift?.status).toBe("changed"); + expect(changed.configDrift?.paths).toEqual( + expect.arrayContaining([ + "definition.preparation", + "definition.capabilities.functions.settings.edge_runtime.policy", + "definition.listeners.api.port", + "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", + ]), + ); + expect(changed.configDrift?.paths).not.toContain("old-secret"); + expect(changed.configDrift?.paths).not.toContain("new-secret"); + }), + ), + ); + + it.live("marks an unconfigured stack", () => + withRuntimeRoot((projectRoot) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot, runtime: { kind: "native" } }); + const unconfigured = yield* inspectStack(stack.id, { config: {} }); + expect(unconfigured.configDrift).toEqual({ + status: "unconfigured", + paths: [], + }); + }), + ), + ); + + it.live( + "reuses omitted managed secrets and detects explicit changes or passthrough removal", + () => + withRuntimeRoot((projectRoot) => + Effect.gen(function* () { + const managed = (secret?: string): StackConfig => ({ + ...baseConfig("old-secret"), + capabilities: { + auth: { settings: secret === undefined ? {} : { jwt_secret: Redacted.make(secret) } }, + functions: baseConfig("old-secret").capabilities?.functions, + }, + }); + const stack = yield* seedConfiguredStack(projectRoot, managed("managed-secret")); + expect((yield* inspectStack(stack.id, { config: managed() })).configDrift).toEqual({ + status: "unchanged", + paths: [], + }); + const changed = yield* inspectStack(stack.id, { config: managed("new-managed-secret") }); + expect(changed.configDrift?.paths).toContain("secrets.secret:auth.settings.jwt_secret"); + expect(changed.configDrift?.paths).not.toContain("managed-secret"); + const removed = yield* inspectStack(stack.id, { + config: { + ...baseConfig("old-secret"), + capabilities: { + functions: { settings: { functions_root: "supabase/functions", edge_runtime: {} } }, + }, + }, + }); + expect(removed.configDrift?.paths).toContain( + "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", + ); + }), + ), + ); + + it.live("rejects malformed candidate config with a typed config error", () => + withRuntimeRoot((projectRoot) => + Effect.gen(function* () { + const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); + const result = yield* inspectStack(stack.id, { + config: { capabilities: { database: { version: "unsupported" } } }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(StackVersionUnsupportedError); + expect(failure.value).not.toBeInstanceOf(InvalidStackConfigError); + } + } + }), + ), + ); + + it.live("decodes Promise facade config and returns the same redacted report", () => + withRuntimeRoot((projectRoot) => + Effect.gen(function* () { + const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); + const env = yield* StackRuntimeEnvironment; + const api = makePromiseApi(NodeServices.layer, env); + return yield* Effect.tryPromise(() => + api.inspectStack(stack.id, { config: { listeners: { api: { port: 55432 } } } }), + ); + }).pipe( + Effect.tap((inspection) => + Effect.sync(() => { + expect(inspection.configDrift?.status).toBe("changed"); + expect(inspection.configDrift?.paths).not.toContain("old-secret"); + }), + ), + ), + ), + ); +}); diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 9cfdbdd7f7..ef73bbf742 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -167,6 +167,33 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { + return { + capabilities: { + database: {}, + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + api: { enabled: false }, + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + } as const; +}; + describe("Effect stack lifecycle handoff", () => { it.live("reclaims a stale owner after a failed maintenance connection", () => Effect.scoped( @@ -985,6 +1012,17 @@ describe("Effect stack lifecycle handoff", () => { ? Option.getOrUndefined(Cause.findErrorOption(listed.cause)) : undefined, ).toBeInstanceOf(StackStateInvalidError); + if (Exit.isFailure(listed)) { + const error = Cause.findErrorOption(listed.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackStateInvalidError); + if (error.value instanceof StackStateInvalidError) { + expect(error.value.stackId).toBe(orphanId); + expect(error.value.message).toContain(orphanId); + } + } + } }), ), ); @@ -1674,7 +1712,7 @@ describe("Effect stack lifecycle handoff", () => { const restarted = yield* openStack(stack.id); yield* Effect.addFinalizer(() => restarted.destroy().pipe(Effect.ignore)); const directStart = yield* restarted - .start({ config: { capabilities: {} } }) + .start({ config: lifecycleConfig() }) .pipe(Effect.exit); expect(Exit.isFailure(directStart)).toBe(true); if (Exit.isFailure(directStart)) { @@ -1684,7 +1722,7 @@ describe("Effect stack lifecycle handoff", () => { expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); } yield* restarted.stop(); - const status = yield* restarted.start({ config: { capabilities: {} } }); + const status = yield* restarted.start({ config: lifecycleConfig() }); const currentOwner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); expect(currentOwner?.rpcRelease).toBe(STACK_RPC_RELEASE); expect(currentOwner?.ownerSessionId).not.toBe(owner.ownerSessionId); @@ -1694,7 +1732,7 @@ describe("Effect stack lifecycle handoff", () => { yield* restarted.stop(); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - const startedAgain = yield* restarted.start({ config: { capabilities: {} } }); + const startedAgain = yield* restarted.start({ config: lifecycleConfig() }); expect(startedAgain.lifecycle).toBe("running"); yield* restarted.destroy(); }), @@ -1725,7 +1763,7 @@ describe("Effect stack lifecycle handoff", () => { const compiled = yield* compileStack({ projectRoot, runtime: { kind: "native" }, - config: { capabilities: {} }, + config: lifecycleConfig(), }); const resolved = yield* resolveSecrets( { diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index bd911e2d46..2ec7a1951b 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -10,6 +10,7 @@ export * from "./Config.ts"; export { createStack, openStack, findStack, listStacks, inspectStack } from "./EffectStack.ts"; export type { EffectStack, + InspectStackOptions, StartStackOptions, PrepareStackOptions, CreateStackOptions, diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index 0ef8710fb8..368a667673 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -52,8 +52,8 @@ export interface NativeProcess { readonly kill: Effect.Effect; } -/** Private argv marker used when a compiled CLI dispatches its embedded native launcher. */ -export const NATIVE_PROCESS_DISPATCH_SENTINEL = "__supabase_stack_native__" as const; +export { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../internal/dispatch-markers.ts"; +import { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../internal/dispatch-markers.ts"; const isBunVirtualPath = (value: string): boolean => /(?:^|[\\/])\$bunfs(?:[\\/]|$)/.test(value); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index 2d338c1684..5e4b6335ec 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -257,8 +257,8 @@ const privateSigningJwk = (value: unknown): SigningJwk | undefined => { return undefined; }; -const invalidSigningMaterial = () => - new InvalidJwtSigningMaterialError({ message: "Unable to resolve JWT signing material" }); +const invalidSigningMaterial = (message = "Unable to resolve JWT signing material") => + new InvalidJwtSigningMaterialError({ message }); const readSigningJwks = ( signing: Extract, @@ -274,7 +274,7 @@ const readSigningJwks = ( const candidate = path.resolve(projectRoot, signing.path); const relative = path.relative(projectRoot, candidate); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) - return yield* invalidSigningMaterial(); + return yield* invalidSigningMaterial("JWT signing key file must be inside project root"); const canonicalRoot = yield* fs .realPath(projectRoot) .pipe(Effect.mapError(() => invalidSigningMaterial())); @@ -287,7 +287,7 @@ const readSigningJwks = ( canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) ) - return yield* invalidSigningMaterial(); + return yield* invalidSigningMaterial("JWT signing key file must be inside project root"); const raw = yield* fs .readFileString(canonicalCandidate) .pipe(Effect.mapError(() => invalidSigningMaterial())); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index ed0615720d..0f3ef414d0 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -307,6 +307,9 @@ describe("managed and pass-through secrets", () => { "stopped", ).pipe(Effect.exit); expect(errorOf(escapingExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + expect(errorOf(escapingExit)?.message).toContain( + "JWT signing key file must be inside project root", + ); const outside = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-outside-" }); yield* fs.writeFileString( @@ -328,6 +331,9 @@ describe("managed and pass-through secrets", () => { "stopped", ).pipe(Effect.exit); expect(errorOf(symlinkExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + expect(errorOf(symlinkExit)?.message).toContain( + "JWT signing key file must be inside project root", + ); }).pipe(Effect.provide(layer)), ); diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 75a4316009..9ad3cef140 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -43,8 +43,8 @@ import { export { StackRuntimeEnvironment } from "../state/Ownership.ts"; export type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; -/** Private argv marker used when a compiled CLI dispatches its embedded Supervisor. */ -export const SUPERVISOR_DISPATCH_SENTINEL = "__supabase_stack_supervisor__" as const; +export { SUPERVISOR_DISPATCH_SENTINEL } from "../internal/dispatch-markers.ts"; +import { SUPERVISOR_DISPATCH_SENTINEL } from "../internal/dispatch-markers.ts"; const isBunVirtualPath = (value: string): boolean => /(?:^|[\\/])\$bunfs(?:[\\/]|$)/.test(value); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index d8603d5a87..b5a3d2d6ac 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -339,10 +339,42 @@ describe("Supervisor ingress", () => { workspaceId: root, checkoutId: root, }); + const apiListener = yield* bindHostListener("127.0.0.1", 0, "api"); + const databaseListener = yield* bindHostListener("127.0.0.1", 0, "database"); + const apiAddress = apiListener.binding.server.address(); + const databaseAddress = databaseListener.binding.server.address(); + if (typeof apiAddress !== "object" || apiAddress === null) + return yield* Effect.die("API listener did not expose an address"); + if (typeof databaseAddress !== "object" || databaseAddress === null) + return yield* Effect.die("Database listener did not expose an address"); + const apiPort = apiAddress.port; + const databasePort = databaseAddress.port; const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, - config: { capabilities: { rest: {} } }, + config: { + capabilities: { + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + api: { port: apiPort }, + database: { port: databasePort }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, }); const store = yield* makeStackStateStore({ stateRoot: root }); yield* store.initialize(stackId, { @@ -359,9 +391,8 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running", definition: compiled.definition, ports: [ - { field: "api", port: 55433, intent: "automatic" }, - { field: "database", port: 55436, intent: "automatic" }, - { field: "pooler", port: 55437, intent: "automatic" }, + { field: "api", port: apiPort, intent: "automatic" }, + { field: "database", port: databasePort, intent: "automatic" }, ] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -369,7 +400,18 @@ describe("Supervisor ingress", () => { })), secrets: {}, }); - const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, store, context }); + const ingress = yield* makeSupervisorIngress({ + stackId, + stateRoot: root, + store, + context, + bindHost: (address, port, field) => + field === "api" + ? Effect.succeed({ ...apiListener, port: apiPort }) + : field === "database" + ? Effect.succeed({ ...databaseListener, port: databasePort }) + : bindHostListener(address, port, field), + }); const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); const reservation = yield* ingress.acquire({ stackId, @@ -415,6 +457,11 @@ describe("Supervisor ingress", () => { checkoutId: root, }; const stackId = yield* deriveStackId(stackIdentity); + const databaseListener = yield* bindHostListener("127.0.0.1", 0, "database"); + const databaseAddress = databaseListener.binding.server.address(); + if (typeof databaseAddress !== "object" || databaseAddress === null) + return yield* Effect.die("Database listener did not expose an address"); + const databasePort = databaseAddress.port; const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -449,7 +496,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - ports: [{ field: "database", port: 55434, intent: "automatic" }] as const, + ports: [{ field: "database", port: databasePort, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30200 + index, @@ -462,6 +509,10 @@ describe("Supervisor ingress", () => { stateRoot: root, store, context, + bindHost: (address, port, field) => + field === "database" + ? Effect.succeed({ ...databaseListener, port: databasePort }) + : bindHostListener(address, port, field), apiMaterial: () => Effect.fail(new StackPreparationError({ message: "API material must not resolve" })), }); @@ -508,6 +559,13 @@ describe("Supervisor ingress", () => { checkoutId: root, }; const stackId = yield* deriveStackId(stackIdentity); + const apiListener = yield* bindHostListener("127.0.0.1", 0, "api"); + if (apiListener.binding.kind !== "http") + return yield* Effect.die("API listener is not HTTP"); + const apiAddress = apiListener.binding.server.address(); + if (typeof apiAddress !== "object" || apiAddress === null) + return yield* Effect.die("API listener did not expose an address"); + const apiPort = apiAddress.port; const templatePath = path.join(root, "templates", "confirmation.html"); const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-outside-", @@ -554,7 +612,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - ports: [{ field: "api", port: 55435, intent: "automatic" }] as const, + ports: [{ field: "api", port: apiPort, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30300 + index, @@ -593,6 +651,10 @@ describe("Supervisor ingress", () => { : Effect.fail(new StackPreparationError({ message: "Template escaped root" })), ), ), + bindHost: (address, port, field) => + field === "api" + ? Effect.succeed({ ...apiListener, port: apiPort }) + : bindHostListener(address, port, field), }); const input = { stackId, diff --git a/packages/stack/src/supervisor/startup-ingress.integration.test.ts b/packages/stack/src/supervisor/startup-ingress.integration.test.ts index 5025615f8b..ed5db938bd 100644 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ b/packages/stack/src/supervisor/startup-ingress.integration.test.ts @@ -28,6 +28,8 @@ import { makeSupervisor, type SupervisorRuntime } from "./Supervisor.ts"; import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackLogEntry } from "../public/Logs.ts"; +import type { StackError } from "../public/Errors.ts"; +import type { HostListener } from "../state/PortCoordinator.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -110,8 +112,14 @@ describe("startup ingress", () => { Context.add(Path.Path, path), Context.add(Crypto.Crypto, crypto), ); - const listenerBound = - yield* Deferred.make(); + const listenerBound = yield* Deferred.make(); + const apiListener = yield* bindHostListener("127.0.0.1", 0, "api"); + if (apiListener.binding.kind !== "http") + return yield* Effect.die("API listener is not HTTP"); + const apiAddress = apiListener.binding.server.address(); + if (typeof apiAddress !== "object" || apiAddress === null) + return yield* Effect.die("API listener did not expose an address"); + const apiPort = apiAddress.port; const startEntered = yield* Deferred.make(); const releaseStart = yield* Deferred.make(); const activationCalls = yield* Ref.make(0); @@ -125,11 +133,11 @@ describe("startup ingress", () => { port: number, field: import("../public/Status.ts").PortField, ) => - bindHostListener(host, port, field).pipe( - Effect.tap((listener) => - field === "api" ? Deferred.succeed(listenerBound, listener) : Effect.void, - ), - ); + field === "api" + ? Effect.succeed({ ...apiListener, port: apiPort }).pipe( + Effect.tap((listener) => Deferred.succeed(listenerBound, listener)), + ) + : bindHostListener(host, port, field); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, @@ -195,7 +203,22 @@ describe("startup ingress", () => { runtime, }); const starting = yield* Effect.forkChild( - supervisor.start({ config: { listeners: { api: { enabled: true } } } }), + supervisor + .start({ + config: { + listeners: { + api: { port: apiPort }, + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }) + .pipe(Effect.tapCause((cause) => Deferred.failCause(listenerBound, cause))), ); const listener = yield* Deferred.await(listenerBound); if (listener.binding.kind !== "http") return yield* Effect.die("API listener is not HTTP"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eec614cb81..558a9a8ee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6(supports-color@7.2.0) + '@supabase/stack': + specifier: workspace:* + version: link:../../packages/stack '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11