From 25a02e7559b24bd0c63754e915ee77e4f21d9898 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:34:37 +0200 Subject: [PATCH 01/20] 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 728294b696..534acf9f44 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 a3b9855017..a4d0bf1675 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -41,6 +41,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; }; @@ -62,7 +66,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; @@ -174,7 +181,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 ce02a211ddc8da4bc44d1b580f55e31beed51b8f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:50:40 +0200 Subject: [PATCH 02/20] feat(cli): add experimental stack status --- .../experimental/stack/stack.command.ts | 6 +- .../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 ++++++++++++++++++ 6 files changed, 459 insertions(+), 1 deletion(-) 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 d85294f588..2edc17210b 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,6 +6,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { stackStartCommand as stackStartCommandBase } from "./start/start.command.ts"; import { stackStopCommand as stackStopCommandBase } from "./stop/stop.command.ts"; +import { legacyExperimentalStackStatusCommand as stackStatusCommandBase } from "./status/status.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; export const stackRuntimeLayer = Layer.mergeAll( @@ -21,12 +22,15 @@ const stackStartCommand = stackStartCommandBase.pipe( const stackStopCommand = stackStopCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "stop"])), ); +const stackStatusCommand = stackStatusCommandBase.pipe( + Command.provide(commandRuntimeLayer(["stack", "status"])), +); export const stackCommand = Command.make("stack").pipe( Command.withDescription( "Manage an experimental, unstable local Supabase stack with the new backend. This command is excluded from the CLI compatibility promise.", ), Command.withShortDescription("Manage experimental local stacks"), - Command.withSubcommands([stackStartCommand, stackStopCommand]), + Command.withSubcommands([stackStartCommand, stackStopCommand, stackStatusCommand]), Command.provide(stackRuntimeLayer), ); 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))), + ); + }); +}); From 77f0924db87694e01cb2a4b87d5e7e5a0a09da2b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 00:58:23 +0200 Subject: [PATCH 03/20] 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 903da6155200ca93c396d06e94e81be2f385779a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:08:43 +0200 Subject: [PATCH 04/20] 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 a9bc9b016a4aa70401ddb73d482151416f7cbf53 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:10:14 +0200 Subject: [PATCH 05/20] 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 251fa8ab74e34eac400f518004503bd630a3ae65 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:21:28 +0200 Subject: [PATCH 06/20] 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 5fad357430d5241011a82394d13212a976d50db2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 01:26:11 +0200 Subject: [PATCH 07/20] 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 f8ef0e7ad269d57821a4fb22160f3fce38a8f1a4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:48:53 +0200 Subject: [PATCH 08/20] 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 276a3f97de0756cf97d6c58672871eb2c447d083 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 07:53:54 +0200 Subject: [PATCH 09/20] 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 82144ea1b97dde324baa9ac7dccb2bd696eb8842 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 8 Sep 2026 08:00:40 +0200 Subject: [PATCH 10/20] 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 9ad64f6968d54e11c7c2386a14efb1abe18a80cc Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 11 Sep 2026 12:17:28 +0200 Subject: [PATCH 11/20] fix(cli): retarget stack status onto current stack command APIs --- apps/cli/docs/stack-commands.md | 9 +- .../experimental/stack/stack.command.ts | 2 +- .../experimental/stack/status/SIDE_EFFECTS.md | 8 +- .../stack/status/status.command.ts | 23 +++-- .../stack/status/status.errors.ts | 4 +- .../stack/status/status.handler.ts | 90 ++++++++++--------- .../stack/status/status.integration.test.ts | 39 ++++---- 7 files changed, 92 insertions(+), 83 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 971b9f047c..fe14097661 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -4,10 +4,11 @@ command interface may change, and it is excluded from the CLI compatibility promise. It is available regardless of the project's backend setting and supports both Docker and native runtimes. -| Command | Purpose | -| ---------------------- | -------------------------------------- | -| `supabase stack start` | Create or resume the project's stack. | -| `supabase stack stop` | Stop a stack while retaining its data. | +| Command | Purpose | +| ----------------------- | -------------------------------------- | +| `supabase stack start` | Create or resume the project's stack. | +| `supabase stack status` | Show identity, readiness, and drift. | +| `supabase stack stop` | Stop a stack while retaining its data. | Use each command's `--help` for its available targeting and runtime options. diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 2edc17210b..8b9b4ab31a 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -6,7 +6,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { stackStartCommand as stackStartCommandBase } from "./start/start.command.ts"; import { stackStopCommand as stackStopCommandBase } from "./stop/stop.command.ts"; -import { legacyExperimentalStackStatusCommand as stackStatusCommandBase } from "./status/status.command.ts"; +import { stackStatusCommand as stackStatusCommandBase } from "./status/status.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; export const stackRuntimeLayer = Layer.mergeAll( 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 e0581c4206..73216f26c5 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase experimental stack status` +# `supabase 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 @@ -16,8 +16,4 @@ paths only; secret values are never emitted. Text output includes identity, runtime, owner, lifecycle, readiness, endpoints, and config drift. JSON output contains the same fields under `identity`, with -`config_drift` and a warning message in `config_drift` when configuration could -not be loaded. Drift compares the persisted effective stack definition with the -configuration-derived candidate, so explicit start policies such as `--eager` -or `--preparation on-demand` remain visible as intentional policy drift on a -later status check. +`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 index 8188e516f3..cbe194fe33 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -1,8 +1,8 @@ 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"; +import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { stackStatus } from "./status.handler.ts"; const config = { stack: Flag.string("stack").pipe(Flag.withDescription("Inspect a named stack."), Flag.optional), @@ -12,15 +12,22 @@ const config = { ), } as const; -export type LegacyExperimentalStackStatusFlags = CliCommand.Command.Config.Infer; +export type StackStatusFlags = CliCommand.Command.Config.Infer; -export const legacyExperimentalStackStatusCommand = Command.make("status", config).pipe( +export const stackStatusCommand = Command.make("status", config).pipe( Command.withDescription("Show the state of a managed local Supabase stack."), Command.withShortDescription("Show stack status"), + Command.withExamples([ + { + command: "supabase stack status", + description: "Show the current project stack", + }, + { + command: "supabase stack status --stack feature-a", + description: "Show a named stack", + }, + ]), Command.withHandler((flags) => - legacyExperimentalStackStatus(flags).pipe( - withLegacyCommandInstrumentation({ flags, config }), - withJsonErrorHandling, - ), + stackStatus(flags).pipe(withCommandTelemetry({ 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 index 38b68df482..1e1cc8120c 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.errors.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -5,9 +5,7 @@ import { ErrorActionabilityId, } from "../../../../shared/telemetry/error-actionability.ts"; -export class LegacyExperimentalStackStatusError extends Data.TaggedError( - "LegacyExperimentalStackStatusError", -)<{ +export class StackCommandStatusError extends Data.TaggedError("ExperimentalStackStatusError")<{ readonly message: string; readonly reason: "flags" | "not-found" | "invalid-config" | "runtime"; readonly suggestion?: string; 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 e93e1bfb57..d18eaf1aaa 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,35 +1,39 @@ import { Effect, Match, Option } from "effect"; import { isStackError, - isStackId, type StackError, type StackInspection, type StackStatus, } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; -import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; -import { LegacyExperimentalStackApi } from "../stack.shared.ts"; -import { legacyLoadStackConfig } from "../stack-config.ts"; -import type { LegacyExperimentalStackStatusFlags } from "./status.command.ts"; -import { LegacyExperimentalStackStatusError } from "./status.errors.ts"; +import { OutputFlag } from "../../../../command-internal/global-flags.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; +import { + StackApi, + StackTargetError, + rejectStackOutput, + validateStackId, + validateStackTarget, +} from "../stack.shared.ts"; +import { loadStackConfig } from "../stack-config.ts"; +import type { StackStatusFlags } from "./status.command.ts"; +import { StackCommandStatusError } 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 mapTargetError = (error: StackTargetError) => + new StackCommandStatusError({ + reason: error.reason, + message: error.message, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), + cause: error, + }); const classifyStackError = (error: StackError) => Match.value(error).pipe( Match.tag("StackNotFoundError", () => ({ reason: "not-found" as const, suggestion: - "Choose an existing --stack-id, or run supabase experimental stack start without --stack-id to create one.", + "Choose an existing --stack-id, or run supabase stack start without --stack-id to create one.", })), Match.tag( "InvalidStackIdentityError", @@ -51,7 +55,7 @@ const classifyStackError = (error: StackError) => const mapStackError = (error: StackError) => { const classification = classifyStackError(error); - return new LegacyExperimentalStackStatusError({ + return new StackCommandStatusError({ ...classification, message: error.message, cause: error, @@ -132,17 +136,13 @@ const render = (inspection: StackInspection, configWarning?: string): string => const findDescriptor = (projectRoot: string, name: string | undefined, id: string | undefined) => Effect.gen(function* () { - const api = yield* LegacyExperimentalStackApi; + const api = yield* StackApi; if (id !== undefined) { - if (!isStackId(id)) - return yield* new LegacyExperimentalStackStatusError({ - reason: "flags", - message: "--stack-id must be a lowercase SHA-256 stack id", - }); - const inspection = yield* catchStackError(api.inspectStack(id)); + const validId = yield* validateStackId(id).pipe(Effect.mapError(mapTargetError)); + const inspection = yield* catchStackError(api.inspectStack(validId)); return { descriptor: inspection.descriptor, - id, + id: validId, projectRoot: inspection.descriptor.projectRoot, inspection, }; @@ -151,35 +151,36 @@ const findDescriptor = (projectRoot: string, name: string | undefined, id: strin api.findStack({ projectRoot, ...(name === undefined ? {} : { name }) }), ); if (Option.isNone(found)) - return yield* new LegacyExperimentalStackStatusError({ + return yield* new StackCommandStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", - suggestion: "Run supabase experimental stack start first.", + suggestion: "Run supabase 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) { +export const stackStatus = Effect.fn("experimental.stack.status")(function* ( + flags: StackStatusFlags, +) { + const telemetryState = yield* TelemetryState; + const body = Effect.gen(function* () { 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 settings = yield* CommandSettings; + const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* validateStackTarget({ + stack: Option.getOrUndefined(flags.stack), + stackId: Option.getOrUndefined(flags.stackId), + }).pipe(Effect.mapError(mapTargetError)); 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( + const api = yield* StackApi; + const loaded = yield* loadStackConfig(target.projectRoot).pipe( Effect.map((config) => ({ config, warning: undefined })), - Effect.catchTag("LegacyStackConfigError", () => + Effect.catchTag("StackConfigError", () => Effect.succeed({ config: undefined, warning: configUnavailableWarning }), ), ); @@ -206,5 +207,6 @@ export const legacyExperimentalStackStatus = Effect.fn("legacy.experimental.stac if (output.format === "text") yield* output.raw(render(inspection, inspectionWarning)); else yield* output.success("", payload(inspection, inspectionWarning)); return inspection; - }, -); + }); + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); +}); 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..152588ab2e 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 @@ -16,15 +16,18 @@ import { 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 { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { actionability, ErrorActionabilityId, } from "../../../../shared/telemetry/error-actionability.ts"; -import { LegacyExperimentalStackApi } from "../stack.shared.ts"; -import { legacyExperimentalStackStatus } from "./status.handler.ts"; -import { legacyExperimentalStackStatusCommand } from "./status.command.ts"; +import { StackApi } from "../stack.shared.ts"; +import { stackStatus } from "./status.handler.ts"; +import { stackStatusCommand } from "./status.command.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; const id = StackIdSchema.make("a".repeat(64)); @@ -101,9 +104,10 @@ const runStatus = (options: { ...(options.drift === undefined ? {} : { configDrift: options.drift }), }; const out = mockOutput({ format: options.outputFormat ?? "text" }); + const telemetry = mockTelemetryStateTracked(); const findInputs: unknown[] = []; const inspectInputs: unknown[] = []; - const api = Layer.succeed(LegacyExperimentalStackApi, { + const api = Layer.succeed(StackApi, { createStack: () => Effect.die("create must not run"), findStack: (input) => { findInputs.push(input); @@ -123,21 +127,20 @@ const runStatus = (options: { }); const layer = Layer.mergeAll( out.layer, + telemetry.layer, api, - mockLegacyCliSettings({ workdir: root }), - ...(options.legacyOutput === true - ? [Layer.succeed(LegacyOutputFlag, Option.some("json"))] - : []), + mockCommandSettings({ workdir: root }), + ...(options.legacyOutput === true ? [Layer.succeed(OutputFlag, Option.some("json"))] : []), BunServices.layer, ); - const effect = legacyExperimentalStackStatus(options.flags ?? flags()).pipe( + const effect = stackStatus(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", () => { +describe("stack status", () => { it.effect( "reports configured identity, dormant readiness, endpoint, drift, and target config", () => { @@ -311,7 +314,7 @@ describe("experimental stack status", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect(error.suggestion).toBe("Run supabase experimental stack start first."); + expect(error.suggestion).toBe("Run supabase stack start first."); expect(run.inspectInputs).toEqual([]); }), ), @@ -367,19 +370,21 @@ describe("experimental stack status", () => { it.effect("does not retry discovery failures", () => { const run = runStatus({}); - const discovery = Layer.succeed(LegacyExperimentalStackApi, { + const telemetry = mockTelemetryStateTracked(); + const discovery = Layer.succeed(StackApi, { 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( + const effect = stackStatus(flags()).pipe( Effect.provide( Layer.mergeAll( run.out.layer, + telemetry.layer, discovery, - mockLegacyCliSettings({ workdir: run.projectRoot }), + mockCommandSettings({ workdir: run.projectRoot }), BunServices.layer, ), ), @@ -403,7 +408,7 @@ 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( + const command = stackStatusCommand.pipe( Command.withHandler((parsedFlags) => Effect.sync(() => { parsed = { stack: parsedFlags.stack, stackId: parsedFlags.stackId }; From 4b54642712fedd3506ce80dec4e8f309782eb543 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Sep 2026 16:47:20 +0100 Subject: [PATCH 12/20] fix(stack): return database credentials when Auth is disabled Database-only stacks previously failed credentials() with an InvalidStackConfigError. The api credentials are now optional and are omitted when the Auth capability is disabled. --- packages/stack/src/public/Credentials.ts | 18 ++++++----- .../stack/src/public/whole-stack.e2e.test.ts | 19 ++++++++--- packages/stack/src/supervisor/Supervisor.ts | 32 +++++++++---------- .../supervisor/supervisor.integration.test.ts | 21 +++++++----- 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/packages/stack/src/public/Credentials.ts b/packages/stack/src/public/Credentials.ts index f75b47c31e..8b78a3e631 100644 --- a/packages/stack/src/public/Credentials.ts +++ b/packages/stack/src/public/Credentials.ts @@ -22,7 +22,7 @@ const EffectStorageCredentialsSchema = Schema.Struct({ export const EffectStackCredentialsSchema = Schema.Struct({ database: EffectDatabaseCredentialsSchema, - api: EffectApiCredentialsSchema, + api: Schema.optionalKey(EffectApiCredentialsSchema), storage: Schema.optionalKey(EffectStorageCredentialsSchema), }); export interface EffectStackCredentials { @@ -30,7 +30,7 @@ export interface EffectStackCredentials { readonly url: Redacted.Redacted; readonly password: Redacted.Redacted; }; - readonly api: { + readonly api?: { readonly publishableKey: string; readonly secretKey: Redacted.Redacted; readonly anonJwt: string; @@ -49,12 +49,14 @@ export const PromiseStackCredentialsSchema = Schema.Struct({ url: Schema.String, password: Schema.String, }), - api: Schema.Struct({ - publishableKey: Schema.String, - secretKey: Schema.String, - anonJwt: Schema.String, - serviceRoleJwt: Schema.String, - }), + api: Schema.optionalKey( + Schema.Struct({ + publishableKey: Schema.String, + secretKey: Schema.String, + anonJwt: Schema.String, + serviceRoleJwt: Schema.String, + }), + ), storage: Schema.optionalKey( Schema.Struct({ endpoint: Schema.String, diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 2e35a88892..bd5835db8d 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -449,16 +449,21 @@ const databaseQuery = async ( } }; +const apiCredentials = (credentials: PromiseStackCredentials) => { + if (credentials.api === undefined) throw new Error("API credentials are required"); + return credentials.api; +}; + const apiHeaders = ( credentials: PromiseStackCredentials, - token: string = credentials.api.anonJwt, + token: string = apiCredentials(credentials).anonJwt, ): Record => ({ - apikey: credentials.api.publishableKey, + apikey: apiCredentials(credentials).publishableKey, Authorization: `Bearer ${token}`, }); const serviceHeaders = (credentials: PromiseStackCredentials): Record => - apiHeaders(credentials, credentials.api.serviceRoleJwt); + apiHeaders(credentials, apiCredentials(credentials).serviceRoleJwt); const functionSource = (table: string, marker: string): string => ` Deno.serve(async () => { @@ -738,7 +743,9 @@ const exerciseWholeStackRealtime = async ( const socket = await (async (): Promise => { try { return await activate(stack, "realtime", async () => { - const candidate = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + const candidate = await openSocket( + makeRealtimeUrl(api, apiCredentials(credentials).publishableKey), + ); openedSocket = candidate; return candidate; }); @@ -947,7 +954,9 @@ const reactivateWholeStackCapabilities = async ( await request(api.url, "/auth/v1/settings", { headers: apiHeaders(credentials) }); }); await activate(stack, "realtime", async () => { - const probe = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + const probe = await openSocket( + makeRealtimeUrl(api, apiCredentials(credentials).publishableKey), + ); probe.close(); }); await activate(stack, "storage", async () => { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index fc653786af..89053f18c2 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -819,12 +819,6 @@ export const makeSupervisor = ( ), ); - const auth = definition.capabilities.auth; - if (!auth.enabled) - return yield* Effect.fail( - rpcError("InvalidStackConfigError", "Stack credentials require Auth to be enabled"), - ); - const requiredSecret = (slot: string): Effect.Effect => { const value = state.secrets[slot]?.value; return value === undefined || value.length === 0 @@ -840,22 +834,28 @@ export const makeSupervisor = ( databasePassword, )}@${databaseHost}:${databaseAssignment.port}/postgres`; - const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); - const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); - const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); - const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); + const auth = definition.capabilities.auth; + const api = auth.enabled + ? yield* Effect.gen(function* () { + const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); + const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); + const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); + const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); + return { + publishableKey, + secretKey: Redacted.make(secretKey), + anonJwt, + serviceRoleJwt: Redacted.make(serviceRoleJwt), + }; + }) + : undefined; const base: EffectStackCredentials = { database: { url: Redacted.make(databaseUrl), password: Redacted.make(databasePassword), }, - api: { - publishableKey, - secretKey: Redacted.make(secretKey), - anonJwt, - serviceRoleJwt: Redacted.make(serviceRoleJwt), - }, + ...(api === undefined ? {} : { api }), }; const storage = definition.capabilities.storage; const s3 = storage.settings.s3_protocol; diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 49921c7d8e..b6fc3bce2d 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1229,6 +1229,8 @@ describe("Supervisor composition", () => { /^postgresql:\/\/postgres:.+@127\.0\.0\.1:\d+\/postgres$/, ); expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); + if (credentials.api === undefined) + return yield* new StackStateInvalidError({ message: "API credentials are missing" }); expect(credentials.api.publishableKey).toEqual(expect.any(String)); expect(Redacted.value(credentials.api.secretKey)).toEqual(expect.any(String)); expect(credentials.api.anonJwt).toEqual(expect.any(String)); @@ -1320,14 +1322,17 @@ describe("Supervisor composition", () => { ), ); - it.live("fails closed when Auth is disabled", () => - run( - Effect.gen(function* () { - const { fixture } = yield* makeCredentialsFixture({ authEnabled: false }); - const failed = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(failed)).toMatchObject({ tag: "InvalidStackConfigError" }); - }), - ), + it.live( + "returns database credentials when Auth is disabled and fails closed for missing secrets", + () => + run( + Effect.gen(function* () { + const { fixture } = yield* makeCredentialsFixture({ authEnabled: false }); + const authDisabled = yield* invokeCredentials(fixture.supervisor); + expect(authDisabled.database.url).toEqual(expect.anything()); + expect(authDisabled.api).toBeUndefined(); + }), + ), ); it.live("fails closed when an enabled Auth secret slot is absent", () => From 3a78e848e831fe759c1eb03fa66ef99378b0d150 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Sep 2026 16:47:21 +0100 Subject: [PATCH 13/20] chore(cli): repair stack status quality checks Suppress the JSON serialization lint in the redaction assertions of the drift test and add ExperimentalStackStatusError to the error-tag fixture. --- apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt | 1 + packages/stack/src/public/config-drift.integration.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9417dee711..546c8afd03 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -233,6 +233,7 @@ EncryptionUnexpectedStatusError ExperimentalFeatureFlagError ExperimentalRequiredError ExperimentalStackStartError +ExperimentalStackStatusError ExperimentalStackStopError ExperimentalStackTargetError FeedbackBackendError diff --git a/packages/stack/src/public/config-drift.integration.test.ts b/packages/stack/src/public/config-drift.integration.test.ts index aa1946c537..74e8fcdba1 100644 --- a/packages/stack/src/public/config-drift.integration.test.ts +++ b/packages/stack/src/public/config-drift.integration.test.ts @@ -107,7 +107,9 @@ describe("inspectStack config drift", () => { "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", ]), ); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output expect(JSON.stringify(changed.configDrift)).not.toContain("old-secret"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output expect(JSON.stringify(changed.configDrift)).not.toContain("new-secret"); }), ), @@ -145,6 +147,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"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output expect(JSON.stringify(changed.configDrift)).not.toContain("managed-secret"); const removed = yield* inspectStack(stack.id, { config: { @@ -194,6 +197,7 @@ describe("inspectStack config drift", () => { Effect.tap((inspection) => Effect.sync(() => { expect(inspection.configDrift?.status).toBe("changed"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output expect(JSON.stringify(inspection.configDrift)).not.toContain("old-secret"); }), ), From ad513053f43ff091bc2c36a46d8c3cdefb43ab1a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Sep 2026 16:47:21 +0100 Subject: [PATCH 14/20] feat(cli): add stack status --env export status --env exports connection variables as dotenv or a JSON variable map, with --override-name for application-specific names. Ordinary status does not reveal credentials. Database-only stacks export database credentials with API credentials omitted when Auth is disabled. An absent config.toml is compared against default settings, matching stack start; invalid configuration still reports drift as unavailable. The legacy -o env form is rejected with a pointer to --env. --- .../stack/start/start.e2e.test.ts | 56 +++- .../experimental/stack/status/SIDE_EFFECTS.md | 42 ++- .../stack/status/status.command.ts | 13 + .../experimental/stack/status/status.env.ts | 95 ++++++ .../stack/status/status.env.unit.test.ts | 28 ++ .../stack/status/status.handler.ts | 36 ++- .../stack/status/status.integration.test.ts | 288 +++++++++++++++++- 7 files changed, 533 insertions(+), 25 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/status/status.env.ts create mode 100644 apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts 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 8934240254..e7029a1799 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 @@ -1,5 +1,6 @@ -// Starts and stops a native stack through the compiled CLI binary, then uses the package's -// public Promise API to inspect and destroy that stack. +// Starts a native stack through the compiled CLI binary, checks its status and connection-variable +// export, stops it, checks status again, then uses the package's public Promise API to inspect and +// destroy that stack. // 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 @@ -9,6 +10,7 @@ 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 { parse as parseDotenv } from "dotenv"; import { afterEach, describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -172,6 +174,34 @@ describe("stack start (compiled e2e)", () => { const databasePath = path.join(homeDir.dir, "managed", "stacks", idText, "data", "database"); await access(path.join(databasePath, "PG_VERSION")); + const status = await runSupabase(["stack", "status", "--stack-id", idText], { + cwd: projectRoot, + home: homeDir.dir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + expect(status.stdout).toContain(`(${idText})`); + expect(status.stdout).toContain("Owner: running"); + expect(status.stdout).toContain("Lifecycle: running"); + expect(status.stdout).toContain("Readiness: ready"); + expect(status.stdout).toMatch(/Config drift: (changed|unchanged)/u); + + const env = await runSupabase( + ["stack", "status", "--env", "--stack-id", idText, "--output-format", "json"], + { cwd: projectRoot, home: homeDir.dir, exitTimeoutMs: CLEANUP_TIMEOUT_MS }, + ); + expect(env.exitCode, `stdout:\n${env.stdout}\nstderr:\n${env.stderr}`).toBe(0); + const variables = JSON.parse(env.stdout) as Record; + expect(Object.keys(variables)).toEqual(["DB_URL"]); + expect(variables.DB_URL).toMatch(/^postgresql:\/\/postgres:.+@.+:\d+\/postgres$/u); + + const dotenv = await runSupabase( + ["stack", "status", "--env", "--stack-id", idText, "--output-format", "text"], + { cwd: projectRoot, home: homeDir.dir, exitTimeoutMs: CLEANUP_TIMEOUT_MS }, + ); + expect(dotenv.exitCode, `stdout:\n${dotenv.stdout}\nstderr:\n${dotenv.stderr}`).toBe(0); + expect(parseDotenv(dotenv.stdout)).toEqual(variables); + await rm(path.join(projectRoot, "supabase", "config.toml")); const stop = await runSupabase(["stack", "stop", "--stack-id", idText], { cwd: projectRoot, @@ -187,6 +217,28 @@ describe("stack start (compiled e2e)", () => { expect(observed.lifecycle).toBe("stopped"); expect(observed.database).toBe("stopped"); + const stoppedStatus = await runSupabase(["stack", "status", "--stack-id", idText], { + cwd: projectRoot, + home: homeDir.dir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect( + stoppedStatus.exitCode, + `stdout:\n${stoppedStatus.stdout}\nstderr:\n${stoppedStatus.stderr}`, + ).toBe(0); + expect(stoppedStatus.stdout).toContain("Owner: absent"); + expect(stoppedStatus.stdout).toContain("Lifecycle: unavailable"); + expect(stoppedStatus.stdout).toContain("Readiness: unknown"); + + const stoppedEnv = await runSupabase(["stack", "status", "--env", "--stack-id", idText], { + cwd: projectRoot, + home: homeDir.dir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(stoppedEnv.exitCode).not.toBe(0); + expect(stoppedEnv.stdout).not.toContain("DB_URL"); + expect(stoppedEnv.stderr).toContain("must be running"); + await access(path.join(databasePath, "PG_VERSION")); await destroyStack(homeDir.dir, idText); 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 73216f26c5..554b948427 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -1,8 +1,8 @@ # `supabase 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. +The command is read-only: it never creates, starts, prepares, stops, or destroys +a stack, and opens a stack handle only when `--env` is used. Target selection accepts the current project, `--stack `, or `--stack-id `. `--stack` and `--stack-id` are mutually exclusive. An explicit @@ -10,10 +10,40 @@ 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. +drift paths. An absent `supabase/config.toml` is compared using default +settings, matching `supabase stack start`. An invalid or unreadable +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. +the warning carried in `config_drift.message` when drift is `unavailable`. + +## Exporting environment variables (`--env`) + +`--env` opens the target stack, requires it to be running, and exports its +connection URLs and credentials instead of the ordinary identity/drift report. +It does not load or compare project configuration. Text output emits dotenv +assignments; JSON and stream-JSON output, including automatic agent detection, +emit a plain variable map under a successful result. The legacy `-o env` form +is rejected; use `--env` instead. + +The exported variables are `DB_URL`, `API_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY`, +`PUBLISHABLE_KEY`, `SECRET_KEY`, `STUDIO_URL`, `INBUCKET_URL`, +`S3_PROTOCOL_ACCESS_KEY_ID`, `S3_PROTOCOL_ACCESS_KEY_SECRET`, +`S3_PROTOCOL_REGION`, and `S3_PROTOCOL_URL`. `ANON_KEY`, `SERVICE_ROLE_KEY`, +`PUBLISHABLE_KEY`, and `SECRET_KEY` are omitted when the stack's Auth capability +is disabled. `API_URL`, `STUDIO_URL`, `INBUCKET_URL`, and the `S3_PROTOCOL_*` +variables are omitted when the corresponding endpoint or storage credentials are +unavailable. Values always come from the running stack; none are invented. + +`--override-name` renames an exported variable, accepting repeated flags or a +comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires +`--env` and rejects an unknown source variable, an invalid target name, a +missing or malformed entry, and a rename that collides with another exported +variable's name. + +Ordinary status (without `--env`) never opens a stack handle and never emits +credentials, regardless of the stack's lifecycle. A stopped stack or a +credentials failure with `--env` fails the command without emitting output. diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index cbe194fe33..7ba2d86ad7 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackStatus } from "./status.handler.ts"; @@ -10,6 +11,14 @@ const config = { Flag.withDescription("Inspect an existing stack by id."), Flag.optional, ), + env: Flag.boolean("env").pipe( + Flag.withDescription("Export connection URLs and credentials as environment variables."), + Flag.withDefault(false), + ), + overrideName: stringSliceFlag( + "override-name", + "Rename an exported variable: API_URL=NEXT_PUBLIC_SUPABASE_URL (requires --env).", + ), } as const; export type StackStatusFlags = CliCommand.Command.Config.Infer; @@ -26,6 +35,10 @@ export const stackStatusCommand = Command.make("status", config).pipe( command: "supabase stack status --stack feature-a", description: "Show a named stack", }, + { + command: "supabase stack status --env --output-format text > .env.local", + description: "Export connection variables as dotenv", + }, ]), Command.withHandler((flags) => stackStatus(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts new file mode 100644 index 0000000000..4d3bbd25ca --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -0,0 +1,95 @@ +import type { EffectStackCredentials, StackStatus } from "@supabase/stack/effect"; +import { Effect, Redacted } from "effect"; +import { StackCommandStatusError } from "./status.errors.ts"; + +const variableNames = [ + "API_URL", + "DB_URL", + "ANON_KEY", + "SERVICE_ROLE_KEY", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "STUDIO_URL", + "INBUCKET_URL", + "S3_PROTOCOL_ACCESS_KEY_ID", + "S3_PROTOCOL_ACCESS_KEY_SECRET", + "S3_PROTOCOL_REGION", + "S3_PROTOCOL_URL", +] as const; + +export const stackEnvOverrides = (entries: ReadonlyArray) => + Effect.gen(function* () { + const names = new Map(variableNames.map((name) => [String(name), String(name)])); + for (const entry of entries) { + const [source, target, extra] = entry.split("="); + if ( + source === undefined || + !names.has(source) || + target === undefined || + extra !== undefined || + !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target) + ) + return yield* new StackCommandStatusError({ + reason: "flags", + message: + "--override-name must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.", + }); + names.set(source, target); + } + if (new Set(names.values()).size !== names.size) + return yield* new StackCommandStatusError({ + reason: "flags", + message: "--override-name produces duplicate environment variable names.", + }); + return names; + }); + +export const stackEnvValues = ( + status: StackStatus, + credentials: EffectStackCredentials, + names: ReadonlyMap, +): Readonly> => { + const values: Record = { + DB_URL: Redacted.value(credentials.database.url), + ...(credentials.api === undefined + ? {} + : { + ANON_KEY: credentials.api.anonJwt, + SERVICE_ROLE_KEY: Redacted.value(credentials.api.serviceRoleJwt), + PUBLISHABLE_KEY: credentials.api.publishableKey, + SECRET_KEY: Redacted.value(credentials.api.secretKey), + }), + ...(status.endpoints.api === undefined ? {} : { API_URL: status.endpoints.api.url }), + ...(status.endpoints.studio === undefined ? {} : { STUDIO_URL: status.endpoints.studio.url }), + ...(status.endpoints.mailUi === undefined ? {} : { INBUCKET_URL: status.endpoints.mailUi.url }), + ...(credentials.storage === undefined + ? {} + : { + S3_PROTOCOL_ACCESS_KEY_ID: credentials.storage.accessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: Redacted.value(credentials.storage.secretAccessKey), + S3_PROTOCOL_REGION: credentials.storage.region, + S3_PROTOCOL_URL: credentials.storage.endpoint, + }), + }; + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [names.get(key) ?? key, value]), + ); +}; + +/** Dotenv quoting preserves URLs and keys verbatim, including literal backslashes. */ +export const encodeStackEnv = (values: Readonly>) => + Effect.forEach( + Object.entries(values).sort(([left], [right]) => left.localeCompare(right)), + ([name, value]) => { + const quote = ["'", "`"].find((candidate) => !value.includes(candidate)); + if (quote === undefined || value.includes("\r")) + return Effect.fail( + new StackCommandStatusError({ + reason: "runtime", + message: + "A credential cannot be represented losslessly as dotenv. Use --env --output-format json.", + }), + ); + return Effect.succeed(`${name}=${quote}${value}${quote}`); + }, + ).pipe(Effect.map((lines) => `${lines.join("\n")}\n`)); diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts new file mode 100644 index 0000000000..b4092d7b25 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "dotenv"; +import { Effect, Exit } from "effect"; +import { encodeStackEnv } from "./status.env.ts"; + +describe("stack dotenv encoding", () => { + it.effect("round-trips literal credentials without expanding or changing characters", () => + Effect.gen(function* () { + const values = { + TOKEN: "000123", + SECRET: "literal\\n$HOME#hash=equals\nnew line", + QUOTED: "it's a secret", + EMPTY: "", + }; + const encoded = yield* encodeStackEnv(values); + expect(parse(encoded)).toEqual(values); + }), + ); + + it.effect("fails without exposing values that dotenv cannot represent losslessly", () => + Effect.gen(function* () { + for (const value of ["both'and`quotes", "carriage\rreturn"]) { + const result = yield* encodeStackEnv({ SECRET: value }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + } + }), + ); +}); 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 d18eaf1aaa..e3b792cead 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -19,6 +19,7 @@ import { import { loadStackConfig } from "../stack-config.ts"; import type { StackStatusFlags } from "./status.command.ts"; import { StackCommandStatusError } from "./status.errors.ts"; +import { encodeStackEnv, stackEnvOverrides, stackEnvValues } from "./status.env.ts"; const mapTargetError = (error: StackTargetError) => new StackCommandStatusError({ @@ -167,17 +168,50 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( const output = yield* Output; const settings = yield* CommandSettings; const outputFlag = yield* Effect.serviceOption(OutputFlag); - yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* rejectStackOutput(outputFlag).pipe( + Effect.mapError((error) => + Option.isSome(outputFlag) && Option.getOrUndefined(outputFlag.value) === "env" + ? new StackCommandStatusError({ + reason: "flags", + message: error.message, + suggestion: + "Use --env to export connection variables; add --output-format json for a variable map.", + cause: error, + }) + : mapTargetError(error), + ), + ); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), stackId: Option.getOrUndefined(flags.stackId), }).pipe(Effect.mapError(mapTargetError)); + if (!flags.env && flags.overrideName.length > 0) + return yield* new StackCommandStatusError({ + reason: "flags", + message: "--override-name requires --env.", + }); + const envNames = yield* stackEnvOverrides(flags.overrideName); const target = yield* findDescriptor( settings.workdir, Option.getOrUndefined(flags.stack), Option.getOrUndefined(flags.stackId), ); const api = yield* StackApi; + if (flags.env) { + const stack = yield* catchStackError(api.openStack(target.id)); + const status = yield* catchStackError(stack.status()); + if (status.lifecycle !== "running") + return yield* new StackCommandStatusError({ + reason: "runtime", + message: "The stack must be running to export connection variables.", + suggestion: "Run supabase stack start first.", + }); + const credentials = yield* catchStackError(stack.credentials()); + const values = stackEnvValues(status, credentials, envNames); + if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); + else yield* output.success("", values); + return target.inspection; + } const loaded = yield* loadStackConfig(target.projectRoot).pipe( Effect.map((config) => ({ config, warning: undefined })), Effect.catchTag("StackConfigError", () => 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 152588ab2e..e3809183f7 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 @@ -5,13 +5,16 @@ import { tmpdir } from "node:os"; 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 { parse as parseDotenv } from "dotenv"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { InvalidStackConfigError, StackNotFoundError, + StackNotRunningError, StackIdSchema, StackStateFormatUnsupportedError, + type EffectStack, type StackInspection, type StackStatus, } from "@supabase/stack/effect"; @@ -20,7 +23,7 @@ import { mockCommandSettings, mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; -import { OutputFlag } from "../../../../command-internal/global-flags.ts"; +import { GLOBAL_OUTPUT_FORMATS, OutputFlag } from "../../../../command-internal/global-flags.ts"; import { actionability, ErrorActionabilityId, @@ -46,6 +49,8 @@ const capabilityNames = [ const flags = (stack = Option.none(), stackId = Option.none()) => ({ stack, stackId, + env: false, + overrideName: [] as string[], }); const makeStatus = ( @@ -76,8 +81,11 @@ const runStatus = (options: { readonly flags?: ReturnType; readonly compareFailure?: "typed" | "defect"; readonly missingTarget?: boolean; - readonly legacyOutput?: boolean; - readonly outputFormat?: "text" | "json"; + readonly legacyOutput?: (typeof GLOBAL_OUTPUT_FORMATS)[number]; + readonly outputFormat?: "text" | "json" | "stream-json"; + readonly credentialFailure?: boolean; + readonly storageCredentials?: boolean; + readonly authDisabled?: boolean; }) => { const root = mkdtempSync(join(tmpdir(), "supabase-stack-status-")); const projectRoot = join(root, "project"); @@ -113,7 +121,48 @@ const runStatus = (options: { findInputs.push(input); return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); }, - openStack: () => Effect.die("open must not run"), + openStack: (openId) => + Effect.succeed({ + id: openId, + status: () => Effect.succeed(options.status ?? makeStatus(id)), + credentials: () => + options.credentialFailure === true + ? Effect.fail( + new StackNotRunningError({ stackId: id, message: "Stack is not running" }), + ) + : Effect.succeed({ + database: { + url: Redacted.make("postgresql://postgres:p%40ss@127.0.0.1:54322/postgres"), + password: Redacted.make("p@ss"), + }, + ...(options.authDisabled === true + ? {} + : { + api: { + anonJwt: "anon-token", + serviceRoleJwt: Redacted.make("service-role-token"), + publishableKey: "sb_publishable_test", + secretKey: Redacted.make("sb_secret_test"), + }, + }), + ...(options.storageCredentials === true + ? { + storage: { + endpoint: "http://127.0.0.1:54321/storage/v1/s3", + region: "local", + accessKeyId: "storage-access", + secretAccessKey: Redacted.make("storage-secret"), + }, + } + : {}), + }), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + destroy: () => Effect.die("unused"), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + } satisfies EffectStack), inspectStack: (_stackId, inspectOptions) => { inspectInputs.push(inspectOptions); if (options.missingTarget === true) @@ -130,7 +179,9 @@ const runStatus = (options: { telemetry.layer, api, mockCommandSettings({ workdir: root }), - ...(options.legacyOutput === true ? [Layer.succeed(OutputFlag, Option.some("json"))] : []), + ...(options.legacyOutput === undefined + ? [] + : [Layer.succeed(OutputFlag, Option.some(options.legacyOutput))]), BunServices.layer, ); const effect = stackStatus(options.flags ?? flags()).pipe( @@ -188,9 +239,9 @@ describe("stack status", () => { ); }); - it.effect("reuses the explicit id inspection when config is missing", () => { + it.effect("reuses the explicit id inspection when config is invalid", () => { const run = runStatus({ - config: "missing", + config: "invalid", flags: flags(Option.none(), Option.some(id)), status: makeStatus(id), }); @@ -204,6 +255,25 @@ describe("stack status", () => { ); }); + it.effect("compares an absent config.toml against default settings like stack start", () => { + const run = runStatus({ + config: "missing", + flags: flags(Option.none(), Option.some(id)), + status: makeStatus(id), + drift: { status: "unchanged", paths: [] }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.inspectInputs).toHaveLength(2); + expect(run.inspectInputs[1]).toEqual({ config: expect.any(Object) }); + expect(run.out.stdoutText).toContain("Config drift: unchanged"); + expect(run.out.stdoutText).not.toContain("Config warning"); + }), + ), + ); + }); + it.effect("reports stopped and unreachable stacks without claiming live readiness", () => { const run = runStatus({ owner: "absent" }); return run.effect.pipe( @@ -234,9 +304,9 @@ describe("stack status", () => { ); }); - it.effect("emits the structured unavailable inspection for missing config", () => { + it.effect("emits the structured unavailable inspection for invalid config", () => { const run = runStatus({ - config: "missing", + config: "invalid", flags: flags(Option.none(), Option.some(id)), outputFormat: "json", }); @@ -258,7 +328,7 @@ describe("stack status", () => { desired_lifecycle: "running", config_drift: { status: "unavailable", - message: expect.any(String), + message: "Project configuration could not be loaded; fix it before checking drift.", }, }); }), @@ -280,18 +350,16 @@ describe("stack status", () => { ); }); - it.effect("reports unavailable drift for missing or invalid config and keeps inspection", () => { - const missing = runStatus({ config: "missing", status: makeStatus(id) }); + it.effect("reports unavailable drift for invalid config and keeps inspection", () => { const invalid = runStatus({ config: "invalid", status: makeStatus(id) }); const invalidJson = runStatus({ config: "invalid", status: makeStatus(id), outputFormat: "json", }); - return Effect.all([missing.effect, invalid.effect, invalidJson.effect]).pipe( + return Effect.all([invalid.effect, invalidJson.effect]).pipe( Effect.tap(() => Effect.sync(() => { - expect(missing.out.stdoutText).toContain("Config drift: unavailable"); expect(invalid.out.stdoutText).toContain("Config drift: unavailable"); expect(invalid.out.stdoutText).not.toContain("FAKE_STATUS_SECRET"); const success = invalidJson.out.messages.find((message) => message.type === "success"); @@ -359,7 +427,7 @@ describe("stack status", () => { 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 }); + const legacy = runStatus({ legacyOutput: "json" }); 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); @@ -368,6 +436,19 @@ describe("stack status", () => { }); }); + it.effect("rejects the legacy -o env form with a pointer to --env", () => { + const run = runStatus({ legacyOutput: "env" }); + return run.effect.pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.suggestion).toContain("--env"); + expect(run.findInputs).toHaveLength(0); + }), + ), + ); + }); + it.effect("does not retry discovery failures", () => { const run = runStatus({}); const telemetry = mockTelemetryStateTracked(); @@ -422,4 +503,179 @@ describe("stack status", () => { Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), ); }); + + it.live("parses env selection and repeated CSV variable overrides", () => { + let input: { env: boolean; overrideName: ReadonlyArray } | undefined; + const command = stackStatusCommand.pipe( + Command.withHandler((parsedFlags) => + Effect.sync(() => { + input = { env: parsedFlags.env, overrideName: parsedFlags.overrideName }; + }), + ), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })([ + "--env", + "--override-name", + "API_URL=APP_URL,ANON_KEY=APP_KEY", + "--override-name", + "DB_URL=DATABASE_URL", + ]); + expect(input?.env).toBe(true); + expect(input?.overrideName).toEqual([ + "API_URL=APP_URL", + "ANON_KEY=APP_KEY", + "DB_URL=DATABASE_URL", + ]); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); + + it.effect("exports the running stack credentials as dotenv with renamed variables", () => { + const run = runStatus({ + config: "invalid", + flags: { ...flags(), env: true, overrideName: ["API_URL=NEXT_PUBLIC_SUPABASE_URL"] }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(parseDotenv(run.out.stdoutText)).toEqual({ + NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", + DB_URL: "postgresql://postgres:p%40ss@127.0.0.1:54322/postgres", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-role-token", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + }); + expect(run.inspectInputs).toHaveLength(0); + }), + ), + ); + }); + + for (const outputFormat of ["json", "stream-json"] as const) { + it.effect(`exports a variable map in ${outputFormat}`, () => { + const run = runStatus({ flags: { ...flags(), env: true }, outputFormat }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + const success = run.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + API_URL: "http://127.0.0.1:54321", + SECRET_KEY: "sb_secret_test", + }); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + }); + } + + it.effect("exports optional service URLs and storage credentials only when available", () => { + const status: StackStatus = { + ...makeStatus(id), + endpoints: { + studio: { + protocol: "http", + address: "127.0.0.1", + port: 54323, + url: "http://127.0.0.1:54323", + }, + mailUi: { + protocol: "http", + address: "127.0.0.1", + port: 54324, + url: "http://127.0.0.1:54324", + }, + }, + }; + const run = runStatus({ flags: { ...flags(), env: true }, status, storageCredentials: true }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + const values = parseDotenv(run.out.stdoutText); + expect(values.API_URL).toBeUndefined(); + expect(values).toMatchObject({ + STUDIO_URL: "http://127.0.0.1:54323", + INBUCKET_URL: "http://127.0.0.1:54324", + S3_PROTOCOL_ACCESS_KEY_SECRET: "storage-secret", + S3_PROTOCOL_REGION: "local", + }); + }), + ), + ); + }); + + it.effect("exports a database-only stack without inventing API credentials", () => { + const run = runStatus({ + flags: { ...flags(), env: true }, + status: { ...makeStatus(id), endpoints: {} }, + authDisabled: true, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(parseDotenv(run.out.stdoutText)).toEqual({ + DB_URL: "postgresql://postgres:p%40ss@127.0.0.1:54322/postgres", + }); + }), + ), + ); + }); + + it.effect("keeps ordinary status independent of credentials and free of secrets", () => { + const run = runStatus({ status: makeStatus(id), credentialFailure: true }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.stdoutText).toContain("Lifecycle: running"); + expect(run.out.stdoutText).not.toContain("sb_secret_test"); + }), + ), + ); + }); + + it.effect("rejects invalid or colliding variable renames before discovery", () => { + const cases: ReadonlyArray>> = [ + { overrideName: ["API_URL=APP_URL"] }, + { env: true, overrideName: ["UNKNOWN=APP_URL"] }, + { env: true, overrideName: ["API_URL=NOT-VALID"] }, + { env: true, overrideName: ["API_URL=DB_URL"] }, + { env: true, overrideName: ["API_URL"] }, + { env: true, overrideName: ["API_URL=A=B"] }, + ]; + return Effect.forEach(cases, (overrides) => { + const run = runStatus({ flags: { ...flags(), ...overrides } }); + return run.effect.pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(run.findInputs).toHaveLength(0); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + }); + }); + + it.effect("exports no partial secrets when the stack is stopped or credentials fail", () => { + const stopped = runStatus({ + flags: { ...flags(), env: true }, + status: { ...makeStatus(id), lifecycle: "stopped" }, + }); + const failedCredentials = runStatus({ + flags: { ...flags(), env: true }, + credentialFailure: true, + }); + return Effect.gen(function* () { + const stoppedExit = yield* stopped.effect.pipe(Effect.exit); + expect(Exit.isFailure(stoppedExit)).toBe(true); + expect(stopped.out.stdoutText).toBe(""); + const failedExit = yield* failedCredentials.effect.pipe(Effect.exit); + expect(Exit.isFailure(failedExit)).toBe(true); + expect(failedCredentials.out.stdoutText).toBe(""); + }); + }); }); From 51f94f4acec40f120af3386ff6345095ff495fef Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Sep 2026 16:47:22 +0100 Subject: [PATCH 15/20] feat(cli): route top-level status through the stack feature flag supabase status follows [experimental] stack and SUPABASE_EXPERIMENTAL_STACK like start and stop, so a project on the new backend gets a status command that understands its stack. --- apps/cli/docs/stack-commands.md | 50 +++++++++++++------ apps/cli/src/cli/complete.unit.test.ts | 2 +- apps/cli/src/cli/root.ts | 7 ++- .../stack/stack-backend.integration.test.ts | 8 +-- .../experimental/stack/stack-backend.ts | 4 +- .../experimental/stack/stack.command.ts | 2 +- packages/config/src/experimental.ts | 3 +- 7 files changed, 50 insertions(+), 26 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index fe14097661..5d87620f44 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -4,19 +4,37 @@ command interface may change, and it is excluded from the CLI compatibility promise. It is available regardless of the project's backend setting and supports both Docker and native runtimes. -| Command | Purpose | -| ----------------------- | -------------------------------------- | -| `supabase stack start` | Create or resume the project's stack. | -| `supabase stack status` | Show identity, readiness, and drift. | -| `supabase stack stop` | Stop a stack while retaining its data. | +| Command | Purpose | +| ----------------------- | --------------------------------------------------------------------------------- | +| `supabase stack start` | Create or resume the project's stack. | +| `supabase stack status` | Show identity, readiness, and drift, or export connection variables with `--env`. | +| `supabase stack stop` | Stop a stack while retaining its data. | Use each command's `--help` for its available targeting and runtime options. +## Exporting environment variables + +```sh +supabase stack status --env --output-format text > .env.local +supabase status --env --override-name API_URL=NEXT_PUBLIC_SUPABASE_URL,ANON_KEY=NEXT_PUBLIC_SUPABASE_ANON_KEY +supabase stack status --env --output-format json +``` + +The top-level example requires the stack backend flag described below. `--env` exports the +connection URLs and credentials of the running stack; text mode emits dotenv assignments, and JSON +or stream-JSON mode emits a variable map. Add `--output-format text` for an explicit dotenv file +regardless of automatic agent output detection; this is dotenv data, not a shell script. Only this +explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` +accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects +unknown variables, invalid names, and collisions. API credentials are omitted when Auth is disabled. + +The legacy `supabase status -o env` form is rejected on the stack backend; use `--env` instead. + ## Selecting the top-level commands -The top-level `supabase start` and `supabase stop` commands use the legacy backend by default. -To make them aliases of the corresponding `supabase stack` commands, add this to -`supabase/config.toml`: +The top-level `supabase start`, `supabase stop`, and `supabase status` commands use the legacy +backend by default. To make them aliases of the corresponding `supabase stack` commands, add this +to `supabase/config.toml`: ```toml [experimental] @@ -25,22 +43,22 @@ stack = true The selected backend determines accepted flags, help, and completion before the command is parsed. Set the flag to `false`, or remove it, to restore the legacy top-level commands. Explicit -`supabase stack` commands always use the new backend. `supabase status` always uses its existing -command implementation and is unaffected by this flag. +`supabase stack` commands always use the new backend; `supabase status` is routed the same way as +`supabase start` and `supabase stop`. Root help and root completion do not read project configuration, so they remain available without -a project directory. Help and completion for `start` and `stop` resolve the same backend as the -command itself. If the project configuration cannot be read or parsed, or if +a project directory. Help and completion for `start`, `status`, and `stop` resolve the same backend +as the command itself. If the project configuration cannot be read or parsed, or if `experimental.stack` has an invalid value, routing falls back to the legacy backend. An invalid `SUPABASE_EXPERIMENTAL_STACK` value is still an error; set it to `0` to select the legacy -top-level command explicitly, or use the explicit `supabase stack start` or `supabase stack stop` -command. +top-level command explicitly, or use the explicit `supabase stack start`, `supabase stack status`, +or `supabase stack stop` command. For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack`; an unset or empty value falls back to the file setting. -Other values are rejected. The override affects only the top-level lifecycle aliases and is -applied before reading the project configuration. +Other values are rejected. The override affects only the top-level `start`, `status`, and `stop` +aliases and is applied before reading the project configuration. ## Data and configuration diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 92dd1bf716..f309212dd0 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -1267,7 +1267,7 @@ describe("tryComplete", () => { expect(stderrWrites).toHaveLength(1); expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); expect(stderrWrites[0]).toContain( - "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop, or use `supabase stack`.", + "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop/status, or use `supabase stack`.", ); expect(exits).toEqual([1]); }); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 47e94b924c..63641947c6 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -11,6 +11,7 @@ import { encryptionCommand } from "../commands/encryption/encryption.command.ts" import { stackRuntimeLayer, stackCommand } from "../commands/experimental/stack/stack.command.ts"; import { stackStartCommand } from "../commands/experimental/stack/start/start.command.ts"; import { stackStopCommand } from "../commands/experimental/stack/stop/stop.command.ts"; +import { stackStatusCommand } from "../commands/experimental/stack/status/status.command.ts"; import type { StackBackend } from "../commands/experimental/stack/stack-backend.ts"; import { computeCommand } from "../commands/experimental/compute/compute.command.ts"; import { feedbackCommand } from "../commands/feedback/feedback.command.ts"; @@ -76,6 +77,10 @@ export const stackStopAliasCommand = stackStopCommand.pipe( Command.provide(commandRuntimeLayer(["stop"])), Command.provide(stackRuntimeLayer), ); +const stackStatusAliasCommand = stackStatusCommand.pipe( + Command.provide(commandRuntimeLayer(["status"])), + Command.provide(stackRuntimeLayer), +); export const rootCommandForFeatures = ( options: { @@ -119,7 +124,7 @@ export const rootCommandForFeatures = ( ssoCommand, stackCommand, options.stackBackend === "stack" ? stackStartAliasCommand : startCommand, - statusCommand, + options.stackBackend === "stack" ? stackStatusAliasCommand : statusCommand, options.stackBackend === "stack" ? stackStopAliasCommand : stopCommand, storageCommand, telemetryCommand, diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 8c4b33e295..6eea905d5d 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -34,7 +34,7 @@ describe("resolveStackBackend", () => { }), ); - it.effect("selects the configured backend for top-level start and stop", () => { + it.effect("selects the configured backend for top-level start, stop, and status", () => { const root = project(`project_id = "stack-routing-test" [api] port = 55421 @@ -50,7 +50,7 @@ stack = true return Effect.gen(function* () { expect(yield* resolve({ args: ["start"], cwd: join(root, "nested"), env: {} })).toBe("stack"); expect(yield* resolve({ args: ["stop"], cwd: root, env: {} })).toBe("stack"); - expect(yield* resolve({ args: ["status"], cwd: root, env: {} })).toBe("legacy"); + expect(yield* resolve({ args: ["status"], cwd: root, env: {} })).toBe("stack"); }).pipe(Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true })))); }); @@ -215,14 +215,14 @@ stack = true expect(completionFlags("stack", "start")).not.toContain("--ignore-health-check"); }); - it("keeps status and stack on their existing command trees", () => { + it("routes status like start and stop, and keeps stack on its own command tree", () => { for (const backend of ["legacy", "stack"] as const) { const stackCommands = respondToComplete(rootCommandForFeatures({ stackBackend: backend }), [ "__complete", "stack", "", ])?.candidates.map(({ name }) => name); - expect(stackCommands).toEqual(["start", "stop"]); + expect(stackCommands).toEqual(["start", "status", "stop"]); expect(completionFlags(backend, "status")).toContain("--override-name"); } diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.ts b/apps/cli/src/commands/experimental/stack/stack-backend.ts index 82a7dd2beb..cdf823d126 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.ts @@ -17,7 +17,7 @@ export class StackRoutingError extends Data.TaggedError("StackRoutingError")<{ readonly cause?: unknown; }> { get suggestion(): string { - return "Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop, or use `supabase stack`."; + return "Set SUPABASE_EXPERIMENTAL_STACK=0 to use legacy start/stop/status, or use `supabase stack`."; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -92,7 +92,7 @@ export const resolveStackBackend = (input: { // The explicit namespace is always backed by the stack runtime and does // not need a project config or environment lookup to select it. if (command === "stack") return "stack"; - if (command !== "start" && command !== "stop") return "legacy"; + if (command !== "start" && command !== "stop" && command !== "status") return "legacy"; const configValue = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 8b9b4ab31a..f28c8e269e 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -31,6 +31,6 @@ export const stackCommand = Command.make("stack").pipe( "Manage an experimental, unstable local Supabase stack with the new backend. This command is excluded from the CLI compatibility promise.", ), Command.withShortDescription("Manage experimental local stacks"), - Command.withSubcommands([stackStartCommand, stackStopCommand, stackStatusCommand]), + Command.withSubcommands([stackStartCommand, stackStatusCommand, stackStopCommand]), Command.provide(stackRuntimeLayer), ); diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 36c2e5dbb2..d326c68d16 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -40,7 +40,8 @@ export const experimental = Schema.Struct({ ), stack: Schema.optionalKey( Schema.Boolean.annotate({ - description: "Use the new local stack backend for top-level start and stop commands.", + description: + "Use the new local stack backend for top-level start, stop, and status commands.", tags, }), ), From 138cf64804e73a4254e749d8874c9410b3e523e9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Sep 2026 17:03:20 +0100 Subject: [PATCH 16/20] fix(cli): emit the stack status env map without a message key output.success merges message into the JSON payload, so --env produced { DB_URL, message: "" }. Use output.result for the bare variable map and assert the serialized shape in the integration test. --- .../stack/status/status.handler.ts | 2 +- .../stack/status/status.integration.test.ts | 51 ++++++++++++------- 2 files changed, 35 insertions(+), 18 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 e3b792cead..4e14c096e8 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -209,7 +209,7 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( const credentials = yield* catchStackError(stack.credentials()); const values = stackEnvValues(status, credentials, envNames); if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); - else yield* output.success("", values); + else yield* output.result(values); return target.inspection; } const loaded = yield* loadStackConfig(target.projectRoot).pipe( 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 e3809183f7..736c26403f 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 @@ -6,7 +6,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { parse as parseDotenv } from "dotenv"; -import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Schema, Stream } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { InvalidStackConfigError, @@ -554,23 +554,40 @@ describe("stack status", () => { ); }); - for (const outputFormat of ["json", "stream-json"] as const) { - it.effect(`exports a variable map in ${outputFormat}`, () => { - const run = runStatus({ flags: { ...flags(), env: true }, outputFormat }); - return run.effect.pipe( - Effect.tap(() => - Effect.sync(() => { - const success = run.out.messages.find((message) => message.type === "success"); - expect(success?.data).toMatchObject({ - API_URL: "http://127.0.0.1:54321", - SECRET_KEY: "sb_secret_test", - }); - expect(run.out.stdoutText).toBe(""); - }), - ), - ); + const exportedVariables = { + API_URL: "http://127.0.0.1:54321", + DB_URL: "postgresql://postgres:p%40ss@127.0.0.1:54322/postgres", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-role-token", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + }; + const VariableMapJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); + + it.effect("exports a bare variable map in json", () => { + const run = runStatus({ flags: { ...flags(), env: true }, outputFormat: "json" }); + return Effect.gen(function* () { + yield* run.effect; + const variables = yield* Schema.decodeEffect(VariableMapJson)(run.out.stdoutText.trim()); + expect(variables).toEqual(exportedVariables); + expect(run.out.messages).toEqual([]); }); - } + }); + + it.effect("exports a variable map as a stream-json result event", () => { + const run = runStatus({ flags: { ...flags(), env: true }, outputFormat: "stream-json" }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + const results = run.out.events.flatMap((event) => + event.type === "result" ? [event.data] : [], + ); + expect(results).toEqual([exportedVariables]); + expect(run.out.stdoutText).toBe(""); + }), + ), + ); + }); it.effect("exports optional service URLs and storage credentials only when available", () => { const status: StackStatus = { From 6b21e4341ee47dc254c6e829b334acba60916ecb Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 12 Sep 2026 14:07:14 +0100 Subject: [PATCH 17/20] chore(docs): regenerate config schemas for stack status routing --- apps/docs/public/cli/config.schema.json | 4 ++-- apps/docs/public/cli/project-config.schema.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 6b39d639c5..3fd537226d 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2406,7 +2406,7 @@ }, "stack": { "type": "boolean", - "description": "Use the new local stack backend for top-level start and stop commands." + "description": "Use the new local stack backend for top-level start, stop, and status commands." }, "orioledb_version": { "type": "string", @@ -4913,7 +4913,7 @@ }, "stack": { "type": "boolean", - "description": "Use the new local stack backend for top-level start and stop commands." + "description": "Use the new local stack backend for top-level start, stop, and status commands." }, "orioledb_version": { "type": "string", diff --git a/apps/docs/public/cli/project-config.schema.json b/apps/docs/public/cli/project-config.schema.json index fa499cf054..2f4f1de393 100644 --- a/apps/docs/public/cli/project-config.schema.json +++ b/apps/docs/public/cli/project-config.schema.json @@ -1944,7 +1944,7 @@ }, "stack": { "type": "boolean", - "description": "Use the new local stack backend for top-level start and stop commands." + "description": "Use the new local stack backend for top-level start, stop, and status commands." }, "orioledb_version": { "type": "string", From f4f708ba9dd553d421d4e9946aa7dfca98e3bc51 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 12 Sep 2026 14:59:59 +0100 Subject: [PATCH 18/20] fix(cli): address stack status review findings Classify lifecycle and dotenv-encoding failures as user-actionable instead of network errors, surface the typed diagnostic when a config comparison is rejected, reject repeated --override-name sources, use double quotes for dotenv values that need them, return void from the handler, and document exit codes, telemetry, and the legacy -o rejection. --- apps/cli/docs/stack-commands.md | 4 +- .../experimental/stack/status/SIDE_EFFECTS.md | 76 ++++++++++++++----- .../experimental/stack/status/status.env.ts | 20 ++++- .../stack/status/status.env.unit.test.ts | 9 ++- .../stack/status/status.errors.ts | 17 ++++- .../stack/status/status.handler.ts | 26 +++++-- .../stack/status/status.integration.test.ts | 22 +++++- 7 files changed, 136 insertions(+), 38 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 9af66aaf5c..131ab67aa2 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -29,7 +29,9 @@ explicit export reveals credentials. Ordinary status remains free of secrets. `- accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. API credentials are omitted when Auth is disabled. -The legacy `supabase status -o env` form is rejected on the stack backend; use `--env` instead. +The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pretty`, `json`, +`toml`, `yaml`, `table`, and `csv`. `--output-format text`, `json`, or `stream-json` replace them. +`-o env` becomes `--env`. ## Selecting the top-level commands 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 554b948427..6845123aaf 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -5,29 +5,39 @@ The command is read-only: it never creates, starts, prepares, stops, or destroys a stack, and opens a stack handle only when `--env` is used. 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. +`--stack-id `. `--stack` and `--stack-id` are mutually exclusive. Any +explicit legacy `-o/--output` value is rejected; use `--output-format` instead, +or `--env` in place of the `env` value. -When the project configuration can be loaded, status includes redacted config -drift paths. An absent `supabase/config.toml` is compared using default -settings, matching `supabase stack start`. An invalid or unreadable -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. +When the project configuration loads and the comparison accepts it, status +includes redacted config drift paths. An absent `supabase/config.toml` is +compared using default settings, matching `supabase stack start`. 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 -the warning carried in `config_drift.message` when drift is `unavailable`. +When the configuration cannot be loaded at all, the warning is `Project +configuration could not be loaded; fix it before checking drift.` When it +loads but the comparison rejects it, such as an invalid stack config or an +unsupported version, the warning is `Project configuration could not be +compared: `. Either warning leaves the persisted stack +inspection available, appears as `Config warning:` in text output, and as +`config_drift.message` with `status: "unavailable"` in JSON. + +Text output includes identity, runtime, owner, lifecycle, readiness, +endpoints, and config drift. JSON output contains the same fields under +`identity`. ## Exporting environment variables (`--env`) `--env` opens the target stack, requires it to be running, and exports its connection URLs and credentials instead of the ordinary identity/drift report. It does not load or compare project configuration. Text output emits dotenv -assignments; JSON and stream-JSON output, including automatic agent detection, -emit a plain variable map under a successful result. The legacy `-o env` form -is rejected; use `--env` instead. +assignments, quoting each value with single quotes, double quotes, or +backticks, choosing the first that round-trips; a value containing all three +quote kinds, or a backslash together with both a single quote and a backtick, +or a carriage return, fails the command with a pointer to +`--output-format json`. JSON and stream-JSON output, including automatic agent +detection, emit a plain variable map under a successful result. As described +above, the legacy `-o env` value is rejected with guidance to use `--env`. The exported variables are `DB_URL`, `API_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `PUBLISHABLE_KEY`, `SECRET_KEY`, `STUDIO_URL`, `INBUCKET_URL`, @@ -40,10 +50,40 @@ unavailable. Values always come from the running stack; none are invented. `--override-name` renames an exported variable, accepting repeated flags or a comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires -`--env` and rejects an unknown source variable, an invalid target name, a -missing or malformed entry, and a rename that collides with another exported -variable's name. +`--env` and rejects an unknown source variable, a source variable listed more +than once, an invalid target name, a missing or malformed entry, and a rename +that collides with another exported variable's name. Ordinary status (without `--env`) never opens a stack handle and never emits credentials, regardless of the stack's lifecycle. A stopped stack or a credentials failure with `--env` fails the command without emitting output. + +## Files read and written + +Without `--env`, the command reads `supabase/config.toml` and the project +dotenv files the shared config loader consults to resolve the target stack's +configuration; with `--env`, it skips config loading entirely. Either way, it +reads the target stack's persisted state under +`/managed/stacks//`, and when a live owner +exists, it reads the owner's local RPC endpoint for status and credentials. +The command calls no API routes and writes no files besides `telemetry.json`. +It reads no environment variables beyond the CLI's usual `SUPABASE_HOME`, +`SUPABASE_WORKDIR`, and `SUPABASE_EXPERIMENTAL_STACK` routing. + +## Output and telemetry + +Exit status is `0` for a successful report, including a stopped stack or an +absent or unreachable owner, and `130` if the command is interrupted. It is +`1` for a flag validation failure (`--stack` with `--stack-id`, any legacy +`-o/--output` value, `--override-name` without `--env` or with an unknown +source, an invalid target name, a duplicate source, or a colliding +destination), for no stack in the current context or an unknown `--stack-id`, +for a typed stack failure, for `--env` against a stack that is not running, or +for a value that dotenv cannot represent losslessly. + +Standard command instrumentation (`withCommandTelemetry`) records command +metadata and flag presence; stack identity, endpoints, and credentials are +never telemetry properties. + +Telemetry state is flushed to `/telemetry.json` +after both successful and failed command runs. diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 4d3bbd25ca..0677ce9ff3 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -20,6 +20,7 @@ const variableNames = [ export const stackEnvOverrides = (entries: ReadonlyArray) => Effect.gen(function* () { const names = new Map(variableNames.map((name) => [String(name), String(name)])); + const sources = new Set(); for (const entry of entries) { const [source, target, extra] = entry.split("="); if ( @@ -34,6 +35,12 @@ export const stackEnvOverrides = (entries: ReadonlyArray) => message: "--override-name must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.", }); + if (sources.has(source)) + return yield* new StackCommandStatusError({ + reason: "flags", + message: `--override-name lists ${source} more than once.`, + }); + sources.add(source); names.set(source, target); } if (new Set(names.values()).size !== names.size) @@ -76,16 +83,23 @@ export const stackEnvValues = ( ); }; -/** Dotenv quoting preserves URLs and keys verbatim, including literal backslashes. */ +const dotenvQuote = (value: string): string | undefined => { + if (!value.includes("'")) return "'"; + if (!value.includes('"') && !value.includes("\\")) return '"'; + if (!value.includes("`")) return "`"; + return undefined; +}; + +/** dotenv only expands `\n`/`\r` escapes inside double quotes, so a value with a backslash skips double quotes to keep its escape sequences literal. */ export const encodeStackEnv = (values: Readonly>) => Effect.forEach( Object.entries(values).sort(([left], [right]) => left.localeCompare(right)), ([name, value]) => { - const quote = ["'", "`"].find((candidate) => !value.includes(candidate)); + const quote = dotenvQuote(value); if (quote === undefined || value.includes("\r")) return Effect.fail( new StackCommandStatusError({ - reason: "runtime", + reason: "output", message: "A credential cannot be represented losslessly as dotenv. Use --env --output-format json.", }), diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index b4092d7b25..41bf13651e 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { parse } from "dotenv"; -import { Effect, Exit } from "effect"; +import { Effect } from "effect"; import { encodeStackEnv } from "./status.env.ts"; describe("stack dotenv encoding", () => { @@ -10,6 +10,7 @@ describe("stack dotenv encoding", () => { TOKEN: "000123", SECRET: "literal\\n$HOME#hash=equals\nnew line", QUOTED: "it's a secret", + MIXED: "it's a `secret`", EMPTY: "", }; const encoded = yield* encodeStackEnv(values); @@ -19,9 +20,9 @@ describe("stack dotenv encoding", () => { it.effect("fails without exposing values that dotenv cannot represent losslessly", () => Effect.gen(function* () { - for (const value of ["both'and`quotes", "carriage\rreturn"]) { - const result = yield* encodeStackEnv({ SECRET: value }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); + for (const value of ["all'three`quotes\"", "both'and`quotes\\n", "carriage\rreturn"]) { + const error = yield* encodeStackEnv({ SECRET: value }).pipe(Effect.flip); + expect(error.reason).toBe("output"); } }), ); 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 1e1cc8120c..4f3c1d59d5 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.errors.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -7,12 +7,23 @@ import { export class StackCommandStatusError extends Data.TaggedError("ExperimentalStackStatusError")<{ readonly message: string; - readonly reason: "flags" | "not-found" | "invalid-config" | "runtime"; + readonly reason: "flags" | "not-found" | "invalid-config" | "lifecycle" | "output" | "runtime"; readonly suggestion?: string; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - if (this.reason === "flags" || this.reason === "not-found") return actionability.provideFlags; - return this.reason === "runtime" ? actionability.externalNetwork : actionability.invalidConfig; + switch (this.reason) { + case "flags": + case "not-found": + return actionability.provideFlags; + case "invalid-config": + return actionability.invalidConfig; + case "lifecycle": + return actionability.startStack; + case "output": + return actionability.provideFlags; + case "runtime": + return actionability.unknown; + } } } 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 b4fd9b8e9b..6d4d1737f0 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -48,6 +48,10 @@ const classifyStackError = (error: StackError) => "InvalidJwtSigningMaterialError", () => ({ reason: "invalid-config" as const }), ), + Match.tag("StackNotRunningError", "StackLifecycleConflictError", () => ({ + reason: "lifecycle" as const, + suggestion: "Run supabase stack start first.", + })), Match.orElse(() => ({ reason: "runtime" as const, suggestion: "Retry the command and use --debug if the stack state remains unavailable.", @@ -79,6 +83,9 @@ const readiness = (status: StackStatus | undefined): string => { const configUnavailableWarning = "Project configuration could not be loaded; fix it before checking drift."; +const configComparisonWarning = (detail: string) => + `Project configuration could not be compared: ${detail}`; + const payload = (inspection: StackInspection, configWarning?: string) => ({ identity: { id: inspection.descriptor.id, @@ -202,7 +209,7 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( const status = yield* catchStackError(stack.status); if (status.lifecycle !== "running") return yield* new StackCommandStatusError({ - reason: "runtime", + reason: "lifecycle", message: "The stack must be running to export connection variables.", suggestion: "Run supabase stack start first.", }); @@ -210,7 +217,7 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( const values = stackEnvValues(status, credentials, envNames); if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); else yield* output.result(values); - return target.inspection; + return; } const loaded = yield* loadStackConfig(target.projectRoot).pipe( Effect.map((config) => ({ config, warning: undefined })), @@ -226,10 +233,16 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( Effect.map(comparedInspection), Effect.catchTags({ - InvalidStackConfigError: () => - Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), - StackVersionUnsupportedError: () => - Effect.succeed({ inspection: undefined, warning: configUnavailableWarning }), + InvalidStackConfigError: (error) => + Effect.succeed({ + inspection: undefined, + warning: configComparisonWarning(error.message), + }), + StackVersionUnsupportedError: (error) => + Effect.succeed({ + inspection: undefined, + warning: configComparisonWarning(error.message), + }), }), catchStackError, ); @@ -240,7 +253,6 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( 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; }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); 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 8497b37351..13d47b7394 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 @@ -415,11 +415,27 @@ describe("stack status", () => { it.effect("falls back only for typed comparison errors and preserves defects", () => { const typed = runStatus({ compareFailure: "typed", status: makeStatus(id) }); + const typedJson = runStatus({ + compareFailure: "typed", + status: makeStatus(id), + outputFormat: "json", + }); 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"); + expect(typed.out.stdoutText).toContain( + "Config warning: Project configuration could not be compared: candidate config is invalid", + ); + yield* typedJson.effect; + const success = typedJson.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + config_drift: { + status: "unavailable", + message: "Project configuration could not be compared: candidate config is invalid", + }, + }); const exit = yield* defect.effect.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(defect.inspectInputs).toHaveLength(1); @@ -663,6 +679,7 @@ describe("stack status", () => { { env: true, overrideName: ["API_URL=DB_URL"] }, { env: true, overrideName: ["API_URL"] }, { env: true, overrideName: ["API_URL=A=B"] }, + { env: true, overrideName: ["API_URL=A", "API_URL=B"] }, ]; return Effect.forEach(cases, (overrides) => { const run = runStatus({ flags: { ...flags(), ...overrides } }); @@ -689,8 +706,9 @@ describe("stack status", () => { credentialFailure: true, }); return Effect.gen(function* () { - const stoppedExit = yield* stopped.effect.pipe(Effect.exit); - expect(Exit.isFailure(stoppedExit)).toBe(true); + const stoppedError = yield* stopped.effect.pipe(Effect.flip); + expect(stoppedError.reason).toBe("lifecycle"); + expect(stoppedError[ErrorActionabilityId]).toEqual(actionability.startStack); expect(stopped.out.stdoutText).toBe(""); const failedExit = yield* failedCredentials.effect.pipe(Effect.exit); expect(Exit.isFailure(failedExit)).toBe(true); From 0cd8b73766b747cd2ffcf19211a892ddcf3c90e8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 14 Sep 2026 10:17:36 +0200 Subject: [PATCH 19/20] test(cli): verify gated stack status routing --- apps/cli/docs/stack-commands.md | 4 ++-- .../stack/stack-backend.integration.test.ts | 11 ++++++++--- .../experimental/stack/start/start.e2e.test.ts | 14 ++++++++++++++ .../experimental/stack/status/SIDE_EFFECTS.md | 17 +++++++++-------- apps/cli/src/commands/status/SIDE_EFFECTS.md | 10 ++++++++++ 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 240f1d7626..8c375586bc 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -22,8 +22,8 @@ supabase status --env --override-name API_URL=NEXT_PUBLIC_SUPABASE_URL,ANON_KEY= supabase stack status --env --output-format json ``` -The top-level example requires the stack backend flag described below. `--env` exports the -connection URLs and credentials of the running stack; text mode emits dotenv assignments, and JSON +Each example requires the stack backend flag described below. `--env` exports the connection URLs +and credentials of the running stack; text mode emits dotenv assignments, and JSON or stream-JSON mode emits a variable map. Add `--output-format text` for an explicit dotenv file regardless of automatic agent output detection; this is dotenv data, not a shell script. Only this explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 39648e6e65..0567d147be 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -220,11 +220,13 @@ stack = true [], ["--help"], ["help", "start"], + ["help", "status"], ["help", "stop"], ["help", "stack"], ["stack", "--help"], ["__complete", "st"], ["__complete", "start", "--"], + ["__complete", "status", "--"], ]) { expect(yield* resolve({ args, cwd: root, env: {} })).toBe("stack"); } @@ -265,7 +267,7 @@ stack = true expect(completionFlags("stack", "start")).not.toContain("--ignore-health-check"); }); - it("gates the stack command tree and routes status like start and stop", () => { + it("gates the stack command tree while preserving status completion", () => { const disabledRoot = respondToComplete(rootCommandForFeatures({ stackBackend: "legacy" }), [ "__complete", "", @@ -286,8 +288,11 @@ stack = true expect(stackCommands).toEqual( backend === "stack" ? ["destroy", "start", "status", "stop"] : [], ); - - expect(completionFlags(backend, "status")).toContain("--override-name"); } + expect(completionFlags("stack", "status")).toContain("--override-name"); + expect(completionFlags("stack", "status")).toContain("--env"); + expect(completionFlags("stack", "status")).toContain("--stack-id"); + expect(completionFlags("legacy", "status")).not.toContain("--stack-id"); + expect(completionFlags("legacy", "status")).not.toContain("--env"); }); }); 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 e72ad06159..28dbc5b47a 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 @@ -189,6 +189,20 @@ describe("stack start (compiled e2e)", () => { expect(status.stdout).toContain("Readiness: ready"); expect(status.stdout).toMatch(/Config drift: (changed|unchanged)/u); + const topLevelStatus = await runSupabase(["status", "--stack-id", idText], { + cwd: projectRoot, + home: homeDir.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect( + topLevelStatus.exitCode, + `stdout:\n${topLevelStatus.stdout}\nstderr:\n${topLevelStatus.stderr}`, + ).toBe(0); + expect(topLevelStatus.stdout).toContain(`(${idText})`); + expect(topLevelStatus.stdout).toContain("Owner: running"); + expect(topLevelStatus.stdout).toContain("Lifecycle: running"); + const env = await runSupabase( ["stack", "status", "--env", "--stack-id", idText, "--output-format", "json"], { cwd: projectRoot, home: homeDir.dir, exitTimeoutMs: CLEANUP_TIMEOUT_MS }, 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 6ba8e394fd..be42288fb2 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -3,7 +3,6 @@ Reports the persisted identity and current owner state of a managed local stack. The command is read-only: it never creates, starts, prepares, stops, or destroys a stack, and opens a stack handle only when `--env` is used. - The command is available only when the `experimental.stack` feature flag is enabled. The top-level `supabase status` command uses this handler when the same flag is enabled. Command routing may read `supabase/config.toml` or @@ -30,8 +29,9 @@ inspection available, appears as `Config warning:` in text output, and as `config_drift.message` with `status: "unavailable"` in JSON. Text output includes identity, runtime, owner, lifecycle, readiness, -endpoints, and config drift. JSON output contains the same fields under -`identity`. +endpoints, and config drift. JSON output nests only the identity fields under +`identity`; runtime, lifecycle, readiness, endpoints, and config drift remain +top-level fields. ## Exporting environment variables (`--env`) @@ -67,15 +67,16 @@ credentials failure with `--env` fails the command without emitting output. ## Files read and written -Without `--env`, the command reads `supabase/config.toml` and the project -dotenv files the shared config loader consults to resolve the target stack's -configuration; with `--env`, it skips config loading entirely. Either way, it +Without `--env`, the command reads the selected `supabase/config.toml` or +`supabase/config.json` and the project dotenv files and environment overrides +the shared config loader consults to resolve the target stack's configuration; +with `--env`, it skips config loading entirely. Either way, it reads the target stack's persisted state under `/managed/stacks//`, and when a live owner exists, it reads the owner's local RPC endpoint for status and credentials. The command calls no API routes and writes no files besides `telemetry.json`. -It reads no environment variables beyond the CLI's usual `SUPABASE_HOME`, -`SUPABASE_WORKDIR`, and `SUPABASE_EXPERIMENTAL_STACK` routing. +It reads the CLI's usual home, workdir, and experimental routing variables, +plus the environment variables used as project configuration overrides. ## Output and telemetry diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index 28284270e6..602836ae79 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -1,5 +1,15 @@ # `supabase status` +This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1`, or +`[experimental] stack = true` when the environment override is unset or empty, `supabase status` +uses the new [`supabase stack status` implementation](../experimental/stack/status/SIDE_EFFECTS.md). +`SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). + +Backend routing reads `supabase/config.json` when present, otherwise `supabase/config.toml`; the +legacy handler reads TOML only. Backend selection happens before command parsing. When the +environment override is unset or empty, an unreadable, malformed, or invalid project configuration +falls back to the legacy backend; an invalid environment override remains an error. + TS-only divergence (CLI-2167 follow-up, no Go counterpart): `status` additionally resolves and surfaces the current linked project/branch — a "Linked Project:" block on stdout in human text mode, and additive fields in every machine-readable output — so an agent (or a human who forgot From 5aea6e56a2b66239f611f184699947dc0ae585e0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 14 Sep 2026 09:31:59 +0100 Subject: [PATCH 20/20] fix(cli): quote stack status dotenv export against shell expansion Double quotes are used only for values without ", backslash, $, backtick, or !, and backticks are no longer a delimiter, so sourcing the exported file cannot run command substitutions embedded in a credential. --- apps/cli/docs/stack-commands.md | 3 ++- .../experimental/stack/status/SIDE_EFFECTS.md | 8 ++++---- .../experimental/stack/status/status.env.ts | 11 +++++++---- .../stack/status/status.env.unit.test.ts | 19 ++++++++++++++++--- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 8c375586bc..d02d078e87 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -25,7 +25,8 @@ supabase stack status --env --output-format json Each example requires the stack backend flag described below. `--env` exports the connection URLs and credentials of the running stack; text mode emits dotenv assignments, and JSON or stream-JSON mode emits a variable map. Add `--output-format text` for an explicit dotenv file -regardless of automatic agent output detection; this is dotenv data, not a shell script. Only this +regardless of automatic agent output detection; this is dotenv data, not a shell script, and values +are quoted so that sourcing the file performs no shell expansion. Only this explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. API credentials are omitted when Auth is disabled. 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 be42288fb2..94015dbc5c 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -38,10 +38,10 @@ top-level fields. `--env` opens the target stack, requires it to be running, and exports its connection URLs and credentials instead of the ordinary identity/drift report. It does not load or compare project configuration. Text output emits dotenv -assignments, quoting each value with single quotes, double quotes, or -backticks, choosing the first that round-trips; a value containing all three -quote kinds, or a backslash together with both a single quote and a backtick, -or a carriage return, fails the command with a pointer to +assignments, quoting each value with single quotes, or with double quotes when +the value contains a single quote but none of `"`, `\`, `$`, backtick, or `!`, +so sourcing the file in a shell performs no expansion. Any other value, or one +containing a carriage return, fails the command with a pointer to `--output-format json`. JSON and stream-JSON output, including automatic agent detection, emit a plain variable map under a successful result. As described above, the legacy `-o env` value is rejected with guidance to use `--env`. diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 0677ce9ff3..216a0d1012 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -85,12 +85,15 @@ export const stackEnvValues = ( const dotenvQuote = (value: string): string | undefined => { if (!value.includes("'")) return "'"; - if (!value.includes('"') && !value.includes("\\")) return '"'; - if (!value.includes("`")) return "`"; + if (!/["\\$`!]/u.test(value)) return '"'; return undefined; }; -/** dotenv only expands `\n`/`\r` escapes inside double quotes, so a value with a backslash skips double quotes to keep its escape sequences literal. */ +/** + * Quotes so that both dotenv parsers and a shell that sources the file read every + * value literally: double quotes are used only without `"`, `\`, `$`, backtick, or + * `!`, and backticks are never a delimiter. + */ export const encodeStackEnv = (values: Readonly>) => Effect.forEach( Object.entries(values).sort(([left], [right]) => left.localeCompare(right)), @@ -101,7 +104,7 @@ export const encodeStackEnv = (values: Readonly>) => new StackCommandStatusError({ reason: "output", message: - "A credential cannot be represented losslessly as dotenv. Use --env --output-format json.", + "A credential cannot be written safely as dotenv. Use --env --output-format json.", }), ); return Effect.succeed(`${name}=${quote}${value}${quote}`); diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index 41bf13651e..5dba18aa48 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -10,7 +10,6 @@ describe("stack dotenv encoding", () => { TOKEN: "000123", SECRET: "literal\\n$HOME#hash=equals\nnew line", QUOTED: "it's a secret", - MIXED: "it's a `secret`", EMPTY: "", }; const encoded = yield* encodeStackEnv(values); @@ -18,9 +17,23 @@ describe("stack dotenv encoding", () => { }), ); - it.effect("fails without exposing values that dotenv cannot represent losslessly", () => + it.effect("quotes so that sourcing the file performs no shell expansion", () => Effect.gen(function* () { - for (const value of ["all'three`quotes\"", "both'and`quotes\\n", "carriage\rreturn"]) { + const encoded = yield* encodeStackEnv({ QUOTED: "it's a secret", PLAIN: "plain$(value)" }); + expect(encoded).toBe(`PLAIN='plain$(value)'\nQUOTED="it's a secret"\n`); + }), + ); + + it.effect("fails without exposing values that cannot be written safely", () => + Effect.gen(function* () { + for (const value of [ + "it's $(whoami)", + "it's a `secret`", + "it's !history", + "it's a \\n escape", + "all'three`quotes\"", + "carriage\rreturn", + ]) { const error = yield* encodeStackEnv({ SECRET: value }).pipe(Effect.flip); expect(error.reason).toBe("output"); }