diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index c2382fd8fa..131ab67aa2 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -4,19 +4,40 @@ 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 destroy` | Permanently delete one stack and its data. | -| `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. | +| `supabase stack destroy` | Permanently delete one stack and 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 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 -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 +46,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 18f20afe78..0628159ffe 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(["destroy", "start", "stop"]); + expect(stackCommands).toEqual(["destroy", "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 ddea323de6..f8510869d5 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 { stackStatusCommand as stackStatusCommandBase } from "./status/status.command.ts"; import { stackDestroyCommand as stackDestroyCommandBase } from "./destroy/destroy.command.ts"; import { stackApiLayer, stackTargetResolverLayer } from "./stack.shared.ts"; @@ -22,6 +23,9 @@ const stackStartCommand = stackStartCommandBase.pipe( const stackStopCommand = stackStopCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "stop"])), ); +const stackStatusCommand = stackStatusCommandBase.pipe( + Command.provide(commandRuntimeLayer(["stack", "status"])), +); const stackDestroyCommand = stackDestroyCommandBase.pipe( Command.provide(commandRuntimeLayer(["stack", "destroy"])), ); @@ -31,6 +35,11 @@ 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, stackDestroyCommand]), + Command.withSubcommands([ + stackStartCommand, + stackStatusCommand, + stackStopCommand, + stackDestroyCommand, + ]), Command.provide(stackRuntimeLayer), ); 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 new file mode 100644 index 0000000000..6845123aaf --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -0,0 +1,89 @@ +# `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, 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. Any +explicit legacy `-o/--output` value is rejected; use `--output-format` instead, +or `--env` in place of the `env` value. + +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. + +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, 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`, +`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, 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.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts new file mode 100644 index 0000000000..7ba2d86ad7 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -0,0 +1,46 @@ +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"; + +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, + ), + 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; + +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: "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..0677ce9ff3 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -0,0 +1,109 @@ +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)])); + const sources = new Set(); + 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.", + }); + 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) + 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]), + ); +}; + +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 = dotenvQuote(value); + if (quote === undefined || value.includes("\r")) + return Effect.fail( + new StackCommandStatusError({ + reason: "output", + 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..41bf13651e --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "dotenv"; +import { Effect } 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", + MIXED: "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 ["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 new file mode 100644 index 0000000000..4f3c1d59d5 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.errors.ts @@ -0,0 +1,29 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class StackCommandStatusError extends Data.TaggedError("ExperimentalStackStatusError")<{ + readonly message: string; + readonly reason: "flags" | "not-found" | "invalid-config" | "lifecycle" | "output" | "runtime"; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + 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 new file mode 100644 index 0000000000..6d4d1737f0 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -0,0 +1,258 @@ +import { Effect, Match, Option } from "effect"; +import { + isStackError, + type StackError, + type StackInspection, + type StackStatus, +} from "@supabase/stack/effect"; +import { Output } from "../../../../shared/output/output.service.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"; +import { encodeStackEnv, stackEnvOverrides, stackEnvValues } from "./status.env.ts"; + +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 stack start without --stack-id to create one.", + })), + Match.tag( + "InvalidStackIdentityError", + "InvalidProjectRootError", + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackUpgradeRequiredError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + () => ({ reason: "invalid-config" as const }), + ), + Match.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.", + })), + ); + +const mapStackError = (error: StackError) => { + const classification = classifyStackError(error); + return new StackCommandStatusError({ + ...classification, + message: error.message, + cause: error, + }); +}; + +const catchStackError = (effect: Effect.Effect) => + effect.pipe(Effect.catchIf(isStackError, (error) => Effect.fail(mapStackError(error)))); + +const readiness = (status: StackStatus | undefined): string => { + if (status === undefined) return "unknown"; + if (status.lifecycle !== "running") return status.lifecycle; + if (status.capabilities.some(({ state }) => state === "failed")) return "degraded"; + if (status.capabilities.some(({ state }) => state === "starting")) return "starting"; + if (status.capabilities.some(({ state }) => state === "stopped")) return "stopped"; + if (status.capabilities.some(({ state }) => state === "dormant")) return "dormant"; + return "ready"; +}; + +const configUnavailableWarning = + "Project configuration could not be loaded; fix it before checking drift."; + +const configComparisonWarning = (detail: string) => + `Project configuration could not be compared: ${detail}`; + +const payload = (inspection: StackInspection, configWarning?: string) => ({ + identity: { + id: inspection.descriptor.id, + name: inspection.descriptor.name, + project_root: inspection.descriptor.projectRoot, + branch_context: inspection.descriptor.branchContext, + }, + runtime: inspection.descriptor.runtime, + owner: inspection.owner, + lifecycle: inspection.status?.lifecycle ?? null, + desired_lifecycle: inspection.status?.desiredLifecycle ?? inspection.descriptor.desiredLifecycle, + readiness: readiness(inspection.status), + ...(inspection.status === undefined ? {} : { endpoints: inspection.status.endpoints }), + ...(inspection.status === undefined ? {} : { capabilities: inspection.status.capabilities }), + config_drift: + inspection.configDrift ?? + ({ + status: "unavailable", + message: configWarning ?? "Configuration was not compared.", + } as const), +}); + +const comparedInspection = ( + inspection: StackInspection, +): { + readonly inspection: StackInspection; + readonly warning?: string; +} => ({ inspection }); + +const render = (inspection: StackInspection, configWarning?: string): string => { + const descriptor = inspection.descriptor; + const lines = [ + `Stack ${descriptor.name} (${descriptor.id})`, + `Project: ${descriptor.projectRoot}`, + `Branch: ${descriptor.branchContext}`, + `Runtime: ${descriptor.runtime.kind}`, + `Owner: ${inspection.owner}`, + `Lifecycle: ${inspection.status?.lifecycle ?? "unavailable"}`, + `Desired lifecycle: ${inspection.status?.desiredLifecycle ?? descriptor.desiredLifecycle}`, + `Readiness: ${readiness(inspection.status)}`, + ]; + if (inspection.status !== undefined) { + const endpoints = Object.entries(inspection.status.endpoints); + if (endpoints.length > 0) { + lines.push("Endpoints:"); + for (const [name, endpoint] of endpoints) + if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); + } + } + const drift = inspection.configDrift; + lines.push(`Config drift: ${drift?.status ?? "unavailable"}`); + if (drift !== undefined) for (const path of drift.paths) lines.push(` ${path}`); + if (configWarning !== undefined) lines.push(`Config warning: ${configWarning}`); + return `${lines.join("\n")}\n`; +}; + +const findDescriptor = (projectRoot: string, name: string | undefined, id: string | undefined) => + Effect.gen(function* () { + const api = yield* StackApi; + if (id !== undefined) { + const validId = yield* validateStackId(id).pipe(Effect.mapError(mapTargetError)); + const inspection = yield* catchStackError(api.inspectStack(validId)); + return { + descriptor: inspection.descriptor, + id: validId, + projectRoot: inspection.descriptor.projectRoot, + inspection, + }; + } + const found = yield* catchStackError( + api.findStack({ projectRoot, ...(name === undefined ? {} : { name }) }), + ); + if (Option.isNone(found)) + return yield* new StackCommandStatusError({ + reason: "not-found", + message: "No managed stack exists for the selected project.", + suggestion: "Run supabase stack start first.", + }); + return { descriptor: found.value, id: found.value.id, projectRoot: found.value.projectRoot }; + }); + +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* CommandSettings; + const outputFlag = yield* Effect.serviceOption(OutputFlag); + 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: "lifecycle", + 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.result(values); + return; + } + const loaded = yield* loadStackConfig(target.projectRoot).pipe( + Effect.map((config) => ({ config, warning: undefined })), + Effect.catchTag("StackConfigError", () => + Effect.succeed({ config: undefined, warning: configUnavailableWarning }), + ), + ); + const comparison = + loaded.config === undefined + ? target.inspection === undefined + ? yield* catchStackError(api.inspectStack(target.id)).pipe(Effect.map(comparedInspection)) + : { inspection: target.inspection } + : yield* api.inspectStack(target.id, { config: loaded.config }).pipe( + Effect.map(comparedInspection), + Effect.catchTags({ + InvalidStackConfigError: (error) => + Effect.succeed({ + inspection: undefined, + warning: configComparisonWarning(error.message), + }), + StackVersionUnsupportedError: (error) => + Effect.succeed({ + inspection: undefined, + warning: configComparisonWarning(error.message), + }), + }), + catchStackError, + ); + const inspection = + comparison.inspection === undefined + ? (target.inspection ?? (yield* catchStackError(api.inspectStack(target.id)))) + : comparison.inspection; + const inspectionWarning = loaded.warning ?? comparison.warning; + if (output.format === "text") yield* output.raw(render(inspection, inspectionWarning)); + else yield* output.success("", payload(inspection, inspectionWarning)); + }); + return 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 new file mode 100644 index 0000000000..13d47b7394 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -0,0 +1,718 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- filesystem test fixture uses the host adapter at this boundary +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { parse as parseDotenv } from "dotenv"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Schema, 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"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { GLOBAL_OUTPUT_FORMATS, OutputFlag } from "../../../../command-internal/global-flags.ts"; +import { + actionability, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.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)); +const capabilityNames = [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; +const flags = (stack = Option.none(), stackId = Option.none()) => ({ + stack, + stackId, + env: false, + overrideName: [] as string[], +}); + +const makeStatus = ( + stackId: typeof id, + desiredLifecycle: StackStatus["desiredLifecycle"] = "running", +): StackStatus => ({ + id: stackId, + lifecycle: "running", + desiredLifecycle, + runtime: { kind: "native" }, + endpoints: { + api: { protocol: "http", address: "127.0.0.1", port: 54321, url: "http://127.0.0.1:54321" }, + }, + versions: {}, + capabilities: capabilityNames.map((name) => ({ + name, + activation: "lazy" as const, + state: "dormant" as const, + })), + artifacts: [], +}); + +const runStatus = (options: { + readonly config?: "valid" | "missing" | "invalid"; + readonly owner?: StackInspection["owner"]; + readonly status?: StackStatus; + readonly drift?: StackInspection["configDrift"]; + readonly flags?: ReturnType; + readonly compareFailure?: "typed" | "defect"; + readonly missingTarget?: boolean; + readonly legacyOutput?: (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"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + if (options.config !== "missing") + writeFileSync( + join(projectRoot, "supabase", "config.toml"), + options.config === "invalid" + ? 'project_id = "ok"\n\n[auth]\njwt_secret = "FAKE_STATUS_SECRET\n' + : 'project_id = "status-test"\n\n[auth]\njwt_secret = "candidate-secret"\n', + ); + const descriptor = { + id, + projectRoot, + name: "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const inspection: StackInspection = { + descriptor, + owner: options.owner ?? "running", + ...(options.status === undefined ? {} : { status: options.status }), + ...(options.drift === undefined ? {} : { configDrift: options.drift }), + }; + const out = mockOutput({ format: options.outputFormat ?? "text" }); + const telemetry = mockTelemetryStateTracked(); + const findInputs: unknown[] = []; + const inspectInputs: unknown[] = []; + const api = Layer.succeed(StackApi, { + createStack: () => Effect.die("create must not run"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + findStack: (input) => { + findInputs.push(input); + return Effect.succeed(options.missingTarget ? Option.none() : Option.some(descriptor)); + }, + 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) + return Effect.fail(new StackNotFoundError({ message: "stack id not found" })); + if (inspectOptions?.config !== undefined && options.compareFailure === "typed") + return Effect.fail(new InvalidStackConfigError({ message: "candidate config is invalid" })); + if (inspectOptions?.config !== undefined && options.compareFailure === "defect") + return Effect.die("comparison defect"); + return Effect.succeed(inspection); + }, + }); + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + api, + mockCommandSettings({ workdir: root }), + ...(options.legacyOutput === undefined + ? [] + : [Layer.succeed(OutputFlag, Option.some(options.legacyOutput))]), + BunServices.layer, + ); + 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("stack status", () => { + it.effect( + "reports configured identity, dormant readiness, endpoint, drift, and target config", + () => { + const run = runStatus({ + status: makeStatus(id), + drift: { status: "changed", paths: ["definition.listeners.api.port"] }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.findInputs).toEqual([{ projectRoot: expect.any(String) }]); + expect(run.inspectInputs).toHaveLength(1); + expect(run.inspectInputs[0]).toEqual({ config: expect.any(Object) }); + expect(run.out.stdoutText).toContain("Runtime: native"); + expect(run.out.stdoutText).toContain("Readiness: dormant"); + expect(run.out.stdoutText).toContain("http://127.0.0.1:54321"); + expect(run.out.stdoutText).toContain("definition.listeners.api.port"); + expect(run.out.stdoutText).not.toContain("candidate-secret"); + }), + ), + ); + }, + ); + + it.effect("forwards a named stack target with the settings project root", () => { + const run = runStatus({ flags: flags(Option.some("feature-a")), status: makeStatus(id) }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.findInputs).toEqual([{ projectRoot: run.root, name: "feature-a" }]); + }), + ), + ); + }); + + it.effect("uses the persisted project root for an explicit id from another cwd", () => { + const run = runStatus({ flags: flags(Option.none(), Option.some(id)), status: makeStatus(id) }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + expect(run.inspectInputs).toHaveLength(2); + expect(run.inspectInputs[1]).toEqual({ config: expect.any(Object) }); + }), + ), + ); + }); + + it.effect("reuses the explicit id inspection when config is invalid", () => { + const run = runStatus({ + config: "invalid", + 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("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( + Effect.tap(() => + Effect.sync(() => { + expect(run.out.stdoutText).toContain("Lifecycle: unavailable"); + expect(run.out.stdoutText).toContain("Desired lifecycle: running"); + expect(run.out.stdoutText).toContain("Readiness: unknown"); + }), + ), + ); + }); + + it.effect("does not claim ready when a running stack has stopped capabilities", () => { + const base = makeStatus(id); + const run = runStatus({ + status: { + ...base, + capabilities: base.capabilities.map((capability, index) => + index === 0 ? { ...capability, state: "stopped" as const } : capability, + ), + }, + }); + return run.effect.pipe( + Effect.tap(() => + Effect.sync(() => expect(run.out.stdoutText).toContain("Readiness: stopped")), + ), + ); + }); + + it.effect("emits the structured unavailable inspection for invalid config", () => { + const run = runStatus({ + config: "invalid", + 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: "Project configuration could not be loaded; fix it before checking drift.", + }, + }); + }), + ), + ); + }); + + 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 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([invalid.effect, invalidJson.effect]).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(invalid.out.stdoutText).toContain("Config drift: unavailable"); + expect(invalid.out.stdoutText).not.toContain("FAKE_STATUS_SECRET"); + const success = invalidJson.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + config_drift: { + status: "unavailable", + message: "Project configuration could not be loaded; fix it before checking drift.", + }, + }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output + expect(JSON.stringify(success?.data)).not.toContain("FAKE_STATUS_SECRET"); + }), + ), + ); + }); + + it.effect("points an empty current context to the start command", () => { + const run = runStatus({ missingTarget: true }); + return run.effect.pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.suggestion).toBe("Run supabase stack start first."); + expect(run.inspectInputs).toEqual([]); + }), + ), + ); + }); + + it.effect("gives actionable guidance when an explicit stack id is missing", () => { + const run = runStatus({ + flags: flags(Option.none(), Option.some(id)), + missingTarget: true, + }); + return run.effect.pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value.suggestion).toContain("existing --stack-id"); + expect(error.value[ErrorActionabilityId]).toEqual(actionability.provideFlags); + } + } + }), + ), + ); + }); + + it.effect("falls back only for typed comparison errors and preserves defects", () => { + const typed = runStatus({ compareFailure: "typed", status: makeStatus(id) }); + const 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); + }); + }); + + 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: "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); + expect(invalid.findInputs).toHaveLength(0); + expect(legacy.findInputs).toHaveLength(0); + }); + }); + + 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(); + const discovery = Layer.succeed(StackApi, { + createStack: () => Effect.die("create must not run"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + findStack: () => + Effect.fail(new StackStateFormatUnsupportedError({ message: "discovery failed" })), + openStack: () => Effect.die("open must not run"), + inspectStack: () => Effect.die("inspect must not run"), + }); + const effect = stackStatus(flags()).pipe( + Effect.provide( + Layer.mergeAll( + run.out.layer, + telemetry.layer, + discovery, + mockCommandSettings({ workdir: run.projectRoot }), + BunServices.layer, + ), + ), + Effect.ensuring(Effect.sync(() => rmSync(run.root, { recursive: true, force: true }))), + Effect.exit, + ); + return effect.pipe( + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) + expect(error.value[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + } + }), + ), + ); + }); + + it.live("parses stack name and stack id through the command", () => { + let parsed: { stack: Option.Option; stackId: Option.Option } | undefined; + const command = stackStatusCommand.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()))), + ); + }); + + 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); + }), + ), + ); + }); + + 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 = { + ...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"] }, + { env: true, overrideName: ["API_URL=A", "API_URL=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 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); + expect(failedCredentials.out.stdoutText).toBe(""); + }); + }); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 9f750f3cfb..c81f9ce3ff 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -234,6 +234,7 @@ ExperimentalFeatureFlagError ExperimentalRequiredError ExperimentalStackDestroyError ExperimentalStackStartError +ExperimentalStackStatusError ExperimentalStackStopError ExperimentalStackTargetError FeedbackBackendError 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", 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, }), ), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 4d863bb877..afaed2a6e1 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -9,6 +9,7 @@ export { export type { PromiseStack, PromiseStackConfig, + PromiseInspectStackOptions, PromiseStartStackOptions, PromisePrepareStackOptions, CreateStackOptions, 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/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 252c441936..ab26600657 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -11,6 +11,7 @@ import { Option, Path, Predicate, + Redacted, Result, Schedule, Schema, @@ -22,7 +23,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"; @@ -145,6 +152,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; @@ -1168,6 +1179,97 @@ export const discoverStacks = ( return { stacks, errors }; }); +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 listStacks = ( options: ListStacksOptions = {}, ): Effect.Effect< @@ -1184,9 +1286,10 @@ export const listStacks = ( 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* () { @@ -1195,14 +1298,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, id), owner: (yield* ownerLockExists(env.stateRoot, id)) ? "unreachable" : "absent", + ...(configDrift === undefined ? {} : { configDrift }), }; if (metadata.rpcRelease !== STACK_RPC_RELEASE) - return { descriptor: descriptor(state, id), owner: "incompatible" }; + return { + descriptor: descriptor(state, id), + owner: "incompatible", + ...(configDrift === undefined ? {} : { configDrift }), + }; const status = yield* Effect.scoped( Effect.gen(function* () { const client = makeControlClient(metadata.endpoint, { @@ -1216,8 +1326,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, id), owner: "unreachable" }; - return { descriptor: descriptor(state, id), owner: "running" }; + return { + descriptor: descriptor(state, id), + owner: "unreachable", + ...(configDrift === undefined ? {} : { configDrift }), + }; + return { + descriptor: descriptor(state, id), + owner: "running", + ...(configDrift === undefined ? {} : { configDrift }), + }; } - return { descriptor: descriptor(state, id), owner: "running", status: status.value }; + return { + descriptor: descriptor(state, id), + 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 ee00acdeb9..c7556dbcfb 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; }; @@ -63,7 +67,10 @@ interface PromiseStackApi { readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; readonly discoverStacks: (options?: ListStacksOptions) => Promise; - readonly inspectStack: (id: StackId) => Promise; + readonly inspectStack: ( + id: StackId, + options?: PromiseInspectStackOptions, + ) => Promise; } type PlatformLayer = typeof NodeServices.layer; @@ -175,7 +182,14 @@ export const makePromiseApi = ( run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), discoverStacks: (options) => run(discoverEffectStacks(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..2d7be0dac2 --- /dev/null +++ b/packages/stack/src/public/config-drift.integration.test.ts @@ -0,0 +1,212 @@ +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, + type StackRuntimeEnvironmentValue, +} 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 defaults = yield* defaultRuntimeEnvironment; + const runtime: StackRuntimeEnvironmentValue = { + ...defaults, + stateRoot: path.join(root, "managed", "stacks"), + tempRoot: "/tmp", + platform: "posix", + }; + 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", + ]), + ); + // 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"); + }), + ), + ); + + 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"); + // 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: { + ...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"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assertion checks redaction of serialized output + 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 392661da79..a40c1a89ef 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -19,6 +19,7 @@ export { } from "./EffectStack.ts"; export type { EffectStack, + InspectStackOptions, StartStackOptions, PrepareStackOptions, CreateStackOptions, diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 33141e1f82..3e954a7451 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -598,16 +598,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 () => { @@ -925,7 +930,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; }); @@ -1132,7 +1139,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 4038bae7a3..edc08c30d7 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -821,12 +821,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 @@ -842,22 +836,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", () =>