From c249464d7c99d0fdcd4683b7270a4250003252dc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:34:07 +0200 Subject: [PATCH 01/43] feat(cli): add experimental stack start --- apps/cli/package.json | 1 + .../legacy-db-target-flags.ts | 4 + .../experimental/experimental.command.ts | 3 +- .../stack/stack-config.integration.test.ts | 232 ++++++ .../experimental/stack/stack-config.ts | 733 ++++++++++++++++++ .../experimental/stack/stack.command.ts | 18 + .../experimental/stack/stack.shared.ts | 132 ++++ .../experimental/stack/start/SIDE_EFFECTS.md | 30 + .../experimental/stack/start/start.command.ts | 48 ++ .../stack/start/start.e2e.test.ts | 158 ++++ .../experimental/stack/start/start.errors.ts | 52 ++ .../experimental/stack/start/start.handler.ts | 228 ++++++ .../stack/start/start.integration.test.ts | 536 +++++++++++++ apps/cli/src/main.ts | 13 +- apps/cli/src/shared/cli/run.ts | 2 + knip.json | 1 - .../stack/src/internal/dispatch-markers.ts | 5 + .../stack/src/internal/supervisor-process.ts | 22 +- packages/stack/src/model/Compiler.ts | 18 +- .../src/model/compiler.integration.test.ts | 21 + packages/stack/src/public/Config.ts | 3 + .../public/effect-stack.integration.test.ts | 36 +- packages/stack/src/runtime/NativeProcess.ts | 4 +- packages/stack/src/supervisor/Launcher.ts | 4 +- .../supervisor/ingress.integration.test.ts | 39 +- .../startup-ingress.integration.test.ts | 25 +- pnpm-lock.yaml | 3 + 27 files changed, 2342 insertions(+), 29 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack-config.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/stack.shared.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/start/start.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/start/start.integration.test.ts create mode 100644 packages/stack/src/internal/dispatch-markers.ts 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/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts new file mode 100644 index 0000000000..52635694d0 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -0,0 +1,232 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Redacted } from "effect"; + +import { 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("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 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"\n`); + return Effect.gen(function* () { + const config = yield* load(root); + expect(config.listeners).toEqual({}); + if (config.capabilities?.database !== undefined && "settings" in config.capabilities.database) + expect(config.capabilities.database.settings?.health_timeout).toBe("2m"); + }); + }); + + 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..3fd6c2774f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -0,0 +1,733 @@ +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. */ +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 legacyReadFunctionEnvironments = (projectRoot: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join(projectRoot, "supabase", "functions"); + const read = (file: string) => + fs + .exists(file) + .pipe( + Effect.flatMap((exists) => + exists ? fs.readFileString(file).pipe(Effect.map(parseEnv)) : 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; + 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.startsWith("supabase/") + ? 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 = (value: string, name: string): string => { + if (value.length === 0 || value.startsWith("/")) return value; + const normalized = value.replace(/^\.\//, "").replace(/^supabase\//, ""); + const target = normalized.split("/").filter((part) => part.length > 0); + const base = ["functions", name]; + let common = 0; + while (common < base.length && common < target.length && base[common] === target[common]) + common += 1; + return [...base.slice(common).map(() => ".."), ...target.slice(common)].join("/"); +}; + +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; + if (listener === undefined) return listener; + if (gatewayEnabled) { + const { enabled: _enabled, ...withoutEnabled } = listener; + return withoutEnabled; + } + 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); + return { + ...(enabled === false ? { enabled: false } : {}), + ...(port === 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); + return { + ...(nested.enabled === false ? { enabled: false } : {}), + ...(port === 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 = ( + 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(value.import_map, name) }), + ...(value.entrypoint === undefined + ? {} + : { entrypoint: legacyFunctionRelativePath(value.entrypoint, name) }), + ...(value.static_files === undefined + ? {} + : { + static_files: value.static_files.map((path) => + legacyFunctionRelativePath(path, 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, + 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 { + preparation: "background", + 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(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 = ( + 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"; + for (const [name, functionConfig] of Object.entries(config.functions)) { + 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 => + 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).pipe( + Effect.mapError((cause) => new LegacyStackConfigError({ message: String(cause) })), + Effect.flatMap( + ( + environments: Readonly<{ + readonly shared: Readonly>>; + readonly functions: Readonly< + Record>>> + >; + }>, + ) => { + const validationError = legacyConfigValidationError( + context.config, + context.projectEnvValues, + ); + if (validationError !== undefined) + return Effect.fail(new LegacyStackConfigError({ message: validationError })); + const input = legacyConfigInput( + projectRoot, + 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..c740eacd73 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -0,0 +1,18 @@ +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 { + 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]), + 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..911504243d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -0,0 +1,132 @@ +import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect"; +import { + createStack, + inspectStack, + isStackId, + openStack, + type StackRuntimePreference, +} from "@supabase/stack/effect"; +import type { StackId } from "@supabase/stack"; +import { StackNotFoundError } from "@supabase/stack/effect"; +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; +} + +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, + FileSystem.FileSystem | Path.Path | Crypto.Crypto | LegacyExperimentalStackApi + >; +} + +export class LegacyExperimentalStackTargetResolver extends Context.Service< + LegacyExperimentalStackTargetResolver, + LegacyExperimentalStackTargetResolverShape +>()("supabase/experimental-stack/TargetResolver") {} + +export class LegacyExperimentalStackApi extends Context.Service< + LegacyExperimentalStackApi, + { + readonly createStack: typeof createStack; + readonly openStack: typeof openStack; + readonly inspectStack: typeof inspectStack; + } +>()("supabase/experimental-stack/StackApi") {} + +export const legacyExperimentalStackApiLayer = Layer.succeed(LegacyExperimentalStackApi, { + createStack, + openStack, + inspectStack, +}); + +/** Runtime configuration for the first stack command. Later commands reuse this layer. */ +export const legacyExperimentalStackTargetResolverLayer = Layer.effect( + LegacyExperimentalStackTargetResolver, + Effect.succeed({ + 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..df0250610f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -0,0 +1,158 @@ +// 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. + +import { access, mkdir, mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +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 +`; + +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; + + 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 }, + 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..153a1de48c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -0,0 +1,228 @@ +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("StackNotFoundError", () => ({ reason: "flags" as const })), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackUpgradeRequiredError", + "StackRuntimeError", + "StackCleanupError", + () => ({ + reason: "lifecycle" as const, + suggestion: "Stop the stack before starting it again.", + }), + ), + 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..a57781297a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -0,0 +1,536 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +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 } 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, + 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 = Layer.mergeAll( + legacyExperimentalStackApiLayer, + legacyExperimentalStackTargetResolverLayer, +).pipe(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.succeed(undefined), + 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); + }, + 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.provide(BunServices.layer), + 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.provide(BunServices.layer), + 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.message).toContain("lowercase SHA-256"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + }).pipe(Effect.provide(resolverLayer), Effect.provide(BunServices.layer)), + ); + + it.effect("classifies an existing stack runtime mismatch as provided flags", () => { + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => Effect.die("unused"), + 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), + Effect.provide(api), + Effect.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.succeed(undefined); + }, + 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.succeed(undefined); + }, + 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("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.succeed(undefined); + }, + 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"); + }, + 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/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/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/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 9cfdbdd7f7..53436abb54 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -167,6 +167,34 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { + const base = 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000); + 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: { port: base + 1 }, + 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( @@ -1674,7 +1702,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(stack.id) }) .pipe(Effect.exit); expect(Exit.isFailure(directStart)).toBe(true); if (Exit.isFailure(directStart)) { @@ -1684,7 +1712,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(stack.id) }); 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 +1722,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(stack.id) }); expect(startedAgain.lifecycle).toBe("running"); yield* restarted.destroy(); }), @@ -1725,7 +1753,7 @@ describe("Effect stack lifecycle handoff", () => { const compiled = yield* compileStack({ projectRoot, runtime: { kind: "native" }, - config: { capabilities: {} }, + config: lifecycleConfig(id), }); const resolved = yield* resolveSecrets( { 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/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..7dc4b069c2 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -53,6 +53,9 @@ const listenBackend = (server: ReturnType) => () => closeServer(server), ).pipe(Effect.as(server)); +const uniquePort = (stackId: string, offset: number): number => + 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000) + offset; + const request = (port: number, path = "/rest/v1/items", method = "GET", host = "127.0.0.1") => Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { const client = requestHttp({ host, port, path, method }, (response: IncomingMessage) => { @@ -342,7 +345,29 @@ describe("Supervisor ingress", () => { 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: uniquePort(stackId, 0) }, + database: { port: uniquePort(stackId, 1) }, + 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 +384,9 @@ 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: uniquePort(stackId, 0), intent: "automatic" }, + { field: "database", port: uniquePort(stackId, 1), intent: "automatic" }, + { field: "pooler", port: uniquePort(stackId, 2), intent: "automatic" }, ] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -449,7 +474,9 @@ 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: uniquePort(stackId, 1), intent: "automatic" }, + ] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30200 + index, @@ -554,7 +581,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: uniquePort(stackId, 0), intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30300 + index, diff --git a/packages/stack/src/supervisor/startup-ingress.integration.test.ts b/packages/stack/src/supervisor/startup-ingress.integration.test.ts index 5025615f8b..c1956638fa 100644 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ b/packages/stack/src/supervisor/startup-ingress.integration.test.ts @@ -28,6 +28,11 @@ 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 uniquePort = (stackId: string): number => + 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000); const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -110,8 +115,7 @@ describe("startup ingress", () => { Context.add(Path.Path, path), Context.add(Crypto.Crypto, crypto), ); - const listenerBound = - yield* Deferred.make(); + const listenerBound = yield* Deferred.make(); const startEntered = yield* Deferred.make(); const releaseStart = yield* Deferred.make(); const activationCalls = yield* Ref.make(0); @@ -195,7 +199,22 @@ describe("startup ingress", () => { runtime, }); const starting = yield* Effect.forkChild( - supervisor.start({ config: { listeners: { api: { enabled: true } } } }), + supervisor + .start({ + config: { + listeners: { + api: { port: uniquePort(stackId) }, + 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 From c370b5a06e3cc184b7491c7f43ad278e122dfec9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:50:33 +0200 Subject: [PATCH 02/43] fix(cli): validate experimental stack config listeners --- .../stack/stack-config.integration.test.ts | 67 +++++++++++++++++++ .../experimental/stack/stack-config.ts | 18 +++-- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 52635694d0..d4a9522406 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Redacted } from "effect"; +import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; import { legacyLoadStackConfig } from "./stack-config.ts"; @@ -220,6 +221,72 @@ enabled = false }); }); + 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 }); + }); + }); + + 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("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 +`); + 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); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index 3fd6c2774f..6f141fa0fb 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -179,8 +179,8 @@ const apiListener = ( config.edge_runtime.enabled; if (listener === undefined) return listener; if (gatewayEnabled) { - const { enabled: _enabled, ...withoutEnabled } = listener; - return withoutEnabled; + const port = explicitPort(document, "api", "port"); + return port === undefined ? {} : { port }; } return { ...listener, enabled: false }; }; @@ -194,10 +194,8 @@ const listenerFromSection = ( if (raw === undefined) return undefined; const enabled = raw["enabled"]; const port = explicitPort(document, sectionName, portKey); - return { - ...(enabled === false ? { enabled: false } : {}), - ...(port === undefined ? {} : { port }), - }; + if (enabled === false) return { enabled: false }; + return port === undefined ? {} : { port }; }; const nestedPort = ( @@ -221,10 +219,8 @@ const nestedListener = ( const nested = section(parent, nestedSection); if (nested === undefined) return undefined; const port = nestedPort(document, sectionName, nestedSection, portKey); - return { - ...(nested.enabled === false ? { enabled: false } : {}), - ...(port === undefined ? {} : { port }), - }; + if (nested.enabled === false) return { enabled: false }; + return port === undefined ? {} : { port }; }; const legacyAuthSettings = (auth: CliConfig["auth"]) => ({ @@ -614,7 +610,9 @@ const legacyConfigValidationError = ( 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 value of Object.values(functionConfig.env)) { if (typeof value !== "string") continue; const match = /^env\(([A-Za-z_][A-Za-z0-9_]*)\)$/.exec(value); From ea973778aded0859b20e09f32ebda90b92d50707 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:56:44 +0200 Subject: [PATCH 03/43] fix(cli): omit empty experimental stack listeners --- .../experimental/stack/stack-config.integration.test.ts | 9 ++++++++- apps/cli/src/commands/experimental/stack/stack-config.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index d4a9522406..d7f9b432ab 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -212,10 +212,14 @@ enabled = false }); it.effect("leaves listeners absent when ports are omitted", () => { - const root = project(`project_id = "stack-config-defaults"\n`); + 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"); }); @@ -228,6 +232,9 @@ enabled = false 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 }); }); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index 6f141fa0fb..da3d90076b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -195,7 +195,7 @@ const listenerFromSection = ( const enabled = raw["enabled"]; const port = explicitPort(document, sectionName, portKey); if (enabled === false) return { enabled: false }; - return port === undefined ? {} : { port }; + return port === undefined ? undefined : { port }; }; const nestedPort = ( @@ -220,7 +220,7 @@ const nestedListener = ( if (nested === undefined) return undefined; const port = nestedPort(document, sectionName, nestedSection, portKey); if (nested.enabled === false) return { enabled: false }; - return port === undefined ? {} : { port }; + return port === undefined ? undefined : { port }; }; const legacyAuthSettings = (auth: CliConfig["auth"]) => ({ From 2290b64b648ba6a17fbba830e505bee70a9add61 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:05:11 +0200 Subject: [PATCH 04/43] fix(cli): tighten experimental stack start config --- .../stack/stack-config.integration.test.ts | 44 +++++++++++++ .../experimental/stack/stack-config.ts | 65 +++++++++++++++---- .../experimental/stack/stack.shared.ts | 8 +-- .../experimental/stack/start/start.handler.ts | 14 +++- .../stack/start/start.integration.test.ts | 48 +++++++++++++- 5 files changed, 160 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index d7f9b432ab..0b7c22308b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -186,6 +186,50 @@ env = { TOKEN = "env(SUPABASE_STACK_TEST_MISSING_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 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("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] diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index da3d90076b..a56da58499 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -11,7 +11,7 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** A config error suitable for an experimental stack command's user-facing boundary. */ -class LegacyStackConfigError extends Data.TaggedError("LegacyStackConfigError")<{ +export class LegacyStackConfigError extends Data.TaggedError("LegacyStackConfigError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -52,19 +52,38 @@ const parseEnv = (contents: string): Record> = 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) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = path.join(projectRoot, "supabase", "functions"); const read = (file: string) => - fs - .exists(file) - .pipe( - Effect.flatMap((exists) => - exists ? fs.readFileString(file).pipe(Effect.map(parseEnv)) : Effect.succeed({}), - ), - ); + 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>>> = {}; @@ -149,7 +168,7 @@ const authProviderNames = [ ] as const; const legacyStackProjectPath = (value: string): string => - value.length === 0 || value.startsWith("/") || value.startsWith("supabase/") + value.length === 0 || value.startsWith("/") ? value : `supabase/${value.startsWith("./") ? value.slice(2) : value}`; @@ -166,6 +185,22 @@ const legacyFunctionRelativePath = (value: string, name: string): string => { return [...base.slice(common).map(() => ".."), ...target.slice(common)].join("/"); }; +const legacyFunctionPathError = ( + name: string, + field: string, + value: string, +): string | undefined => { + if (value.length === 0) return undefined; + const normalized = value.replace(/^\.\//u, "").replace(/^supabase\//u, ""); + if ( + value.startsWith("/") || + !normalized.startsWith("functions/") || + normalized.split("/").includes("..") + ) + return `functions.${name}.${field} path must be inside supabase/functions`; + return undefined; +}; + const apiListener = ( document: Readonly> | undefined, config: CliConfig, @@ -511,7 +546,6 @@ const legacyFunctionsSettings = ( }; const legacyConfigInput = ( - projectRoot: string, config: CliConfig, document?: Record, projectEnvValues: Readonly> = {}, @@ -528,7 +562,6 @@ const legacyConfigInput = ( const capability = (enabled: boolean, settings: unknown) => enabled ? { settings } : { enabled: false as const }; return { - preparation: "background", capabilities: { database: { version: String(db.major_version), @@ -613,6 +646,15 @@ const legacyConfigValidationError = ( 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(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); @@ -655,7 +697,6 @@ export const legacyLoadStackConfig = (projectRoot: string): LegacyStackConfigEff if (validationError !== undefined) return Effect.fail(new LegacyStackConfigError({ message: validationError })); const input = legacyConfigInput( - projectRoot, context.config, context.loaded?.document, context.projectEnvValues, diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 911504243d..645ce8ef9f 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -22,7 +22,7 @@ interface LegacyExperimentalStackTarget { readonly runtime?: StackRuntimePreference; } -class LegacyExperimentalStackTargetError extends Data.TaggedError( +export class LegacyExperimentalStackTargetError extends Data.TaggedError( "LegacyExperimentalStackTargetError", )<{ readonly message: string; @@ -73,9 +73,9 @@ export const legacyExperimentalStackApiLayer = Layer.succeed(LegacyExperimentalS }); /** Runtime configuration for the first stack command. Later commands reuse this layer. */ -export const legacyExperimentalStackTargetResolverLayer = Layer.effect( +export const legacyExperimentalStackTargetResolverLayer = Layer.succeed( LegacyExperimentalStackTargetResolver, - Effect.succeed({ + { resolve: (input) => Effect.gen(function* () { if (input.id !== undefined && !isStackId(input.id)) { @@ -128,5 +128,5 @@ export const legacyExperimentalStackTargetResolverLayer = Layer.effect( : {}), }; }), - }), + }, ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 153a1de48c..cc5b6c91ca 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -197,10 +197,14 @@ const legacyStackStartError = (error: unknown) => { "StackVersionUnsupportedError", "InvalidStackIdentityError", "InvalidProjectRootError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", "StackSecretMismatchError", "InvalidJwtSigningMaterialError", () => ({ reason: "invalid-config" as const, + suggestion: + "Run supabase experimental stack status to inspect the persisted stack state.", }), ), Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), @@ -210,13 +214,19 @@ const legacyStackStartError = (error: unknown) => { "StackMustBeStoppedError", "StackLifecycleConflictError", "StackUpgradeRequiredError", - "StackRuntimeError", - "StackCleanupError", () => ({ 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({ 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 index a57781297a..88eff3b210 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -5,7 +5,13 @@ 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 } from "@supabase/stack/effect"; +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"; @@ -387,6 +393,46 @@ describe("experimental stack start targeting", () => { ); }); + 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("experimental stack status"); + 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)))); From 02c669ca9baae10f446d28b2066843e99a7382a4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:08:56 +0200 Subject: [PATCH 05/43] test(stack): stabilize ingress listener fixtures --- .../public/effect-stack.integration.test.ts | 13 +++--- .../supervisor/ingress.integration.test.ts | 40 ++++++++++++++----- .../startup-ingress.integration.test.ts | 21 +++++----- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 53436abb54..a0532ee65e 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -167,8 +167,7 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { - const base = 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000); +const lifecycleConfig = () => { return { capabilities: { database: {}, @@ -184,7 +183,7 @@ const lifecycleConfig = (stackId: string) => { }, listeners: { api: { enabled: false }, - database: { port: base + 1 }, + database: { enabled: false }, pooler: { enabled: false }, studio: { enabled: false }, mailUi: { enabled: false }, @@ -1702,7 +1701,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: lifecycleConfig(stack.id) }) + .start({ config: lifecycleConfig() }) .pipe(Effect.exit); expect(Exit.isFailure(directStart)).toBe(true); if (Exit.isFailure(directStart)) { @@ -1712,7 +1711,7 @@ describe("Effect stack lifecycle handoff", () => { expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); } yield* restarted.stop(); - const status = yield* restarted.start({ config: lifecycleConfig(stack.id) }); + 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); @@ -1722,7 +1721,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: lifecycleConfig(stack.id) }); + const startedAgain = yield* restarted.start({ config: lifecycleConfig() }); expect(startedAgain.lifecycle).toBe("running"); yield* restarted.destroy(); }), @@ -1753,7 +1752,7 @@ describe("Effect stack lifecycle handoff", () => { const compiled = yield* compileStack({ projectRoot, runtime: { kind: "native" }, - config: lifecycleConfig(id), + config: lifecycleConfig(), }); const resolved = yield* resolveSecrets( { diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 7dc4b069c2..cea8f35ccb 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -198,6 +198,14 @@ 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 internalApiListener = yield* bindHostListener("::1", apiPort, "api"); const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -230,7 +238,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - ports: [{ field: "api", port: 55432, intent: "automatic" }], + ports: [{ field: "api", port: apiPort, intent: "automatic" }], privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30000 + index, @@ -239,14 +247,15 @@ describe("Supervisor ingress", () => { }); const listenerCloseCount = yield* Ref.make(0); const bindHost = (address: string, port: number, field: HostListener["field"]) => - bindHostListener(address, port, field).pipe( - Effect.map((listener) => ({ - ...listener, - close: listener.close.pipe( - Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), - ), - })), - ); + field !== "api" + ? bindHostListener(address, port, field) + : Effect.succeed({ + ...(address === "::1" ? internalApiListener : apiListener), + port: apiPort, + close: (address === "::1" ? internalApiListener : apiListener).close.pipe( + Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), + ), + }); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, @@ -535,6 +544,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-", @@ -581,7 +597,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - ports: [{ field: "api", port: uniquePort(stackId, 0), intent: "automatic" }] as const, + ports: [{ field: "api", port: apiPort, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30300 + index, @@ -620,6 +636,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 c1956638fa..f051823abe 100644 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ b/packages/stack/src/supervisor/startup-ingress.integration.test.ts @@ -31,9 +31,6 @@ import type { StackLogEntry } from "../public/Logs.ts"; import type { StackError } from "../public/Errors.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; -const uniquePort = (stackId: string): number => - 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000); - const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -116,6 +113,14 @@ describe("startup ingress", () => { Context.add(Crypto.Crypto, crypto), ); 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; + yield* Deferred.succeed(listenerBound, { ...apiListener, port: apiPort }); const startEntered = yield* Deferred.make(); const releaseStart = yield* Deferred.make(); const activationCalls = yield* Ref.make(0); @@ -129,11 +134,9 @@ 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 }) + : bindHostListener(host, port, field); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, @@ -203,7 +206,7 @@ describe("startup ingress", () => { .start({ config: { listeners: { - api: { port: uniquePort(stackId) }, + api: { port: apiPort }, database: { enabled: false }, pooler: { enabled: false }, studio: { enabled: false }, From 99873ecfb0ad9ad30727f4394aa4943525b41735 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:09:10 +0200 Subject: [PATCH 06/43] fix(cli): harden experimental stack config paths --- .../stack/stack-config.integration.test.ts | 29 +++++++ .../experimental/stack/stack-config.ts | 83 +++++++++++++------ .../experimental/stack/start/start.handler.ts | 18 ++-- 3 files changed, 99 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 0b7c22308b..726bf27d93 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -214,6 +214,23 @@ import_map = "./import_map.json" }); }); + 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" @@ -295,6 +312,18 @@ env = { TOKEN = "env(SUPABASE_STACK_TEST_DISABLED_MISSING_ENV)" } }); }); + 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] diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index a56da58499..eb664371a7 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -68,11 +68,16 @@ const validateEnvKeys = ( ); }; -const legacyReadFunctionEnvironments = (projectRoot: string) => +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: {}, path }; const read = (file: string) => fs.exists(file).pipe( Effect.flatMap((exists) => @@ -91,10 +96,11 @@ const legacyReadFunctionEnvironments = (projectRoot: string) => 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 }; + return { shared, functions: result, path }; }); const section = (document: Readonly> | undefined, name: string) => { @@ -174,29 +180,39 @@ const legacyStackProjectPath = (value: string): string => /** Converts a CLI function path (relative to supabase/) to the stack resolver's * function-directory-relative form. */ -const legacyFunctionRelativePath = (value: string, name: string): string => { - if (value.length === 0 || value.startsWith("/")) return value; - const normalized = value.replace(/^\.\//, "").replace(/^supabase\//, ""); - const target = normalized.split("/").filter((part) => part.length > 0); - const base = ["functions", name]; - let common = 0; - while (common < base.length && common < target.length && base[common] === target[common]) - common += 1; - return [...base.slice(common).map(() => ".."), ...target.slice(common)].join("/"); +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 normalized = value.replace(/^\.\//u, "").replace(/^supabase\//u, ""); - if ( - value.startsWith("/") || - !normalized.startsWith("functions/") || - normalized.split("/").includes("..") - ) + 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 (relative === ".." || relative.startsWith(`..${path.sep}`)) return `functions.${name}.${field} path must be inside supabase/functions`; return undefined; }; @@ -483,6 +499,8 @@ const legacyAuthSettings = (auth: CliConfig["auth"]) => ({ }); const legacyFunctionsSettings = ( + projectRoot: string, + path: Path.Path, config: CliConfig, document?: Record, projectEnvValues: Readonly> = {}, @@ -497,15 +515,15 @@ const legacyFunctionsSettings = ( ...(value.verify_jwt === undefined ? {} : { verify_jwt: value.verify_jwt }), ...(value.import_map === undefined ? {} - : { import_map: legacyFunctionRelativePath(value.import_map, name) }), + : { import_map: legacyFunctionRelativePath(path, projectRoot, value.import_map, name) }), ...(value.entrypoint === undefined ? {} - : { entrypoint: legacyFunctionRelativePath(value.entrypoint, name) }), + : { entrypoint: legacyFunctionRelativePath(path, projectRoot, value.entrypoint, name) }), ...(value.static_files === undefined ? {} : { - static_files: value.static_files.map((path) => - legacyFunctionRelativePath(path, name), + static_files: value.static_files.map((filePath) => + legacyFunctionRelativePath(path, projectRoot, filePath, name), ), }), env: Object.fromEntries( @@ -546,6 +564,8 @@ const legacyFunctionsSettings = ( }; const legacyConfigInput = ( + projectRoot: string, + path: Path.Path, config: CliConfig, document?: Record, projectEnvValues: Readonly> = {}, @@ -590,7 +610,7 @@ const legacyConfigInput = ( }), functions: capability( config.edge_runtime.enabled, - legacyFunctionsSettings(config, document, projectEnvValues), + legacyFunctionsSettings(projectRoot, path, config, document, projectEnvValues), ), studio: capability(studio.enabled, { api_url: studio.api_url, @@ -637,6 +657,8 @@ const legacyConfigInput = ( }; const legacyConfigValidationError = ( + path: Path.Path, + projectRoot: string, config: CliConfig, projectEnvValues: Readonly>, ): string | undefined => { @@ -652,7 +674,7 @@ const legacyConfigValidationError = ( ...functionConfig.static_files.map((path) => ["static_files", path] as const), ] as const) { if (typeof value !== "string") continue; - const pathError = legacyFunctionPathError(name, field, value); + const pathError = legacyFunctionPathError(path, projectRoot, name, field, value); if (pathError !== undefined) return pathError; } for (const value of Object.values(functionConfig.env)) { @@ -679,7 +701,15 @@ export const legacyLoadStackConfig = (projectRoot: string): LegacyStackConfigEff message: `No Supabase project configuration found in ${projectRoot}. Run supabase init first.`, }), ) - : legacyReadFunctionEnvironments(projectRoot).pipe( + : 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( ( @@ -688,15 +718,20 @@ export const legacyLoadStackConfig = (projectRoot: string): LegacyStackConfigEff readonly functions: Readonly< Record>>> >; + readonly path: Path.Path; }>, ) => { const validationError = legacyConfigValidationError( + environments.path, + projectRoot, context.config, context.projectEnvValues, ); if (validationError !== undefined) return Effect.fail(new LegacyStackConfigError({ message: validationError })); const input = legacyConfigInput( + projectRoot, + environments.path, context.config, context.loaded?.document, context.projectEnvValues, diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index cc5b6c91ca..c3edbfada5 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -197,16 +197,20 @@ const legacyStackStartError = (error: unknown) => { "StackVersionUnsupportedError", "InvalidStackIdentityError", "InvalidProjectRootError", - "StackStateInvalidError", - "StackStateFormatUnsupportedError", "StackSecretMismatchError", "InvalidJwtSigningMaterialError", - () => ({ - reason: "invalid-config" as const, - suggestion: - "Run supabase experimental stack status to inspect the persisted stack state.", - }), + () => ({ reason: "invalid-config" as const }), ), + Match.tag("StackStateInvalidError", () => ({ + reason: "invalid-config" as const, + suggestion: + "Run supabase experimental stack status to inspect the persisted stack state.", + })), + Match.tag("StackStateFormatUnsupportedError", () => ({ + reason: "invalid-config" as const, + suggestion: + "Run supabase experimental stack status to inspect the persisted state and use a compatible stack version.", + })), Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), Match.tag( "StackOwnershipConflictError", From e44ab9fa89e2b56456aab7fe016641f0f7c3a22b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:10:36 +0200 Subject: [PATCH 07/43] test(stack): stabilize ingress listener fixtures --- .../supervisor/ingress.integration.test.ts | 27 +++++++------------ .../startup-ingress.integration.test.ts | 5 ++-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index cea8f35ccb..d922bed9e4 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -198,14 +198,6 @@ 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 internalApiListener = yield* bindHostListener("::1", apiPort, "api"); const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -238,7 +230,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - ports: [{ field: "api", port: apiPort, intent: "automatic" }], + ports: [{ field: "api", port: 55432, intent: "automatic" }], privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30000 + index, @@ -247,15 +239,14 @@ describe("Supervisor ingress", () => { }); const listenerCloseCount = yield* Ref.make(0); const bindHost = (address: string, port: number, field: HostListener["field"]) => - field !== "api" - ? bindHostListener(address, port, field) - : Effect.succeed({ - ...(address === "::1" ? internalApiListener : apiListener), - port: apiPort, - close: (address === "::1" ? internalApiListener : apiListener).close.pipe( - Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), - ), - }); + bindHostListener(address, port, field).pipe( + Effect.map((listener) => ({ + ...listener, + close: listener.close.pipe( + Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), + ), + })), + ); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, diff --git a/packages/stack/src/supervisor/startup-ingress.integration.test.ts b/packages/stack/src/supervisor/startup-ingress.integration.test.ts index f051823abe..ed5db938bd 100644 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ b/packages/stack/src/supervisor/startup-ingress.integration.test.ts @@ -120,7 +120,6 @@ describe("startup ingress", () => { if (typeof apiAddress !== "object" || apiAddress === null) return yield* Effect.die("API listener did not expose an address"); const apiPort = apiAddress.port; - yield* Deferred.succeed(listenerBound, { ...apiListener, port: apiPort }); const startEntered = yield* Deferred.make(); const releaseStart = yield* Deferred.make(); const activationCalls = yield* Ref.make(0); @@ -135,7 +134,9 @@ describe("startup ingress", () => { field: import("../public/Status.ts").PortField, ) => field === "api" - ? Effect.succeed({ ...apiListener, port: apiPort }) + ? Effect.succeed({ ...apiListener, port: apiPort }).pipe( + Effect.tap((listener) => Deferred.succeed(listenerBound, listener)), + ) : bindHostListener(host, port, field); const ingress = yield* makeSupervisorIngress({ stackId, From 9acb080005a2c4b2f603e435264f08b2bb508051 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:13:08 +0200 Subject: [PATCH 08/43] test(stack): stabilize remaining ingress ports --- .../supervisor/ingress.integration.test.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index d922bed9e4..b5a3d2d6ac 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -53,9 +53,6 @@ const listenBackend = (server: ReturnType) => () => closeServer(server), ).pipe(Effect.as(server)); -const uniquePort = (stackId: string, offset: number): number => - 42_000 + (Number.parseInt(stackId.slice(0, 6), 16) % 7_000) + offset; - const request = (port: number, path = "/rest/v1/items", method = "GET", host = "127.0.0.1") => Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { const client = requestHttp({ host, port, path, method }, (response: IncomingMessage) => { @@ -342,6 +339,16 @@ 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" }, @@ -358,8 +365,8 @@ describe("Supervisor ingress", () => { pooler: { enabled: false }, }, listeners: { - api: { port: uniquePort(stackId, 0) }, - database: { port: uniquePort(stackId, 1) }, + api: { port: apiPort }, + database: { port: databasePort }, pooler: { enabled: false }, studio: { enabled: false }, mailUi: { enabled: false }, @@ -384,9 +391,8 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running", definition: compiled.definition, ports: [ - { field: "api", port: uniquePort(stackId, 0), intent: "automatic" }, - { field: "database", port: uniquePort(stackId, 1), intent: "automatic" }, - { field: "pooler", port: uniquePort(stackId, 2), intent: "automatic" }, + { field: "api", port: apiPort, intent: "automatic" }, + { field: "database", port: databasePort, intent: "automatic" }, ] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -394,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, @@ -440,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" }, @@ -474,9 +496,7 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - ports: [ - { field: "database", port: uniquePort(stackId, 1), intent: "automatic" }, - ] as const, + ports: [{ field: "database", port: databasePort, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, port: 30200 + index, @@ -489,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" })), }); From 8c29ef700cbef88ba5d67ae8fbcc20539af33edd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:14:07 +0200 Subject: [PATCH 09/43] fix(cli): scope stack config path service --- .../experimental/stack/stack-config.ts | 220 +++++++++--------- 1 file changed, 111 insertions(+), 109 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index eb664371a7..dc1bb51156 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -100,7 +100,7 @@ const legacyReadFunctionEnvironments = ( const functionEnv = yield* read(path.join(root, entry, ".env")); result[entry] = { ...shared, ...functionEnv }; } - return { shared, functions: result, path }; + return { shared, functions: result }; }); const section = (document: Readonly> | undefined, name: string) => { @@ -212,7 +212,7 @@ const legacyFunctionPathError = ( path.join(projectRoot, "supabase", value.startsWith("./") ? value.slice(2) : value), ); const relative = path.relative(functionsRoot, target); - if (relative === ".." || relative.startsWith(`..${path.sep}`)) + if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return `functions.${name}.${field} path must be inside supabase/functions`; return undefined; }; @@ -690,118 +690,120 @@ const legacyConfigValidationError = ( /** Loads and translates the effective project config for all experimental stack commands. */ export const legacyLoadStackConfig = (projectRoot: string): LegacyStackConfigEffect => - 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>>> - >; - readonly path: Path.Path; - }>, - ) => { - const validationError = legacyConfigValidationError( - environments.path, - projectRoot, - context.config, - context.projectEnvValues, - ); - if (validationError !== undefined) - return Effect.fail(new LegacyStackConfigError({ message: validationError })); - const input = legacyConfigInput( - projectRoot, - environments.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 + 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 : {}), + 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)}`, - }), + }); + }, + ), + Effect.flatMap((input) => + Schema.decodeUnknownEffect(StackConfigSchema)(withoutUndefined(input), { + onExcessProperty: "error", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStackConfigError({ + message: `invalid stack config: ${String(cause)}`, + }), + ), ), ), ), - ), - ), - ); + ), + ); + }); From cb78d993db4072ed50f3323ddf0a939f247c9019 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:15:50 +0200 Subject: [PATCH 10/43] fix(cli): clarify stack state recovery guidance --- apps/cli/src/commands/experimental/stack/stack-config.ts | 2 +- .../src/commands/experimental/stack/start/start.handler.ts | 4 ++-- .../experimental/stack/start/start.integration.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index dc1bb51156..2ffefe4052 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -77,7 +77,7 @@ const legacyReadFunctionEnvironments = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = path.join(projectRoot, "supabase", "functions"); - if (skip) return { shared: {}, functions: {}, path }; + if (skip) return { shared: {}, functions: {} }; const read = (file: string) => fs.exists(file).pipe( Effect.flatMap((exists) => diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index c3edbfada5..622c3ccdde 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -204,12 +204,12 @@ const legacyStackStartError = (error: unknown) => { Match.tag("StackStateInvalidError", () => ({ reason: "invalid-config" as const, suggestion: - "Run supabase experimental stack status to inspect the persisted stack state.", + "Inspect the reported state error and restore a valid state record before retrying.", })), Match.tag("StackStateFormatUnsupportedError", () => ({ reason: "invalid-config" as const, suggestion: - "Run supabase experimental stack status to inspect the persisted state and use a compatible stack version.", + "Use a CLI version compatible with the persisted stack state.", })), Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), Match.tag( 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 index 88eff3b210..1e69df5362 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -424,7 +424,7 @@ describe("experimental stack start targeting", () => { expect(failure).toBeInstanceOf(LegacyExperimentalStackStartError); if (failure instanceof LegacyExperimentalStackStartError) { expect(failure.reason).toBe("invalid-config"); - expect(failure.suggestion).toContain("experimental stack status"); + expect(failure.suggestion).toContain("restore a valid state record"); expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); } }).pipe( From ef2ab0a63f9a6e45dc3be4db21807f285ed4a788 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:18:19 +0200 Subject: [PATCH 11/43] style(cli): format stack start handler --- .../cli/src/commands/experimental/stack/start/start.handler.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 622c3ccdde..008f81c047 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -208,8 +208,7 @@ const legacyStackStartError = (error: unknown) => { })), Match.tag("StackStateFormatUnsupportedError", () => ({ reason: "invalid-config" as const, - suggestion: - "Use a CLI version compatible with the persisted stack state.", + suggestion: "Use a CLI version compatible with the persisted stack state.", })), Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), Match.tag( From 3055b18d42bb8905bd0a682bd291ef7f5d64b5a7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:20:27 +0200 Subject: [PATCH 12/43] test(cli): consume exported stack config errors --- .../experimental/stack/stack-config.integration.test.ts | 7 +++++-- .../experimental/stack/start/start.integration.test.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 726bf27d93..c8a5b236f3 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Redacted } from "effect"; +import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; -import { legacyLoadStackConfig } from "./stack-config.ts"; +import { LegacyStackConfigError, legacyLoadStackConfig } from "./stack-config.ts"; const load = (projectRoot: string) => legacyLoadStackConfig(projectRoot).pipe(Effect.provide(BunServices.layer)); @@ -193,6 +193,9 @@ env = { TOKEN = "env(SUPABASE_STACK_TEST_MISSING_ENV)" } 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"); 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 index 1e69df5362..6ff95aa8b0 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -17,6 +17,7 @@ import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; import { legacyExperimentalStackApiLayer, + LegacyExperimentalStackTargetError, LegacyExperimentalStackTargetResolver, legacyExperimentalStackTargetResolverLayer, LegacyExperimentalStackApi, @@ -183,6 +184,7 @@ describe("experimental stack start targeting", () => { 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), Effect.provide(BunServices.layer)), From 03ccaa656505fa6128b03e0a74a533c176d97065 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:22 +0200 Subject: [PATCH 13/43] chore(cli): extend experimental stack effect lint --- AGENTS.md | 2 +- .../stack/stack-config.integration.test.ts | 2 + .../experimental/stack/stack.shared.ts | 52 +++++++++++++++---- .../stack/start/start.e2e.test.ts | 6 +++ .../stack/start/start.integration.test.ts | 31 ++++++----- package.json | 4 +- 6 files changed, 71 insertions(+), 26 deletions(-) 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/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index c8a5b236f3..0bfe577796 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -1,5 +1,7 @@ +// 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"; diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 645ce8ef9f..c71931c902 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -8,6 +8,7 @@ import { } 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, @@ -48,7 +49,7 @@ interface LegacyExperimentalStackTargetResolverShape { }) => Effect.Effect< LegacyExperimentalStackTarget, LegacyExperimentalStackTargetError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto | LegacyExperimentalStackApi + LegacyExperimentalStackApi >; } @@ -60,17 +61,50 @@ export class LegacyExperimentalStackTargetResolver extends Context.Service< export class LegacyExperimentalStackApi extends Context.Service< LegacyExperimentalStackApi, { - readonly createStack: typeof createStack; - readonly openStack: typeof openStack; - readonly inspectStack: typeof inspectStack; + readonly createStack: ( + ...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.succeed(LegacyExperimentalStackApi, { - createStack, - openStack, - inspectStack, -}); +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)), + 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( 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 index df0250610f..dd0d3b8c17 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -3,8 +3,11 @@ // 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"; @@ -47,6 +50,7 @@ enabled = false 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"; @@ -90,6 +94,7 @@ describe("experimental stack start (compiled e2e)", () => { 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) { @@ -119,6 +124,7 @@ describe("experimental stack start (compiled e2e)", () => { 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-")); 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 index 6ff95aa8b0..6ce4abe6bb 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1,5 +1,7 @@ +// 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"; @@ -42,10 +44,10 @@ const project = (): string => { return root; }; -const resolverLayer = Layer.mergeAll( - legacyExperimentalStackApiLayer, - legacyExperimentalStackTargetResolverLayer, -).pipe(Layer.provide(BunServices.layer)); +const resolverLayer = legacyExperimentalStackTargetResolverLayer.pipe( + Layer.provideMerge(legacyExperimentalStackApiLayer), + Layer.provide(BunServices.layer), +); const status = (id: string, runtime: "native" | "container" = "native") => ({ @@ -86,7 +88,7 @@ function fakeStack( credentials: () => Effect.die("credentials not used in start test"), prepare: () => Effect.die("prepare not used in start test"), start, - stop: () => Effect.succeed(undefined), + stop: () => Effect.void, destroy: () => Effect.die("destroy not used in start test"), logs: () => Effect.die("logs not used in start test"), followLogs: () => Stream.empty, @@ -155,7 +157,6 @@ describe("experimental stack start targeting", () => { expect(target.runtime).toBeUndefined(); }).pipe( Effect.provide(resolverLayer), - Effect.provide(BunServices.layer), Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), ); }); @@ -173,7 +174,6 @@ describe("experimental stack start targeting", () => { expect(target.runtime).toEqual({ kind: "native" }); }).pipe( Effect.provide(resolverLayer), - Effect.provide(BunServices.layer), Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), ); }); @@ -187,7 +187,7 @@ describe("experimental stack start targeting", () => { expect(failure).toBeInstanceOf(LegacyExperimentalStackTargetError); expect(failure.message).toContain("lowercase SHA-256"); expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); - }).pipe(Effect.provide(resolverLayer), Effect.provide(BunServices.layer)), + }).pipe(Effect.provide(resolverLayer)), ); it.effect("classifies an existing stack runtime mismatch as provided flags", () => { @@ -214,9 +214,12 @@ describe("experimental stack start targeting", () => { .pipe(Effect.flip); expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); }).pipe( - Effect.provide(legacyExperimentalStackTargetResolverLayer), - Effect.provide(api), - Effect.provide(BunServices.layer), + Effect.provide( + legacyExperimentalStackTargetResolverLayer.pipe( + Layer.provideMerge(api), + Layer.provide(BunServices.layer), + ), + ), ); }); @@ -319,7 +322,7 @@ describe("experimental stack start targeting", () => { ), stop: () => { stopped = true; - return Effect.succeed(undefined); + return Effect.void; }, destroy: () => Effect.sync(() => { @@ -352,7 +355,7 @@ describe("experimental stack start targeting", () => { ...fakeStack("f".repeat(64), () => Effect.succeed(status("f".repeat(64)))), stop: () => { stopped = true; - return Effect.succeed(undefined); + return Effect.void; }, destroy: () => Effect.sync(() => { @@ -467,7 +470,7 @@ describe("experimental stack start targeting", () => { ), stop: () => { stopped = true; - return Effect.succeed(undefined); + return Effect.void; }, destroy: () => Effect.sync(() => { 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", From bf1269f13b57ed58876d57824b83a411ab1c5bc1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:11:19 +0200 Subject: [PATCH 14/43] fix(stack): explain signing key path restrictions --- packages/stack/src/state/SecretStore.ts | 8 ++++---- packages/stack/src/state/secrets.integration.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) 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)), ); From 99d490c359c58f803f3878a0678ed0f4fd1c9981 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 14:22:12 +0200 Subject: [PATCH 15/43] fix(cli): retain the API listener for analytics --- .../stack/stack-config.integration.test.ts | 24 +++++++++++++++++++ .../experimental/stack/stack-config.ts | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 0bfe577796..3fb5fa0739 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -266,6 +266,28 @@ enabled = true }); }); + 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] @@ -356,6 +378,8 @@ pop3_port = 55437 [edge_runtime] enabled = false inspector_port = 55438 +[analytics] +enabled = false `); return Effect.gen(function* () { const config = yield* load(root); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.ts b/apps/cli/src/commands/experimental/stack/stack-config.ts index 2ffefe4052..0b85412819 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.ts @@ -227,7 +227,8 @@ const apiListener = ( config.auth.enabled || config.realtime.enabled || config.storage.enabled || - config.edge_runtime.enabled; + config.edge_runtime.enabled || + config.analytics.enabled; if (listener === undefined) return listener; if (gatewayEnabled) { const port = explicitPort(document, "api", "port"); From 6a5fac64fb5a8493ecd30257dbccae310de7cab4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:51:06 +0200 Subject: [PATCH 16/43] feat(cli): add experimental stack stop --- .../experimental/stack/stack.command.ts | 6 +- .../stack/start/start.integration.test.ts | 3 + .../experimental/stack/stop/stop.command.ts | 23 ++ .../experimental/stack/stop/stop.errors.ts | 28 +++ .../experimental/stack/stop/stop.handler.ts | 121 +++++++++ .../stack/stop/stop.integration.test.ts | 236 ++++++++++++++++++ 6 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/experimental/stack/stop/stop.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/stop/stop.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/stop/stop.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index c740eacd73..a6240680ee 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -3,6 +3,7 @@ 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 { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -11,7 +12,10 @@ import { 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]), + Command.withSubcommands([ + legacyExperimentalStackStartCommand, + legacyExperimentalStackStopCommand, + ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), Command.provide(legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer))), 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 index 6ce4abe6bb..5d6b290c70 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -124,6 +124,7 @@ function handlerLayer(opts: { opts.onCreate?.(options); return Effect.succeed(opts.stack); }, + findStack: () => Effect.succeed(Option.none()), openStack: () => { opts.onOpen?.(); return Effect.succeed(opts.stack); @@ -193,6 +194,7 @@ describe("experimental stack start targeting", () => { 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()), openStack: () => Effect.die("unused"), inspectStack: () => Effect.succeed({ @@ -511,6 +513,7 @@ describe("experimental stack start targeting", () => { created = true; return Effect.die("create should not run"); }, + findStack: () => Effect.succeed(Option.none()), openStack: () => Effect.die("open should not run"), inspectStack: () => Effect.die("inspect should not run"), }), 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..01da45ae4d --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -0,0 +1,23 @@ +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("Name this 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.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..f2bea446fd --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -0,0 +1,121 @@ +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", () => ({ reason: "flags" as const })), + Match.tag( + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackRuntimeError", + "StackCleanupError", + () => ({ reason: "lifecycle" as const }), + ), + Match.tag( + "InvalidStackConfigError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => ({ reason: "invalid-config" as const }), + ), + Match.orElse(() => ({ reason: "unknown" as const })), + ); + return new LegacyExperimentalStackStopError({ + ...classification, + message: stackError?.message ?? String(error), + cause: error, + }); +}; + +const stoppedPayload = (id: StackDescriptor["id"]) => ({ 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.succeed("Stack stopped.")), + 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..429ab8d0b3 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -0,0 +1,236 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +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 { StackIdSchema, StackStateInvalidError } from "@supabase/stack/effect"; +import type { EffectStack, 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 { + legacyExperimentalStackStop, + legacyValidateExperimentalStackStopTarget, +} from "./stop.handler.ts"; +import { LegacyExperimentalStackStopError } from "./stop.errors.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; +}) { + const out = mockOutput(); + const state = { + findInputs: [] as Array<{ projectRoot: string; name?: string }>, + openedIds: [] as string[], + stopCalls: 0, + retainedRows: ["retained-row"], + }; + 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.die("must not destroy"), + 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"), + findStack: (input) => + Effect.sync(() => { + state.findInputs.push(input); + return descriptor === undefined ? Option.none() : Option.some(descriptor); + }), + openStack: (stackId) => + 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"); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(true); + }).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", () => { + 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 while retaining its 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.retainedRows).toEqual(["retained-row"]); + }).pipe( + Effect.provide(setupResult.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("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", () => { + 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("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 }))), + ); + }); +}); From d0733f850a3fe4a3c199f2fb6c54b13a467288d3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:59:25 +0200 Subject: [PATCH 17/43] docs(cli): document experimental stack stop effects --- .../experimental/stack/stop/SIDE_EFFECTS.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md 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..4449db20b6 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -0,0 +1,23 @@ +# `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. + +## 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. Standard command instrumentation records command metadata; stack data and +credentials are not emitted as telemetry properties. From 08de2391c1f04598fcd5de7e656b819b6a0b8103 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:03:09 +0200 Subject: [PATCH 18/43] fix(cli): refine experimental stack stop output --- .../experimental/stack/stop/stop.command.ts | 11 +++- .../experimental/stack/stop/stop.handler.ts | 7 ++- .../stack/stop/stop.integration.test.ts | 59 ++++++++++++++++--- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 01da45ae4d..8df1870733 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -4,7 +4,10 @@ import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-c import { legacyExperimentalStackStop } from "./stop.handler.ts"; const config = { - stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), + 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, @@ -14,6 +17,12 @@ const config = { 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 }), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index f2bea446fd..3a4bd8a817 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -46,10 +46,13 @@ const stopError = (error: unknown): LegacyExperimentalStackStopError => { ), Match.tag( "InvalidStackConfigError", + "InvalidStackIdentityError", + "StackStateFormatUnsupportedError", "InvalidProjectRootError", "StackStateInvalidError", () => ({ reason: "invalid-config" as const }), ), + Match.tag("StackUpgradeRequiredError", () => ({ reason: "lifecycle" as const })), Match.orElse(() => ({ reason: "unknown" as const })), ); return new LegacyExperimentalStackStopError({ @@ -59,7 +62,7 @@ const stopError = (error: unknown): LegacyExperimentalStackStopError => { }); }; -const stoppedPayload = (id: StackDescriptor["id"]) => ({ id, lifecycle: "stopped" }); +const stoppedPayload = (id: StackDescriptor["id"]) => ({ found: true, id, lifecycle: "stopped" }); export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack.stop")(function* ( flags: LegacyExperimentalStackStopFlags, @@ -113,7 +116,7 @@ export const legacyExperimentalStackStop = Effect.fn("legacy.experimental.stack. const stopping = yield* output.task(`Stopping stack ${target.id}...`); yield* stack.stop().pipe( Effect.tapError((error) => stopping.fail(error.message)), - Effect.tap(() => stopping.succeed("Stack stopped.")), + Effect.tap(() => stopping.clear()), Effect.mapError(stopError), ); if (output.format === "text") yield* output.raw(`Stack ${target.id} stopped.\n`); 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 index 429ab8d0b3..51234063ae 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -4,7 +4,7 @@ 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 { StackIdSchema, StackStateInvalidError } from "@supabase/stack/effect"; +import { StackIdSchema, StackNotFoundError, StackStateInvalidError } from "@supabase/stack/effect"; import type { EffectStack, StackStatus, StackStopError } from "@supabase/stack/effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -41,13 +41,14 @@ function setup(opts: { root: string; found?: { id: string; name?: string }; stop?: () => Effect.Effect; + openFailure?: StackNotFoundError; }) { const out = mockOutput(); const state = { findInputs: [] as Array<{ projectRoot: string; name?: string }>, openedIds: [] as string[], stopCalls: 0, - retainedRows: ["retained-row"], + destroyCalled: false, }; const id = opts.found?.id ?? "a".repeat(64); const stack = { @@ -62,7 +63,10 @@ function setup(opts: { Effect.sync(() => { state.stopCalls += 1; })), - destroy: () => Effect.die("must not destroy"), + destroy: () => + Effect.sync(() => { + state.destroyCalled = true; + }), logs: () => Effect.die("unused"), followLogs: () => Stream.empty, } satisfies EffectStack; @@ -86,11 +90,13 @@ function setup(opts: { state.findInputs.push(input); return descriptor === undefined ? Option.none() : Option.some(descriptor); }), - openStack: (stackId) => - Effect.sync(() => { + 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, @@ -111,7 +117,6 @@ describe("experimental stack stop", () => { expect(setupResult.state.openedIds).toEqual(["a".repeat(64)]); expect(setupResult.state.stopCalls).toBe(1); expect(setupResult.out.stdoutText).toContain("stopped"); - expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(true); }).pipe( Effect.provide(setupResult.layer), Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), @@ -130,20 +135,56 @@ describe("experimental stack stop", () => { }).pipe(Effect.provide(setupResult.layer)); }); - it.effect("stops an already stopped stack repeatedly while retaining its data", () => { + 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.retainedRows).toEqual(["retained-row"]); + 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 }); From a2035be0d61f36550b5a21d3ca22b403bbaa87ff Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:04:51 +0200 Subject: [PATCH 19/43] fix(cli): classify invalid stack stop names --- .../experimental/stack/stop/stop.handler.ts | 5 +-- .../stack/stop/stop.integration.test.ts | 33 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index 3a4bd8a817..930b19a06b 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -34,7 +34,9 @@ const stopError = (error: unknown): LegacyExperimentalStackStopError => { stackError === undefined ? { reason: "unknown" as const } : Match.value(stackError).pipe( - Match.tag("StackNotFoundError", () => ({ reason: "flags" as const })), + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), Match.tag( "StackOwnershipConflictError", "StackNotRunningError", @@ -46,7 +48,6 @@ const stopError = (error: unknown): LegacyExperimentalStackStopError => { ), Match.tag( "InvalidStackConfigError", - "InvalidStackIdentityError", "StackStateFormatUnsupportedError", "InvalidProjectRootError", "StackStateInvalidError", 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 index 51234063ae..4c0f78892c 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -4,7 +4,12 @@ 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 { StackIdSchema, StackNotFoundError, StackStateInvalidError } from "@supabase/stack/effect"; +import { + InvalidStackIdentityError, + StackIdSchema, + StackNotFoundError, + StackStateInvalidError, +} from "@supabase/stack/effect"; import type { EffectStack, StackStatus, StackStopError } from "@supabase/stack/effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliSettings } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -42,6 +47,7 @@ function setup(opts: { found?: { id: string; name?: string }; stop?: () => Effect.Effect; openFailure?: StackNotFoundError; + findFailure?: InvalidStackIdentityError; }) { const out = mockOutput(); const state = { @@ -89,7 +95,11 @@ function setup(opts: { 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(() => { @@ -202,6 +212,25 @@ describe("experimental stack stop", () => { ); }); + 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 }); From 4b5985687888c5393ec4123ad639b775b31e8dc2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:11:05 +0200 Subject: [PATCH 20/43] test(cli): cover experimental stack stop parsing --- .../experimental/stack/stop/SIDE_EFFECTS.md | 9 +++-- .../stack/stop/stop.integration.test.ts | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index 4449db20b6..95c71bb4b8 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -13,11 +13,14 @@ 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. +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. Standard command instrumentation records command metadata; stack data and -credentials are not emitted as telemetry properties. +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. 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.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 4c0f78892c..300e9f7431 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -4,10 +4,12 @@ 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, StackStateInvalidError, } from "@supabase/stack/effect"; import type { EffectStack, StackStatus, StackStopError } from "@supabase/stack/effect"; @@ -19,11 +21,13 @@ import { 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), @@ -289,6 +293,23 @@ describe("experimental stack stop", () => { ); }); + 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("rejects the legacy output flag with actionable guidance", () => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-stop-output-")); const setupResult = setup({ root }); @@ -304,3 +325,18 @@ describe("experimental stack stop", () => { ); }); }); + +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()))), + ); + }); +}); From b0a2677d7891d0758ecfe4f01140a678df4b41ca Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:17:53 +0200 Subject: [PATCH 21/43] test(cli): cover experimental stack stop errors --- .../experimental/stack/stop/SIDE_EFFECTS.md | 3 +- .../stack/stop/stop.integration.test.ts | 79 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index 95c71bb4b8..fd67a07bac 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -22,5 +22,6 @@ returning. That process is package-owned and is not managed directly by the CLI. 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. Standard command instrumentation records command +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.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 300e9f7431..48ce63985d 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -10,9 +10,18 @@ import { StackIdSchema, StackNotFoundError, StackOwnershipConflictError, + StackRuntimeMismatchError, + StackStateFormatUnsupportedError, StackStateInvalidError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import type { + EffectStack, + OpenStackError, + StackDiscoveryError, + StackStatus, + StackStopError, } from "@supabase/stack/effect"; -import type { EffectStack, 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"; @@ -50,8 +59,8 @@ function setup(opts: { root: string; found?: { id: string; name?: string }; stop?: () => Effect.Effect; - openFailure?: StackNotFoundError; - findFailure?: InvalidStackIdentityError; + openFailure?: OpenStackError; + findFailure?: StackDiscoveryError; }) { const out = mockOutput(); const state = { @@ -310,6 +319,70 @@ describe("experimental stack stop", () => { ); }); + 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 }); From 52f511d60d19fd3b912bfcf8082392f85276e835 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:52 +0200 Subject: [PATCH 22/43] chore(cli): annotate stack stop fixtures --- .../commands/experimental/stack/stop/stop.integration.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 index 48ce63985d..602ddfb1ed 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -1,5 +1,7 @@ +// 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"; @@ -147,6 +149,7 @@ describe("experimental stack stop", () => { }); 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 } }); @@ -262,6 +265,7 @@ describe("experimental stack stop", () => { }); 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* () { From 63c9f23e8a2fb1c707b445f04ebfd26e9a9f0d84 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:53:53 +0200 Subject: [PATCH 23/43] test(cli): complete stack stop service mocks --- .../commands/experimental/stack/stop/stop.integration.test.ts | 1 + 1 file changed, 1 insertion(+) 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 index 602ddfb1ed..2337c88a64 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -106,6 +106,7 @@ function setup(opts: { mockLegacyCliSettings({ workdir: opts.root }), Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), + listStacks: () => Effect.succeed([]), findStack: (input) => Effect.sync(() => { state.findInputs.push(input); From c9116699b1ff93f281b59f09e2f244974d7882cf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:57:51 +0200 Subject: [PATCH 24/43] feat(cli): expose stack discovery service --- apps/cli/src/commands/experimental/stack/stack.shared.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index c71931c902..103226191d 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,6 +1,7 @@ import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect"; import { createStack, + findStack, inspectStack, isStackId, openStack, @@ -67,6 +68,12 @@ export class LegacyExperimentalStackApi extends Context.Service< Effect.Success>, Effect.Error> >; + readonly findStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; readonly openStack: ( ...args: Parameters ) => Effect.Effect< @@ -99,6 +106,7 @@ export const legacyExperimentalStackApiLayer = Layer.effect( return { createStack: (...args: Parameters) => provideServices(createStack(...args)), + findStack: (...args: Parameters) => provideServices(findStack(...args)), openStack: (...args: Parameters) => provideServices(openStack(...args)), inspectStack: (...args: Parameters) => provideServices(inspectStack(...args)), From 865c92fd59ff639193d4a3aa299c1f2af3223556 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 08:00:11 +0200 Subject: [PATCH 25/43] test(cli): scope stop mock to available stack methods --- .../commands/experimental/stack/stop/stop.integration.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 2337c88a64..602ddfb1ed 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -106,7 +106,6 @@ function setup(opts: { mockLegacyCliSettings({ workdir: opts.root }), Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), - listStacks: () => Effect.succeed([]), findStack: (input) => Effect.sync(() => { state.findInputs.push(input); From d876873b6938e350f9ec269918dc6c88fb40b19f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:34:37 +0200 Subject: [PATCH 26/43] feat(stack): report redacted configuration drift --- packages/stack/src/index.ts | 1 + packages/stack/src/public/EffectStack.ts | 135 +++++++++++- packages/stack/src/public/PromiseStack.ts | 18 +- packages/stack/src/public/Status.ts | 6 + .../public/config-drift.integration.test.ts | 203 ++++++++++++++++++ packages/stack/src/public/index.ts | 1 + 6 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 packages/stack/src/public/config-drift.integration.test.ts 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/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 8ba62a1c55..ae586d49db 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -10,6 +10,7 @@ import { Option, Path, Predicate, + Redacted, Schedule, Schema, Stream, @@ -20,7 +21,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 +147,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; @@ -1098,11 +1109,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 +1214,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 +1242,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..aa1946c537 --- /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(JSON.stringify(changed.configDrift)).not.toContain("old-secret"); + expect(JSON.stringify(changed.configDrift)).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(JSON.stringify(changed.configDrift)).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(JSON.stringify(inspection.configDrift)).not.toContain("old-secret"); + }), + ), + ), + ), + ); +}); 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, From 9f2a37a90bd1fa75770ca422a6498351c043f808 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:50:40 +0200 Subject: [PATCH 27/43] feat(cli): add experimental stack status --- .../experimental/stack/stack.command.ts | 2 + .../experimental/stack/status/SIDE_EFFECTS.md | 19 ++ .../stack/status/status.command.ts | 26 ++ .../stack/status/status.errors.ts | 19 ++ .../stack/status/status.handler.ts | 155 ++++++++++++ .../stack/status/status.integration.test.ts | 235 ++++++++++++++++++ .../public/config-drift.integration.test.ts | 8 +- 7 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/status/status.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.integration.test.ts diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index a6240680ee..b8741bf096 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -4,6 +4,7 @@ import { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.laye 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 { legacyExperimentalStackApiLayer, legacyExperimentalStackTargetResolverLayer, @@ -15,6 +16,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( Command.withSubcommands([ legacyExperimentalStackStartCommand, legacyExperimentalStackStopCommand, + legacyExperimentalStackStatusCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), 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..7acf4c8639 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -0,0 +1,19 @@ +# `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 `config_warning` when available. 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..1543d522dc --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -0,0 +1,19 @@ +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"; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.reason === "flags" ? actionability.provideFlags : 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..13cfd74280 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -0,0 +1,155 @@ +import { Effect, Option } from "effect"; +import { isStackId, 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 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 "failed"; + if (status.capabilities.some(({ state }) => state === "starting")) return "starting"; + if (status.capabilities.some(({ state }) => state === "dormant")) return "dormant"; + return "ready"; +}; + +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 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: ${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* api.inspectStack(id); + return { + descriptor: inspection.descriptor, + id, + projectRoot: inspection.descriptor.projectRoot, + }; + } + const found = yield* 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 as string | undefined })), + Effect.catchTag("LegacyStackConfigError", (error) => + Effect.succeed({ config: undefined, warning: error.message }), + ), + ); + const comparison = + loaded.config === undefined + ? yield* api.inspectStack(target.id).pipe( + Effect.map((inspection) => ({ + inspection, + warning: undefined as string | undefined, + })), + ) + : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( + Effect.map((inspection) => ({ inspection, warning: undefined as string | undefined })), + Effect.catchTags({ + InvalidStackConfigError: (error) => + Effect.succeed({ inspection: undefined, warning: error.message }), + StackVersionUnsupportedError: (error) => + Effect.succeed({ inspection: undefined, warning: error.message }), + }), + ); + const inspection = + comparison.inspection === undefined + ? yield* 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..cd935dbbf2 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -0,0 +1,235 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { + InvalidStackConfigError, + 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 { LegacyExperimentalStackApi } from "../stack.shared.ts"; +import { legacyExperimentalStackStatus } from "./status.handler.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): StackStatus => ({ + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + 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 legacyOutput?: boolean; +}) => { + 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 = "unterminated\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(); + 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(Option.some(descriptor)); + }, + openStack: () => Effect.die("open must not run"), + inspectStack: (_stackId, inspectOptions) => { + inspectInputs.push(inspectOptions); + 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("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("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("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) }); + return Effect.all([missing.effect, invalid.effect]).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(missing.out.stdoutText).toContain("Config drift: unavailable"); + expect(invalid.out.stdoutText).toContain("Config drift: unavailable"); + }), + ), + ); + }); + + 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" })), + 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))), + ); + }); +}); diff --git a/packages/stack/src/public/config-drift.integration.test.ts b/packages/stack/src/public/config-drift.integration.test.ts index aa1946c537..f8235a564d 100644 --- a/packages/stack/src/public/config-drift.integration.test.ts +++ b/packages/stack/src/public/config-drift.integration.test.ts @@ -107,8 +107,8 @@ describe("inspectStack config drift", () => { "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", ]), ); - expect(JSON.stringify(changed.configDrift)).not.toContain("old-secret"); - expect(JSON.stringify(changed.configDrift)).not.toContain("new-secret"); + expect(changed.configDrift?.paths).not.toContain("old-secret"); + expect(changed.configDrift?.paths).not.toContain("new-secret"); }), ), ); @@ -145,7 +145,7 @@ describe("inspectStack config drift", () => { }); const changed = yield* inspectStack(stack.id, { config: managed("new-managed-secret") }); expect(changed.configDrift?.paths).toContain("secrets.secret:auth.settings.jwt_secret"); - expect(JSON.stringify(changed.configDrift)).not.toContain("managed-secret"); + expect(changed.configDrift?.paths).not.toContain("managed-secret"); const removed = yield* inspectStack(stack.id, { config: { ...baseConfig("old-secret"), @@ -194,7 +194,7 @@ describe("inspectStack config drift", () => { Effect.tap((inspection) => Effect.sync(() => { expect(inspection.configDrift?.status).toBe("changed"); - expect(JSON.stringify(inspection.configDrift)).not.toContain("old-secret"); + expect(inspection.configDrift?.paths).not.toContain("old-secret"); }), ), ), From aacd97b8d9a67ef68ad1a83742a3ce8f9716c255 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:58:23 +0200 Subject: [PATCH 28/43] fix(cli): classify experimental stack status errors --- .../stack/status/status.errors.ts | 5 +- .../stack/status/status.handler.ts | 71 +++++++++++++++---- .../stack/status/status.integration.test.ts | 18 ++++- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/status/status.errors.ts b/apps/cli/src/commands/experimental/stack/status/status.errors.ts index 1543d522dc..38b68df482 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.errors.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -9,11 +9,12 @@ export class LegacyExperimentalStackStatusError extends Data.TaggedError( "LegacyExperimentalStackStatusError", )<{ readonly message: string; - readonly reason: "flags" | "not-found" | "invalid-config"; + readonly reason: "flags" | "not-found" | "invalid-config" | "runtime"; readonly suggestion?: string; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.reason === "flags" ? actionability.provideFlags : actionability.invalidConfig; + 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 index 13cfd74280..2ff1887e79 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,5 +1,11 @@ -import { Effect, Option } from "effect"; -import { isStackId, type StackInspection, type StackStatus } from "@supabase/stack/effect"; +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"; @@ -18,6 +24,42 @@ const validateFlags = (flags: LegacyExperimentalStackStatusFlags) => ) : Effect.void; +const classifyStackError = (error: StackError) => + Match.value(error).pipe( + Match.tag("StackNotFoundError", () => ({ + reason: "not-found" as const, + suggestion: "Run supabase experimental stack start first.", + })), + 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; @@ -49,6 +91,13 @@ const payload = (inspection: StackInspection, configWarning?: string) => ({ } 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 = [ @@ -85,14 +134,16 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin reason: "flags", message: "--stack-id must be a lowercase SHA-256 stack id", }); - const inspection = yield* api.inspectStack(id); + const inspection = yield* catchStackError(api.inspectStack(id)); return { descriptor: inspection.descriptor, id, projectRoot: inspection.descriptor.projectRoot, }; } - const found = yield* api.findStack({ projectRoot, ...(name === undefined ? {} : { name }) }); + const found = yield* catchStackError( + api.findStack({ projectRoot, ...(name === undefined ? {} : { name }) }), + ); if (Option.isNone(found)) return yield* new LegacyExperimentalStackStatusError({ reason: "not-found", @@ -128,24 +179,20 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac ); const comparison = loaded.config === undefined - ? yield* api.inspectStack(target.id).pipe( - Effect.map((inspection) => ({ - inspection, - warning: undefined as string | undefined, - })), - ) + ? yield* catchStackError(api.inspectStack(target.id)).pipe(Effect.map(comparedInspection)) : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( - Effect.map((inspection) => ({ inspection, warning: undefined as string | undefined })), + Effect.map(comparedInspection), Effect.catchTags({ InvalidStackConfigError: (error) => Effect.succeed({ inspection: undefined, warning: error.message }), StackVersionUnsupportedError: (error) => Effect.succeed({ inspection: undefined, warning: error.message }), }), + catchStackError, ); const inspection = comparison.inspection === undefined - ? yield* api.inspectStack(target.id) + ? yield* catchStackError(api.inspectStack(target.id)) : comparison.inspection; const inspectionWarning = loaded.warning ?? comparison.warning; if (output.format === "text") yield* output.raw(render(inspection, inspectionWarning)); 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 index cd935dbbf2..9bd2fc9fe0 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { InvalidStackConfigError, StackIdSchema, @@ -14,6 +14,10 @@ import { 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"; @@ -229,7 +233,17 @@ describe("experimental stack status", () => { Effect.exit, ); return effect.pipe( - Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + 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); + } + }), + ), ); }); }); From a8de60a6943986dea0476bb65e87f38e29ea20e9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:08:43 +0200 Subject: [PATCH 29/43] fix(cli): refine experimental stack status reporting --- .../experimental/stack/status/SIDE_EFFECTS.md | 6 ++- .../stack/status/status.handler.ts | 7 ++- .../stack/status/status.integration.test.ts | 47 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index 7acf4c8639..e0581c4206 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -16,4 +16,8 @@ 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 `config_warning` when available. +`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.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 2ff1887e79..37a0dde693 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -139,6 +139,7 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin descriptor: inspection.descriptor, id, projectRoot: inspection.descriptor.projectRoot, + inspection, }; } const found = yield* catchStackError( @@ -179,7 +180,9 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac ); const comparison = loaded.config === undefined - ? yield* catchStackError(api.inspectStack(target.id)).pipe(Effect.map(comparedInspection)) + ? 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({ @@ -192,7 +195,7 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac ); const inspection = comparison.inspection === undefined - ? yield* catchStackError(api.inspectStack(target.id)) + ? (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)); 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 index 9bd2fc9fe0..64b30b4be1 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -4,6 +4,7 @@ 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, StackIdSchema, @@ -20,6 +21,8 @@ import { } 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 = [ @@ -149,6 +152,17 @@ describe("experimental stack status", () => { }, ); + 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( @@ -161,6 +175,22 @@ describe("experimental stack status", () => { ); }); + 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( @@ -246,4 +276,21 @@ describe("experimental stack status", () => { ), ); }); + + 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()))), + ); + }); }); From 551ef2d34d1f3386a47b959719780fea5aa5ca27 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:10:14 +0200 Subject: [PATCH 30/43] test(cli): cover experimental stack status output parity --- .../stack/status/status.handler.ts | 2 +- .../stack/status/status.integration.test.ts | 56 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 37a0dde693..9b3a803fc8 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -107,7 +107,7 @@ const render = (inspection: StackInspection, configWarning?: string): string => `Runtime: ${descriptor.runtime.kind}`, `Owner: ${inspection.owner}`, `Lifecycle: ${inspection.status?.lifecycle ?? "unavailable"}`, - `Desired lifecycle: ${descriptor.desiredLifecycle}`, + `Desired lifecycle: ${inspection.status?.desiredLifecycle ?? descriptor.desiredLifecycle}`, `Readiness: ${readiness(inspection.status)}`, ]; if (inspection.status !== undefined) { 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 index 64b30b4be1..77a98b50e0 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -42,10 +42,13 @@ const flags = (stack = Option.none(), stackId = Option.none()) = stackId, }); -const makeStatus = (stackId: typeof id): StackStatus => ({ +const makeStatus = ( + stackId: typeof id, + desiredLifecycle: StackStatus["desiredLifecycle"] = "running", +): StackStatus => ({ id: stackId, lifecycle: "running", - desiredLifecycle: "running", + desiredLifecycle, runtime: { kind: "native" }, endpoints: { api: { protocol: "http", address: "127.0.0.1", port: 54321, url: "http://127.0.0.1:54321" }, @@ -67,6 +70,7 @@ const runStatus = (options: { readonly flags?: ReturnType; readonly compareFailure?: "typed" | "defect"; readonly legacyOutput?: boolean; + readonly outputFormat?: "text" | "json"; }) => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-status-")); const projectRoot = join(root, "project"); @@ -92,7 +96,7 @@ const runStatus = (options: { ...(options.status === undefined ? {} : { status: options.status }), ...(options.drift === undefined ? {} : { configDrift: options.drift }), }; - const out = mockOutput(); + const out = mockOutput({ format: options.outputFormat ?? "text" }); const findInputs: unknown[] = []; const inspectInputs: unknown[] = []; const api = Layer.succeed(LegacyExperimentalStackApi, { @@ -204,6 +208,52 @@ describe("experimental stack status", () => { ); }); + 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) }); From e460c1378594ea9eeb7606ef5d015716c5b1ba0e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:21:28 +0200 Subject: [PATCH 31/43] fix(cli): clarify experimental stack status --- .../stack/status/status.handler.ts | 24 ++++---- .../stack/status/status.integration.test.ts | 59 ++++++++++++++++++- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 9b3a803fc8..7dbf7fff44 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -28,7 +28,7 @@ const classifyStackError = (error: StackError) => Match.value(error).pipe( Match.tag("StackNotFoundError", () => ({ reason: "not-found" as const, - suggestion: "Run supabase experimental stack start first.", + suggestion: "Choose an existing --stack-id, or omit --stack-id to start a new stack.", })), Match.tag( "InvalidStackIdentityError", @@ -63,12 +63,16 @@ const catchStackError = (effect: Effect.Effect) => 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 "failed"; + 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, @@ -149,7 +153,7 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin return yield* new LegacyExperimentalStackStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", - suggestion: "Run supabase experimental stack start first.", + suggestion: "Choose an existing --stack-id, or omit --stack-id to start a new stack.", }); return { descriptor: found.value, id: found.value.id, projectRoot: found.value.projectRoot }; }); @@ -173,9 +177,9 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac ); const api = yield* LegacyExperimentalStackApi; const loaded = yield* legacyLoadStackConfig(target.projectRoot).pipe( - Effect.map((config) => ({ config, warning: undefined as string | undefined })), - Effect.catchTag("LegacyStackConfigError", (error) => - Effect.succeed({ config: undefined, warning: error.message }), + Effect.map((config) => ({ config, warning: undefined })), + Effect.catchTag("LegacyStackConfigError", () => + Effect.succeed({ config: undefined, warning: configUnavailableWarning }), ), ); const comparison = @@ -186,10 +190,10 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( Effect.map(comparedInspection), Effect.catchTags({ - InvalidStackConfigError: (error) => - Effect.succeed({ inspection: undefined, warning: error.message }), - StackVersionUnsupportedError: (error) => - Effect.succeed({ inspection: undefined, warning: error.message }), + InvalidStackConfigError: () => + Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), + StackVersionUnsupportedError: () => + Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), }), catchStackError, ); 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 index 77a98b50e0..fab6b1686e 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -7,6 +7,7 @@ import { Cause, Effect, Exit, Layer, Option } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { InvalidStackConfigError, + StackNotFoundError, StackIdSchema, StackStateFormatUnsupportedError, type StackInspection, @@ -69,6 +70,7 @@ const runStatus = (options: { readonly drift?: StackInspection["configDrift"]; readonly flags?: ReturnType; readonly compareFailure?: "typed" | "defect"; + readonly missingTarget?: boolean; readonly legacyOutput?: boolean; readonly outputFormat?: "text" | "json"; }) => { @@ -79,7 +81,7 @@ const runStatus = (options: { writeFileSync( join(projectRoot, "supabase", "config.toml"), options.config === "invalid" - ? 'project_id = "unterminated\n' + ? '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 = { @@ -108,6 +110,8 @@ const runStatus = (options: { 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") @@ -208,6 +212,23 @@ describe("experimental stack status", () => { ); }); + 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", @@ -257,11 +278,45 @@ describe("experimental stack status", () => { 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) }); - return Effect.all([missing.effect, invalid.effect]).pipe( + 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"); + expect(invalidJson.out.stdoutText).not.toContain("FAKE_STATUS_SECRET"); + const success = invalidJson.out.messages.find((message) => message.type === "success"); + expect(success?.data).not.toEqual( + expect.objectContaining({ message: expect.stringContaining("FAKE_STATUS_SECRET") }), + ); + }), + ), + ); + }); + + 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); + } + } }), ), ); From 4cb228fe740e658bba698965c9f8920a7b919159 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:26:11 +0200 Subject: [PATCH 32/43] fix(cli): clarify missing stack status guidance --- .../stack/status/status.handler.ts | 5 ++-- .../stack/status/status.integration.test.ts | 26 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 7dbf7fff44..e93e1bfb57 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -28,7 +28,8 @@ const classifyStackError = (error: StackError) => Match.value(error).pipe( Match.tag("StackNotFoundError", () => ({ reason: "not-found" as const, - suggestion: "Choose an existing --stack-id, or omit --stack-id to start a new stack.", + suggestion: + "Choose an existing --stack-id, or run supabase experimental stack start without --stack-id to create one.", })), Match.tag( "InvalidStackIdentityError", @@ -153,7 +154,7 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin return yield* new LegacyExperimentalStackStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", - suggestion: "Choose an existing --stack-id, or omit --stack-id to start a new stack.", + suggestion: "Run supabase experimental stack start first.", }); return { descriptor: found.value, id: found.value.id, projectRoot: found.value.projectRoot }; }); 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 index fab6b1686e..690bd11918 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -105,7 +105,7 @@ const runStatus = (options: { createStack: () => Effect.die("create must not run"), findStack: (input) => { findInputs.push(input); - return Effect.succeed(Option.some(descriptor)); + return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); }, openStack: () => Effect.die("open must not run"), inspectStack: (_stackId, inspectOptions) => { @@ -289,11 +289,27 @@ describe("experimental stack status", () => { 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"); - expect(invalidJson.out.stdoutText).not.toContain("FAKE_STATUS_SECRET"); const success = invalidJson.out.messages.find((message) => message.type === "success"); - expect(success?.data).not.toEqual( - expect.objectContaining({ message: expect.stringContaining("FAKE_STATUS_SECRET") }), - ); + expect(success?.data).toMatchObject({ + config_drift: { + status: "unavailable", + message: "Project configuration could not be loaded; fix it before checking drift.", + }, + }); + 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([]); }), ), ); From 67db1a8675f57bf33e9e78bf17f6505f91ffb62c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:53 +0200 Subject: [PATCH 33/43] chore(cli): annotate stack status fixtures --- .../experimental/stack/status/status.integration.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 index 690bd11918..bcef63c0d2 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -1,5 +1,7 @@ +// 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"; @@ -296,6 +298,7 @@ describe("experimental stack status", () => { 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"); }), ), From 0a7ef7ddc620ad3d4d8e310a14b554425fb0e201 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:53:54 +0200 Subject: [PATCH 34/43] test(cli): complete stack status service mocks --- .../stack/status/status.integration.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 index bcef63c0d2..dcdb3b3691 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -105,11 +105,12 @@ const runStatus = (options: { 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)); - }, - openStack: () => Effect.die("open 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) @@ -371,6 +372,7 @@ describe("experimental stack status", () => { 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"), }); From 34ca600fdad2a3cbe8f166998f05d4b6fdbc4d6f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 08:00:40 +0200 Subject: [PATCH 35/43] test(cli): scope status mock to available stack methods --- .../stack/status/status.integration.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 index dcdb3b3691..bcef63c0d2 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -105,12 +105,11 @@ const runStatus = (options: { 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"), + findStack: (input) => { + findInputs.push(input); + return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); + }, + openStack: () => Effect.die("open must not run"), inspectStack: (_stackId, inspectOptions) => { inspectInputs.push(inspectOptions); if (options.missingTarget === true) @@ -372,7 +371,6 @@ describe("experimental stack status", () => { 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"), }); From a1946b651f7c3156f0401460acebe89ea3ead29a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:01:09 +0200 Subject: [PATCH 36/43] feat(cli): add experimental stack list --- .../experimental/stack/list/SIDE_EFFECTS.md | 10 + .../experimental/stack/list/list.command.ts | 15 ++ .../experimental/stack/list/list.errors.ts | 18 ++ .../experimental/stack/list/list.handler.ts | 68 +++++++ .../stack/list/list.integration.test.ts | 173 ++++++++++++++++++ .../experimental/stack/stack.command.ts | 2 + .../experimental/stack/stack.shared.ts | 8 + .../stack/start/start.integration.test.ts | 3 + 8 files changed, 297 insertions(+) create mode 100644 apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/experimental/stack/list/list.command.ts create mode 100644 apps/cli/src/commands/experimental/stack/list/list.errors.ts create mode 100644 apps/cli/src/commands/experimental/stack/list/list.handler.ts create mode 100644 apps/cli/src/commands/experimental/stack/list/list.integration.test.ts 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..0118bc4b29 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -0,0 +1,10 @@ +# `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. + +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`. 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..2e5dc58150 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -0,0 +1,15 @@ +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({ flags: {}, config: {} }), + 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..23490c9256 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.errors.ts @@ -0,0 +1,18 @@ +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" | "runtime"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "flags") return actionability.provideFlags; + return this.reason === "runtime" ? actionability.externalNetwork : 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..f4acd5a2bb --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.handler.ts @@ -0,0 +1,68 @@ +import { Effect, Match, Option } from "effect"; +import { isStackError, type StackDescriptor, type StackError } 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 compareCodepoint = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +const compareEntries = ( + left: ReturnType, + right: ReturnType, +): number => { + const project = compareCodepoint(left.project_root, right.project_root); + if (project !== 0) return project; + const name = compareCodepoint(left.name, right.name); + return name !== 0 ? name : compareCodepoint(left.id, right.id); +}; + +const mapStackError = (error: StackError) => { + const reason = Match.value(error).pipe( + Match.tag( + "InvalidStackIdentityError", + "InvalidProjectRootError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + () => "invalid-config" as const, + ), + Match.orElse(() => "runtime" as const), + ); + return new LegacyExperimentalStackListError({ reason, message: error.message }); +}; + +const render = (stacks: ReadonlyArray>): string => { + if (stacks.length === 0) return "No managed stacks found.\n"; + return `${stacks.map((stack) => `${stack.name} (${stack.id})\n Project: ${stack.project_root}\n Branch: ${stack.branch_context}\n Runtime: ${stack.runtime.kind}\n Desired lifecycle: ${stack.desired_lifecycle}`).join("\n\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.", + }); + const api = yield* LegacyExperimentalStackApi; + const stacks = (yield* api + .listStacks() + .pipe(Effect.catchIf(isStackError, (error) => Effect.fail(mapStackError(error))))) + .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..1245a1e08b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +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"; + +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 mutationCalls = 0; + const api = Layer.succeed(LegacyExperimentalStackApi, { + createStack: () => { + mutationCalls++; + return Effect.die("create must not run"); + }, + findStack: () => { + mutationCalls++; + return Effect.succeed(Option.none()); + }, + listStacks: () => + Effect.sync(() => { + listCalls++; + return stacks; + }), + openStack: () => { + mutationCalls++; + return Effect.die("open must not run"); + }, + inspectStack: () => { + mutationCalls++; + 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 mutationCalls() { + return mutationCalls; + }, + 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("b", "/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.mutationCalls).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.indexOf(`alpha (${"a".repeat(64)})`)).toBeLessThan( + run.out.stdoutText.indexOf(`alpha (${"b".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("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("preserves registry errors and rejects legacy output without listing", () => { + const run = runList([], { legacyOutput: true }); + return Effect.gen(function* () { + const legacyExit = yield* run.effect.pipe(Effect.exit); + expect(Exit.isFailure(legacyExit)).toBe(true); + expect(run.listCalls).toBe(0); + 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"), + }); + const errorExit = yield* legacyExperimentalStackList().pipe( + Effect.provide(Layer.mergeAll(errorOut.layer, errorLayer, BunServices.layer)), + Effect.exit, + ); + 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)) { + expect(error.value).toBeInstanceOf(LegacyExperimentalStackListError); + if (error.value instanceof LegacyExperimentalStackListError) + expect(error.value[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + } + }); + }); +}); diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index b8741bf096..9a4a4183e6 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -5,6 +5,7 @@ import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-l 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, @@ -17,6 +18,7 @@ export const legacyExperimentalStackCommand = Command.make("stack").pipe( legacyExperimentalStackStartCommand, legacyExperimentalStackStopCommand, legacyExperimentalStackStatusCommand, + legacyExperimentalStackListCommand, ]), Command.provide(legacyExperimentalStackTargetResolverLayer), Command.provide(legacyExperimentalStackApiLayer), diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 103226191d..3d42fefb7f 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -3,6 +3,7 @@ import { createStack, findStack, inspectStack, + listStacks, isStackId, openStack, type StackRuntimePreference, @@ -74,6 +75,12 @@ export class LegacyExperimentalStackApi extends Context.Service< Effect.Success>, Effect.Error> >; + readonly listStacks: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; readonly openStack: ( ...args: Parameters ) => Effect.Effect< @@ -107,6 +114,7 @@ export const legacyExperimentalStackApiLayer = Layer.effect( 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)), 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 index 5d6b290c70..f53f9a5e22 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -125,6 +125,7 @@ function handlerLayer(opts: { return Effect.succeed(opts.stack); }, findStack: () => Effect.succeed(Option.none()), + listStacks: () => Effect.succeed([]), openStack: () => { opts.onOpen?.(); return Effect.succeed(opts.stack); @@ -195,6 +196,7 @@ describe("experimental stack start targeting", () => { const api = Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("unused"), findStack: () => Effect.succeed(Option.none()), + listStacks: () => Effect.succeed([]), openStack: () => Effect.die("unused"), inspectStack: () => Effect.succeed({ @@ -514,6 +516,7 @@ describe("experimental stack start targeting", () => { 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"), }), From 7d36677ec72e1c9425b57a8f0ae8efafbfc08aa7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:13:25 +0200 Subject: [PATCH 37/43] fix(cli): refine experimental stack list reporting --- .../experimental/stack/list/SIDE_EFFECTS.md | 8 ++- .../experimental/stack/list/list.errors.ts | 6 ++- .../experimental/stack/list/list.handler.ts | 38 +++++++------- .../stack/list/list.integration.test.ts | 51 ++++++++++++++++++- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md index 0118bc4b29..ad3ecf6e88 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -2,9 +2,15 @@ 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. +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.errors.ts b/apps/cli/src/commands/experimental/stack/list/list.errors.ts index 23490c9256..ebd272dea6 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.errors.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.errors.ts @@ -9,10 +9,12 @@ export class LegacyExperimentalStackListError extends Data.TaggedError( "LegacyExperimentalStackListError", )<{ readonly message: string; - readonly reason: "flags" | "invalid-config" | "runtime"; + readonly reason: "flags" | "invalid-config"; + readonly suggestion?: string; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { if (this.reason === "flags") return actionability.provideFlags; - return this.reason === "runtime" ? actionability.externalNetwork : actionability.invalidConfig; + 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 index f4acd5a2bb..e828636eca 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.handler.ts @@ -1,5 +1,9 @@ -import { Effect, Match, Option } from "effect"; -import { isStackError, type StackDescriptor, type StackError } from "@supabase/stack/effect"; +import { Effect, 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"; @@ -27,23 +31,21 @@ const compareEntries = ( return name !== 0 ? name : compareCodepoint(left.id, right.id); }; -const mapStackError = (error: StackError) => { - const reason = Match.value(error).pipe( - Match.tag( - "InvalidStackIdentityError", - "InvalidProjectRootError", - "StackStateInvalidError", - "StackStateFormatUnsupportedError", - () => "invalid-config" as const, - ), - Match.orElse(() => "runtime" as const), - ); - return new LegacyExperimentalStackListError({ reason, message: error.message }); -}; +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 => + runtime.kind === "container" ? `container (${runtime.engine})` : runtime.kind; const render = (stacks: ReadonlyArray>): string => { if (stacks.length === 0) return "No managed stacks found.\n"; - return `${stacks.map((stack) => `${stack.name} (${stack.id})\n Project: ${stack.project_root}\n Branch: ${stack.branch_context}\n Runtime: ${stack.runtime.kind}\n Desired lifecycle: ${stack.desired_lifecycle}`).join("\n\n")}\n`; + return `${stacks.map((stack) => `${stack.name} (${stack.id})\n Project: ${stack.project_root}\n Branch: ${stack.branch_context}\n Runtime: ${renderRuntime(stack.runtime)}\n Desired lifecycle: ${stack.desired_lifecycle}`).join("\n\n")}\n`; }; export const legacyExperimentalStackList = Effect.fn("legacy.experimental.stack.list")( @@ -56,9 +58,7 @@ export const legacyExperimentalStackList = Effect.fn("legacy.experimental.stack. message: "The legacy -o/--output flag is not supported here; use --output-format json.", }); const api = yield* LegacyExperimentalStackApi; - const stacks = (yield* api - .listStacks() - .pipe(Effect.catchIf(isStackError, (error) => Effect.fail(mapStackError(error))))) + const stacks = (yield* api.listStacks().pipe(Effect.mapError(mapStackError))) .map(entry) .sort(compareEntries); if (output.format === "text") yield* output.raw(render(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 index 1245a1e08b..68100283af 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -1,6 +1,7 @@ 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, @@ -15,6 +16,8 @@ import { 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, @@ -130,6 +133,20 @@ describe("experimental stack list", () => { ); }); + it.effect("renders the container engine in text output", () => { + const run = runList([ + { + ...descriptor("p", "/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( @@ -145,6 +162,17 @@ describe("experimental stack list", () => { const legacyExit = yield* run.effect.pipe(Effect.exit); 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)) { + expect(error.value).toBeInstanceOf(LegacyExperimentalStackListError); + if (error.value instanceof LegacyExperimentalStackListError) { + expect(error.value.message).toContain("legacy -o/--output flag"); + expect(error.value[ErrorActionabilityId]).toEqual(actionability.provideFlags); + } + } + } const errorOut = mockOutput(); const errorLayer = Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("unused"), @@ -164,10 +192,31 @@ describe("experimental stack list", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { expect(error.value).toBeInstanceOf(LegacyExperimentalStackListError); - if (error.value instanceof LegacyExperimentalStackListError) + if (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()))), + ); + }); }); From 664e029f07492eabf952b70c021e1c9916dc6388 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:14:33 +0200 Subject: [PATCH 38/43] test(cli): fix list container fixture --- .../commands/experimental/stack/list/list.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 68100283af..de176b3aaf 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -136,7 +136,7 @@ describe("experimental stack list", () => { it.effect("renders the container engine in text output", () => { const run = runList([ { - ...descriptor("p", "/work/container", "podman", "stopped"), + ...descriptor("d", "/work/container", "podman", "stopped"), runtime: { kind: "container", engine: "podman" }, }, ]); From 77028b2ac14ecc3d01d024347cef5566e813041e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:19:11 +0200 Subject: [PATCH 39/43] test(cli): strengthen stack list ordering assertion --- .../experimental/stack/list/list.integration.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index de176b3aaf..88ea47682e 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -86,7 +86,7 @@ describe("experimental stack list", () => { "sorts distinct persisted roots and reports stopped lifecycle without live claims", () => { const run = runList([ - descriptor("b", "/work/z", "zeta", "stopped"), + descriptor("e", "/work/z", "zeta", "stopped"), descriptor("a", "/work/a", "beta", "running"), descriptor("c", "/work/a", "alpha", "unconfigured"), descriptor("b", "/work/a", "alpha", "stopped"), @@ -102,8 +102,8 @@ describe("experimental stack list", () => { 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.indexOf(`alpha (${"a".repeat(64)})`)).toBeLessThan( - run.out.stdoutText.indexOf(`alpha (${"b".repeat(64)})`), + expect(run.out.stdoutText.indexOf(`alpha (${"b".repeat(64)})`)).toBeLessThan( + run.out.stdoutText.indexOf(`alpha (${"c".repeat(64)})`), ); }), ), From 88add78d227d4b84c743c54d916dfcff51d319d3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:21:57 +0200 Subject: [PATCH 40/43] test(cli): require both stack sort entries --- .../commands/experimental/stack/list/list.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 index 88ea47682e..875592c91d 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -102,6 +102,8 @@ describe("experimental stack list", () => { 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)})`), ); From f4553d7e40c07f9f41cc1f1f37ac3900c43793c9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:13:04 +0200 Subject: [PATCH 41/43] fix(cli): address experimental stack list review --- .../experimental/stack/list/list.command.ts | 5 +- .../experimental/stack/list/list.handler.ts | 27 +++-- .../stack/list/list.integration.test.ts | 106 +++++++++--------- packages/stack/src/public/EffectStack.ts | 46 +++++++- .../public/effect-stack.integration.test.ts | 11 ++ 5 files changed, 131 insertions(+), 64 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/list/list.command.ts b/apps/cli/src/commands/experimental/stack/list/list.command.ts index 2e5dc58150..8a9b343e22 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.command.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -7,9 +7,6 @@ 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({ flags: {}, config: {} }), - withJsonErrorHandling, - ), + legacyExperimentalStackList().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), ), ); diff --git a/apps/cli/src/commands/experimental/stack/list/list.handler.ts b/apps/cli/src/commands/experimental/stack/list/list.handler.ts index e828636eca..a486da108a 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Match, Option } from "effect"; import { type StackDescriptor, type StackDiscoveryError, @@ -18,17 +18,17 @@ const entry = (descriptor: StackDescriptor) => ({ desired_lifecycle: descriptor.desiredLifecycle, }); -const compareCodepoint = (left: string, right: string): number => +const compareCodeunit = (left: string, right: string): number => left === right ? 0 : left < right ? -1 : 1; const compareEntries = ( left: ReturnType, right: ReturnType, ): number => { - const project = compareCodepoint(left.project_root, right.project_root); + const project = compareCodeunit(left.project_root, right.project_root); if (project !== 0) return project; - const name = compareCodepoint(left.name, right.name); - return name !== 0 ? name : compareCodepoint(left.id, right.id); + const name = compareCodeunit(left.name, right.name); + return name !== 0 ? name : compareCodeunit(left.id, right.id); }; const mapStackError = (error: StackDiscoveryError) => @@ -41,11 +41,23 @@ const mapStackError = (error: StackDiscoveryError) => }); const renderRuntime = (runtime: StackRuntime): string => - runtime.kind === "container" ? `container (${runtime.engine})` : runtime.kind; + 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"; - return `${stacks.map((stack) => `${stack.name} (${stack.id})\n Project: ${stack.project_root}\n Branch: ${stack.branch_context}\n Runtime: ${renderRuntime(stack.runtime)}\n Desired lifecycle: ${stack.desired_lifecycle}`).join("\n\n")}\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")( @@ -56,6 +68,7 @@ export const legacyExperimentalStackList = Effect.fn("legacy.experimental.stack. 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))) 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 index 875592c91d..3462b38409 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -39,14 +39,14 @@ const runList = ( ) => { const out = mockOutput({ format: options.format }); let listCalls = 0; - let mutationCalls = 0; + let otherApiCalls = 0; const api = Layer.succeed(LegacyExperimentalStackApi, { createStack: () => { - mutationCalls++; + otherApiCalls++; return Effect.die("create must not run"); }, findStack: () => { - mutationCalls++; + otherApiCalls++; return Effect.succeed(Option.none()); }, listStacks: () => @@ -55,11 +55,11 @@ const runList = ( return stacks; }), openStack: () => { - mutationCalls++; + otherApiCalls++; return Effect.die("open must not run"); }, inspectStack: () => { - mutationCalls++; + otherApiCalls++; return Effect.die("inspect must not run"); }, }); @@ -74,8 +74,8 @@ const runList = ( get listCalls() { return listCalls; }, - get mutationCalls() { - return mutationCalls; + get otherApiCalls() { + return otherApiCalls; }, effect: legacyExperimentalStackList().pipe(Effect.provide(layer)), }; @@ -95,7 +95,7 @@ describe("experimental stack list", () => { Effect.tap(() => Effect.sync(() => { expect(run.listCalls).toBe(1); - expect(run.mutationCalls).toBe(0); + expect(run.otherApiCalls).toBe(0); expect(run.out.stdoutText.indexOf("alpha")).toBeLessThan( run.out.stdoutText.indexOf("beta"), ); @@ -158,51 +158,57 @@ describe("experimental stack list", () => { ); }); - it.effect("preserves registry errors and rejects legacy output without listing", () => { + it.effect("rejects legacy output without listing", () => { const run = runList([], { legacyOutput: true }); - return Effect.gen(function* () { - const legacyExit = yield* run.effect.pipe(Effect.exit); - 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)) { - expect(error.value).toBeInstanceOf(LegacyExperimentalStackListError); - if (error.value instanceof LegacyExperimentalStackListError) { - expect(error.value.message).toContain("legacy -o/--output flag"); - expect(error.value[ErrorActionabilityId]).toEqual(actionability.provideFlags); - } - } - } - 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"), - }); - const errorExit = yield* legacyExperimentalStackList().pipe( - Effect.provide(Layer.mergeAll(errorOut.layer, errorLayer, BunServices.layer)), - Effect.exit, - ); - 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)) { - expect(error.value).toBeInstanceOf(LegacyExperimentalStackListError); - if (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); + 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", () => { diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index ae586d49db..4023c721cf 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -7,6 +7,7 @@ import { Exit, FileSystem, Fiber, + Match, Option, Path, Predicate, @@ -1097,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.succeed(undefined) : Effect.fail(error), + ), + ); if ( state !== undefined && (projectRoot === undefined || state.identity.projectRoot === projectRoot) diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index a0532ee65e..ef73bbf742 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1012,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); + } + } + } }), ), ); From ca4f2b2d9e4f6a3c48f38b42d1c697493d55b700 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:27:29 +0200 Subject: [PATCH 42/43] fix(stack): satisfy effect lint in stack discovery --- packages/stack/src/public/EffectStack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 4023c721cf..39bf9b0816 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -1137,7 +1137,7 @@ export const listStacks = ( ); }), Effect.catchTag("StackStateInvalidError", (error) => - isMissingStateRemnantError(error) ? Effect.succeed(undefined) : Effect.fail(error), + isMissingStateRemnantError(error) ? Effect.void : Effect.fail(error), ), ); if ( From edeccb4273a62bdd4e9316aaf6cf45353ec4e4cd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 08:01:05 +0200 Subject: [PATCH 43/43] test(cli): add list operation to stack service fixtures --- .../experimental/stack/status/status.integration.test.ts | 2 ++ .../commands/experimental/stack/stop/stop.integration.test.ts | 1 + 2 files changed, 3 insertions(+) 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 index bcef63c0d2..2c20e0b093 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -109,6 +109,7 @@ const runStatus = (options: { 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); @@ -371,6 +372,7 @@ describe("experimental stack status", () => { 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"), }); 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 index 602ddfb1ed..2337c88a64 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -106,6 +106,7 @@ function setup(opts: { mockLegacyCliSettings({ workdir: opts.root }), Layer.succeed(LegacyExperimentalStackApi, { createStack: () => Effect.die("must not create"), + listStacks: () => Effect.succeed([]), findStack: (input) => Effect.sync(() => { state.findInputs.push(input);