diff --git a/apps/cli/src/commands/experimental/stack/stack.command.ts b/apps/cli/src/commands/experimental/stack/stack.command.ts index 4923594bb3..75367c3fc2 100644 --- a/apps/cli/src/commands/experimental/stack/stack.command.ts +++ b/apps/cli/src/commands/experimental/stack/stack.command.ts @@ -4,12 +4,13 @@ import { commandSettingsLayer } from "../../../config/command-settings.layer.ts" import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { experimentalStackStartCommand } from "./start/start.command.ts"; +import { experimentalStackStopCommand } from "./stop/stop.command.ts"; import { experimentalStackApiLayer, experimentalStackTargetResolverLayer } from "./stack.shared.ts"; export const experimentalStackCommand = Command.make("stack").pipe( Command.withDescription("Manage an experimental managed local Supabase stack."), Command.withShortDescription("Manage a managed local stack"), - Command.withSubcommands([experimentalStackStartCommand]), + Command.withSubcommands([experimentalStackStartCommand, experimentalStackStopCommand]), Command.provide(experimentalStackTargetResolverLayer), Command.provide(experimentalStackApiLayer), Command.provide(commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer))), diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index af214e91ad..73503d7d4b 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,6 +1,7 @@ -import { Context, Data, Effect, FileSystem, Layer, Path, Crypto } from "effect"; +import { Context, Data, Effect, FileSystem, Layer, Option, Path, Crypto } from "effect"; import { createStack, + findStack, inspectStack, isStackId, openStack, @@ -26,6 +27,7 @@ interface ExperimentalStackTarget { export class ExperimentalStackTargetError extends Data.TaggedError("ExperimentalStackTargetError")<{ readonly message: string; readonly reason: "flags" | "invalid-config"; + readonly suggestion?: string; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -55,6 +57,12 @@ export class ExperimentalStackTargetResolver extends Context.Service< export class ExperimentalStackApi extends Context.Service< ExperimentalStackApi, { + readonly findStack: ( + ...args: Parameters + ) => Effect.Effect< + Effect.Success>, + Effect.Error> + >; readonly createStack: ( ...args: Parameters ) => Effect.Effect< @@ -76,6 +84,45 @@ export class ExperimentalStackApi extends Context.Service< } >()("supabase/experimental-stack/StackApi") {} +export const validateExperimentalStackTarget = (input: { + readonly stack?: string; + readonly stackId?: string; +}): Effect.Effect => + Effect.gen(function* () { + if (input.stack !== undefined && input.stackId !== undefined) { + return yield* new ExperimentalStackTargetError({ + message: "--stack and --stack-id cannot be used together", + reason: "flags", + }); + } + }); + +export const validateExperimentalStackId = ( + id: string, +): Effect.Effect => + isStackId(id) + ? Effect.succeed(id) + : Effect.fail( + new ExperimentalStackTargetError({ + message: "--stack-id must be a lowercase SHA-256 stack id", + reason: "flags", + }), + ); + +export const rejectExperimentalStackOutput = ( + outputFlag: Option.Option>, +): Effect.Effect => + Option.isSome(outputFlag) && Option.isSome(outputFlag.value) + ? Effect.fail( + new ExperimentalStackTargetError({ + message: "The legacy -o/--output flag is not supported here; use --output-format json.", + reason: "flags", + suggestion: + "Use --output-format json, --output-format text, or --output-format stream-json.", + }), + ) + : Effect.void; + export const experimentalStackApiLayer = Layer.effect( ExperimentalStackApi, Effect.gen(function* () { @@ -91,6 +138,7 @@ export const experimentalStackApiLayer = Layer.effect( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcess), ); return { + findStack: (...args: Parameters) => provideServices(findStack(...args)), createStack: (...args: Parameters) => provideServices(createStack(...args)), openStack: (...args: Parameters) => provideServices(openStack(...args)), @@ -104,13 +152,7 @@ export const experimentalStackApiLayer = Layer.effect( export const experimentalStackTargetResolverLayer = Layer.succeed(ExperimentalStackTargetResolver, { resolve: (input) => Effect.gen(function* () { - if (input.id !== undefined && !isStackId(input.id)) { - return yield* new ExperimentalStackTargetError({ - message: "--stack-id must be a lowercase SHA-256 stack id", - reason: "flags", - }); - } - const id = input.id; + const id = input.id === undefined ? undefined : yield* validateExperimentalStackId(input.id); const stackApi = yield* ExperimentalStackApi; const inspection = id === undefined 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 1dcb551720..dee45a541e 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,6 +1,6 @@ -// This is a compiled CLI boundary test. It deliberately starts the native owner through the -// built binary, then uses the package's public Promise API only to inspect and destroy that exact -// stack after the CLI process has exited. +// This is a compiled CLI boundary test. It deliberately starts and stops the native stack through +// the built binary, then uses the package's public Promise API to inspect and destroy that exact +// 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 @@ -50,15 +50,14 @@ enabled = false enabled = false `; -// oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary -async function inspectAndDestroyStack(home: string, stackId: string) { +// oxlint-disable-next-line effecttsgo/async-function -- subprocess inspection is a foreign Promise boundary +async function inspectStackState(home: string, stackId: string) { const script = ` import { inspectStack, openStack, StackIdSchema } from "@supabase/stack"; const id = StackIdSchema.make(process.argv.at(-1)); const inspection = await inspectStack(id); const stack = await openStack(id); const status = await stack.status(); - await stack.destroy(); console.log(JSON.stringify({ owner: inspection.owner, projectRoot: inspection.descriptor.projectRoot, @@ -88,6 +87,25 @@ async function inspectAndDestroyStack(home: string, stackId: string) { }; } +// oxlint-disable-next-line effecttsgo/async-function -- subprocess cleanup is a foreign Promise boundary +async function destroyStack(home: string, stackId: string) { + const script = ` + import { openStack, StackIdSchema } from "@supabase/stack"; + const stack = await openStack(StackIdSchema.make(process.argv.at(-1))); + await stack.destroy(); + `; + await execFile("bun", ["--bun", "-e", script, stackId], { + cwd: process.cwd(), + env: { + ...process.env, + SUPABASE_HOME: home, + SUPABASE_NO_KEYRING: "1", + SUPABASE_TELEMETRY_DISABLED: "1", + }, + timeout: CLEANUP_TIMEOUT_MS, + }); +} + describe("experimental stack start (compiled e2e)", () => { let home: ReturnType | undefined; let projectDir: string | undefined; @@ -104,7 +122,7 @@ describe("experimental stack start (compiled e2e)", () => { const discovered = candidates.filter((entry) => /^[0-9a-f]{64}$/u.test(entry)); const ownedId = stackId ?? (discovered.length === 1 ? discovered[0] : undefined); if (ownedId !== undefined) { - await inspectAndDestroyStack(home.dir, ownedId); + await destroyStack(home.dir, ownedId); cleanupComplete = true; } else if (discovered.length > 1) { throw new Error(`Could not identify one owned stack for cleanup: ${discovered.join(", ")}`); @@ -122,7 +140,7 @@ describe("experimental stack start (compiled e2e)", () => { }, CLEANUP_TIMEOUT_MS); test.skipIf(!nativeSupported)( - "starts a detached native owner and leaves a ready database after CLI exit", + "starts and stops a native stack while preserving its database", { timeout: START_TIMEOUT_MS + CLEANUP_TIMEOUT_MS }, // oxlint-disable-next-line effecttsgo/async-function -- compiled CLI e2e callback is a Promise boundary async () => { @@ -149,13 +167,34 @@ describe("experimental stack start (compiled e2e)", () => { if (idText === undefined || homeDir === undefined || projectRoot === undefined) throw new Error("compiled start did not return a stack id"); - const observed = await inspectAndDestroyStack(homeDir.dir, idText); - stackDestroyed = true; - expect(observed.owner).toBe("running"); + const running = await inspectStackState(homeDir.dir, idText); + expect(running.owner).toBe("running"); + expect(running.projectRoot).toBe(await realpath(projectRoot)); + expect(running.runtime).toEqual({ kind: "native" }); + expect(running.lifecycle).toBe("running"); + expect(running.database).toBe("ready"); + const databasePath = path.join(homeDir.dir, "managed", "stacks", idText, "data", "database"); + await access(path.join(databasePath, "PG_VERSION")); + + await rm(path.join(projectRoot, "supabase", "config.toml")); + const stop = await runSupabase(["experimental", "stack", "stop", "--stack-id", idText], { + cwd: projectRoot, + home: homeDir.dir, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + + const observed = await inspectStackState(homeDir.dir, idText); + expect(observed.owner).toBe("absent"); expect(observed.projectRoot).toBe(await realpath(projectRoot)); expect(observed.runtime).toEqual({ kind: "native" }); - expect(observed.lifecycle).toBe("running"); - expect(observed.database).toBe("ready"); + expect(observed.lifecycle).toBe("stopped"); + expect(observed.database).toBe("stopped"); + + await access(path.join(databasePath, "PG_VERSION")); + + await destroyStack(homeDir.dir, idText); + stackDestroyed = true; await expect(access(path.join(homeDir.dir, "managed", "stacks", idText))).rejects.toThrow(); }, diff --git a/apps/cli/src/commands/experimental/stack/start/start.errors.ts b/apps/cli/src/commands/experimental/stack/start/start.errors.ts index f40c426c9d..615f8d0c69 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.errors.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.errors.ts @@ -5,14 +5,6 @@ import { ErrorActionabilityId, } from "../../../../shared/telemetry/error-actionability.ts"; -export class ExperimentalStackTargetFlagsError extends Data.TaggedError( - "ExperimentalStackTargetFlagsError", -)<{ readonly message: string }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - export class ExperimentalStackStartError extends Data.TaggedError("ExperimentalStackStartError")<{ readonly reason: | "invalid-config" diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 7ea4dde5d4..cd6590fee3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -8,10 +8,16 @@ 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 { ExperimentalStackApi, ExperimentalStackTargetResolver } from "../stack.shared.ts"; +import { + ExperimentalStackApi, + ExperimentalStackTargetError, + ExperimentalStackTargetResolver, + rejectExperimentalStackOutput, + validateExperimentalStackTarget, +} from "../stack.shared.ts"; import { loadStackConfig } from "../stack-config.ts"; import type { ExperimentalStackStartFlags } from "./start.command.ts"; -import { ExperimentalStackStartError, ExperimentalStackTargetFlagsError } from "./start.errors.ts"; +import { ExperimentalStackStartError } from "./start.errors.ts"; const statusPayload = (status: StackStatus) => ({ id: status.id, @@ -49,16 +55,13 @@ const eagerlyActivate = < value: T, ): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); -const validateExperimentalStackStartTarget = ( - flags: Pick, -) => - Option.isSome(flags.stack) && Option.isSome(flags.stackId) - ? Effect.fail( - new ExperimentalStackTargetFlagsError({ - message: "--stack and --stack-id cannot be used together", - }), - ) - : Effect.void; +const mapTargetError = (error: ExperimentalStackTargetError) => + new ExperimentalStackStartError({ + reason: error.reason, + message: error.message, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), + cause: error, + }); export const experimentalStackStart = Effect.fn("experimental.stack.start")(function* ( flags: ExperimentalStackStartFlags, @@ -70,20 +73,20 @@ export const experimentalStackStart = Effect.fn("experimental.stack.start")(func const resolver = yield* ExperimentalStackTargetResolver; const stackApi = yield* ExperimentalStackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); - if (Option.isSome(outputFlag) && Option.isSome(outputFlag.value)) - return yield* new ExperimentalStackStartError({ - 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* validateExperimentalStackStartTarget(flags); + yield* rejectExperimentalStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* validateExperimentalStackTarget({ + stack: Option.getOrUndefined(flags.stack), + stackId: Option.getOrUndefined(flags.stackId), + }).pipe(Effect.mapError(mapTargetError)); - const target = yield* resolver.resolve({ - projectRoot: settings.workdir, - ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), - ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), - runtime: flags.runtime, - }); + const target = yield* resolver + .resolve({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), + runtime: flags.runtime, + }) + .pipe(Effect.mapError(mapTargetError)); const config = yield* loadStackConfig(target.projectRoot).pipe( Effect.mapError( (error) => diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 47ad8ebe8f..a2eb959e8a 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -123,6 +123,7 @@ function handlerLayer(opts: { ), }); const apiLayer = Layer.succeed(ExperimentalStackApi, { + findStack: () => Effect.succeed(Option.none()), createStack: (options) => { opts.onCreate?.(options); return Effect.succeed(opts.stack); @@ -197,6 +198,7 @@ describe("experimental stack start targeting", () => { it.effect("classifies an existing stack runtime mismatch as provided flags", () => { const api = Layer.succeed(ExperimentalStackApi, { + findStack: () => Effect.succeed(Option.none()), createStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), inspectStack: () => @@ -503,6 +505,7 @@ describe("experimental stack start targeting", () => { }, }), Layer.succeed(ExperimentalStackApi, { + findStack: () => Effect.succeed(Option.none()), createStack: () => { created = true; return Effect.die("create should not run"); diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md new file mode 100644 index 0000000000..72e2893f85 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -0,0 +1,32 @@ +# `supabase experimental stack stop` + +This command stops the managed stack identified by the current project, an optional `--stack` +name, or `--stack-id`. It uses the public `@supabase/stack` API to stop the owner while +preserving the stack's persistent state and data volumes. It never destroys the stack. + +## Files read and written + +The stack package reads and updates its durable state under `` +and the selected stack's lifecycle state. The CLI reads its normal workdir settings. The +command does not load `supabase/config.toml`, so a missing or invalid project config does not +prevent stopping a stack addressed with `--stack-id`. Implicit and named stacks still depend on +workdir discovery, so removing an ancestor config can change which stack is selected; use an +explicit `--workdir` when needed. + +No project files, credentials, or runtime configuration files are written. The package owns +the supervisor teardown and state transition; the CLI does not remove containers, volumes, +or stack state itself. If persisted state says the stack is running but its owner is +unreachable, the package may launch a short-lived Supervisor to arbitrate teardown before +returning. That process is package-owned and is not managed directly by the CLI. + +## Output and telemetry + +Text mode reports the selected stack and stopped outcome. Structured modes include the selected +stack id and stopped outcome. If no current stack exists, the command succeeds with an explicit +no-stack result. Exit status is `0` for a successful stop or no current stack, `1` for a +missing named stack or any typed stop failure, and `130` if the command is interrupted before +the stop completes. Standard command instrumentation records command +metadata; stack data and credentials are not emitted as telemetry properties. + +Telemetry state is flushed to `/telemetry.json` +after both successful and failed command runs. diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts new file mode 100644 index 0000000000..a041463e59 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -0,0 +1,37 @@ +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 { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; +import { experimentalStackStop } from "./stop.handler.ts"; + +const config = { + stack: Flag.string("stack").pipe( + Flag.withDescription("Stop the stack with this name (defaults to the current project stack)."), + Flag.optional, + ), + stackId: Flag.string("stack-id").pipe( + Flag.withDescription("Stop an existing stack by id."), + Flag.optional, + ), +} as const; + +export type ExperimentalStackStopFlags = CliCommand.Command.Config.Infer; + +export const experimentalStackStopCommand = Command.make("stop", config).pipe( + Command.withDescription("Stop a managed local Supabase stack while preserving its data."), + Command.withShortDescription("Stop a managed local stack"), + Command.withExamples([ + { + command: "supabase experimental stack stop --stack feature-a", + description: "Stop the existing feature-a stack", + }, + ]), + Command.withHandler((flags) => + experimentalStackStop(flags).pipe( + withCommandTelemetry({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["experimental", "stack", "stop"])), +); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts new file mode 100644 index 0000000000..bf098f1290 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.errors.ts @@ -0,0 +1,26 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export class ExperimentalStackStopError extends Data.TaggedError("ExperimentalStackStopError")<{ + readonly reason: "flags" | "invalid-config" | "lifecycle" | "unknown"; + readonly message: string; + readonly suggestion?: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "flags": + return actionability.provideFlags; + case "invalid-config": + return actionability.invalidConfig; + case "lifecycle": + return actionability.invalidConfig; + case "unknown": + return actionability.unknown; + } + } +} diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts new file mode 100644 index 0000000000..e48a481196 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -0,0 +1,120 @@ +import { Effect, Match, Option } from "effect"; +import { + type StackDescriptor, + type OpenStackError, + type StackDiscoveryError, + type StackStopError, +} 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 { + ExperimentalStackApi, + ExperimentalStackTargetError, + rejectExperimentalStackOutput, + validateExperimentalStackId, + validateExperimentalStackTarget, +} from "../stack.shared.ts"; +import type { ExperimentalStackStopFlags } from "./stop.command.ts"; +import { ExperimentalStackStopError } from "./stop.errors.ts"; + +const mapTargetError = (error: ExperimentalStackTargetError) => + new ExperimentalStackStopError({ + reason: error.reason, + message: error.message, + ...(error.suggestion === undefined ? {} : { suggestion: error.suggestion }), + cause: error, + }); + +const stopError = (error: StackDiscoveryError | OpenStackError | StackStopError) => { + const classification = Match.value(error).pipe( + Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ + reason: "flags" as const, + })), + Match.tag("StackOwnershipConflictError", () => ({ + reason: "unknown" as const, + suggestion: + "Retry the stack stop; if it remains owned, rerun with --debug and inspect cleanup diagnostics.", + })), + Match.tag("StackLifecycleConflictError", "StackUpgradeRequiredError", () => ({ + reason: "lifecycle" as const, + })), + Match.tag( + "StackStateFormatUnsupportedError", + "InvalidProjectRootError", + "StackStateInvalidError", + () => ({ reason: "invalid-config" as const }), + ), + Match.tag("StackRuntimeMismatchError", () => ({ reason: "unknown" as const })), + Match.tag("StackCleanupError", () => ({ + reason: "unknown" as const, + suggestion: "Retry the stack stop with --debug and inspect cleanup diagnostics.", + })), + Match.exhaustive, + ); + return new ExperimentalStackStopError({ + ...classification, + message: error.message, + cause: error, + }); +}; + +const stoppedPayload = (id: StackDescriptor["id"]) => ({ found: true, id, lifecycle: "stopped" }); + +export const experimentalStackStop = Effect.fn("experimental.stack.stop")(function* ( + flags: ExperimentalStackStopFlags, +) { + const telemetryState = yield* TelemetryState; + const body = Effect.gen(function* () { + const output = yield* Output; + const settings = yield* CommandSettings; + const stackApi = yield* ExperimentalStackApi; + const outputFlag = yield* Effect.serviceOption(OutputFlag); + yield* rejectExperimentalStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError)); + yield* validateExperimentalStackTarget({ + stack: Option.getOrUndefined(flags.stack), + stackId: Option.getOrUndefined(flags.stackId), + }).pipe(Effect.mapError(mapTargetError)); + + const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; + const targetOption = + id === undefined + ? yield* stackApi + .findStack({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + }) + .pipe(Effect.mapError(stopError)) + : yield* validateExperimentalStackId(id).pipe( + Effect.mapError(mapTargetError), + Effect.map((validId) => + Option.some({ + id: validId, + projectRoot: settings.workdir, + }), + ), + ); + if (Option.isNone(targetOption)) { + if (Option.isSome(flags.stack)) + return yield* new ExperimentalStackStopError({ + reason: "flags", + message: `No managed stack named "${flags.stack.value}" was found for this project.`, + suggestion: "Choose an existing --stack name or omit --stack for the current project.", + }); + yield* output.success("No managed stack found for this context.", { found: false }); + return; + } + const target = targetOption.value; + const stack = yield* stackApi.openStack(target.id).pipe(Effect.mapError(stopError)); + const stopping = yield* output.task(`Stopping stack ${target.id}...`); + yield* stack.stop().pipe( + Effect.tapError((error) => stopping.fail(error.message)), + Effect.tap(() => stopping.clear()), + Effect.mapError(stopError), + ); + if (output.format === "text") yield* output.raw(`Stack ${target.id} stopped.\n`); + else yield* output.success("", stoppedPayload(target.id)); + }); + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts new file mode 100644 index 0000000000..311405408e --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -0,0 +1,395 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { + InvalidStackIdentityError, + StackCleanupError, + StackIdSchema, + StackNotFoundError, + StackOwnershipConflictError, + StackStateFormatUnsupportedError, + StackStateInvalidError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import type { + EffectStack, + OpenStackError, + StackDiscoveryError, + StackStatus, + StackStopError, +} from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + 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 { ExperimentalStackApi } from "../stack.shared.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { experimentalStackStop } from "./stop.handler.ts"; +import { ExperimentalStackStopError } from "./stop.errors.ts"; +import { experimentalStackStopCommand } from "./stop.command.ts"; + +const status = (id: string): StackStatus => ({ + id: StackIdSchema.make(id), + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], +}); + +const flags = (overrides: Partial[0]> = {}) => ({ + stack: Option.none(), + stackId: Option.none(), + ...overrides, +}); + +function setup(opts: { + root: string; + found?: { id: string; name?: string }; + stop?: () => Effect.Effect; + openFailure?: OpenStackError; + findFailure?: StackDiscoveryError; +}) { + const out = mockOutput(); + const telemetry = mockTelemetryStateTracked(); + const state = { + findInputs: [] as Array<{ projectRoot: string; name?: string }>, + openedIds: [] as string[], + stopCalls: 0, + destroyCalled: false, + }; + const id = opts.found?.id ?? "a".repeat(64); + const stack = { + id: StackIdSchema.make(id), + status: () => Effect.succeed(status(id)), + credentials: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + start: () => Effect.die("unused"), + stop: + opts.stop ?? + (() => + Effect.sync(() => { + state.stopCalls += 1; + })), + destroy: () => + Effect.sync(() => { + state.destroyCalled = true; + }), + logs: () => Effect.die("unused"), + followLogs: () => Stream.empty, + } satisfies EffectStack; + const descriptor = opts.found + ? { + id: stack.id, + projectRoot: opts.root, + name: opts.found.name ?? "feature-a", + branchContext: "ordinary-workspace", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + } + : undefined; + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + mockCommandSettings({ workdir: opts.root }), + Layer.succeed(ExperimentalStackApi, { + createStack: () => Effect.die("must not create"), + findStack: (input) => + Effect.sync(() => { + state.findInputs.push(input); + return descriptor === undefined ? Option.none() : Option.some(descriptor); + }).pipe( + Effect.flatMap((value) => + opts.findFailure === undefined ? Effect.succeed(value) : Effect.fail(opts.findFailure), + ), + ), + openStack: (stackId) => { + if (opts.openFailure !== undefined) return Effect.fail(opts.openFailure); + return Effect.sync(() => { + state.openedIds.push(stackId); + return stack; + }); + }, + inspectStack: () => Effect.die("must not inspect"), + }), + BunServices.layer, + ); + return { layer, out, state, telemetry }; +} + +describe("experimental stack stop", () => { + it.effect("stops a named stack without calling destroy", () => { + const root = "/tmp/supabase-stack-stop"; + const setupResult = setup({ + root, + found: { id: "a".repeat(64), name: "feature-a" }, + }); + return Effect.gen(function* () { + yield* experimentalStackStop(flags({ stack: Option.some("feature-a") })); + expect(setupResult.state.findInputs).toEqual([{ projectRoot: root, name: "feature-a" }]); + expect(setupResult.state.openedIds).toEqual(["a".repeat(64)]); + expect(setupResult.state.stopCalls).toBe(1); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.stdoutText).toContain("stopped"); + expect(setupResult.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("opens an explicit id without discovering a stack", () => { + const root = "/tmp/supabase-stack-stop-id"; + const id = "c".repeat(64); + const setupResult = setup({ root, found: { id } }); + return Effect.gen(function* () { + yield* experimentalStackStop(flags({ stackId: Option.some(id) })); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([id]); + expect(setupResult.state.stopCalls).toBe(1); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("invokes stop twice without calling destroy", () => { + const root = "/tmp/supabase-stack-stop-repeat"; + const setupResult = setup({ root, found: { id: "d".repeat(64) } }); + return Effect.gen(function* () { + yield* experimentalStackStop(flags()); + yield* experimentalStackStop(flags()); + expect(setupResult.state.stopCalls).toBe(2); + expect(setupResult.state.destroyCalled).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies an addressed missing stack as actionable flags", () => { + const root = "/tmp/supabase-stack-stop-open-missing"; + const setupResult = setup({ + root, + found: { id: "e".repeat(64) }, + openFailure: new StackNotFoundError({ message: "Stack state was not found" }), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop( + flags({ stackId: Option.some("e".repeat(64)) }), + ).pipe(Effect.flip); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("emits a self-describing JSON stopped result", () => { + const root = "/tmp/supabase-stack-stop-json"; + const setupResult = setup({ root, found: { id: "f".repeat(64) } }); + const output = mockOutput({ format: "json" }); + return Effect.gen(function* () { + yield* experimentalStackStop(flags()); + expect(output.messages.find((message) => message.type === "success")?.data).toEqual({ + found: true, + id: "f".repeat(64), + lifecycle: "stopped", + }); + }).pipe(Effect.provide(Layer.mergeAll(setupResult.layer, output.layer))); + }); + + it.effect("reports a missing named stack without opening or stopping anything", () => { + const root = "/tmp/supabase-stack-stop-named-missing"; + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags({ stack: Option.some("missing") })).pipe( + Effect.flip, + ); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.findInputs).toEqual([{ projectRoot: root, name: "missing" }]); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies invalid stack names as actionable flags", () => { + const root = "/tmp/supabase-stack-stop-invalid-name"; + const setupResult = setup({ + root, + findFailure: new InvalidStackIdentityError({ message: "The stack name must not be blank" }), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags({ stack: Option.some("") })).pipe( + Effect.flip, + ); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects malformed ids before opening or stopping anything", () => { + const root = "/tmp/supabase-stack-stop-malformed"; + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags({ stackId: Option.some("invalid") })).pipe( + Effect.flip, + ); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("reports when no current stack exists", () => { + const root = "/tmp/supabase-stack-stop-missing"; + const setupResult = setup({ root }); + return Effect.gen(function* () { + yield* experimentalStackStop(flags()); + expect( + setupResult.out.messages.some((message) => + message.message.includes("No managed stack found"), + ), + ).toBe(true); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects mutually exclusive targets before discovering a stack", () => { + const setupResult = setup({ root: "/tmp/supabase-stack-stop-mutex" }); + return Effect.gen(function* () { + const targetFailure = yield* experimentalStackStop( + flags({ stack: Option.some("feature-a"), stackId: Option.some("a".repeat(64)) }), + ).pipe(Effect.flip); + expect(targetFailure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + expect(targetFailure.message).toContain("cannot be used together"); + expect(setupResult.state.findInputs).toEqual([]); + expect(setupResult.state.openedIds).toEqual([]); + expect(setupResult.state.stopCalls).toBe(0); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("does not report success when package stop fails", () => { + const root = "/tmp/supabase-stack-stop-failure"; + const setupResult = setup({ + root, + found: { id: "b".repeat(64) }, + stop: () => Effect.fail(new StackStateInvalidError({ message: "stop failed" })), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(ExperimentalStackStopError); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + expect(setupResult.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies an ownership conflict as unknown with retry guidance", () => { + const root = "/tmp/supabase-stack-stop-conflict"; + const ownershipConflict = new StackOwnershipConflictError({ message: "stack is owned" }); + const setupResult = setup({ + root, + found: { id: "7".repeat(64) }, + stop: () => Effect.fail(ownershipConflict), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("unknown"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.unknown); + expect(failure.suggestion).toBe( + "Retry the stack stop; if it remains owned, rerun with --debug and inspect cleanup diagnostics.", + ); + expect(failure.message).toBe(ownershipConflict.message); + expect(failure.cause).toBe(ownershipConflict); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies persisted state format failures as invalid config", () => { + const root = "/tmp/supabase-stack-stop-format"; + const setupResult = setup({ + root, + found: { id: "8".repeat(64) }, + openFailure: new StackStateFormatUnsupportedError({ + format: "future", + message: "Unsupported stack state format", + }), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("invalid-config"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies stack upgrade requirements as lifecycle failures", () => { + const root = "/tmp/supabase-stack-stop-upgrade"; + const setupResult = setup({ + root, + found: { id: "9".repeat(64) }, + openFailure: new StackUpgradeRequiredError({ + expectedRelease: "next", + actualRelease: "current", + message: "Stack upgrade required", + }), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("lifecycle"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + expect(setupResult.state.stopCalls).toBe(0); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("classifies cleanup failures as unknown actionability with debug guidance", () => { + const root = "/tmp/supabase-stack-stop-cleanup"; + const setupResult = setup({ + root, + found: { id: "a".repeat(64) }, + stop: () => Effect.fail(new StackCleanupError({ message: "cleanup failed" })), + }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure.reason).toBe("unknown"); + expect(failure[ErrorActionabilityId]).toEqual(actionability.unknown); + expect(failure.suggestion).toContain("--debug"); + expect(setupResult.state.openedIds).toEqual(["a".repeat(64)]); + expect(setupResult.state.destroyCalled).toBe(false); + expect(setupResult.out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(setupResult.layer)); + }); + + it.effect("rejects the output flag with actionable guidance", () => { + const root = "/tmp/supabase-stack-stop-output"; + const setupResult = setup({ root }); + return Effect.gen(function* () { + const failure = yield* experimentalStackStop(flags()).pipe(Effect.flip); + expect(failure).toBeInstanceOf(ExperimentalStackStopError); + expect(failure[ErrorActionabilityId]).toEqual(actionability.provideFlags); + }).pipe( + Effect.provide( + Layer.mergeAll(setupResult.layer, Layer.succeed(OutputFlag, Option.some("json"))), + ), + ); + }); +}); + +describe("experimental stack stop parser", () => { + it.live("passes a stack name to the handler", () => { + let parsed: Option.Option | undefined; + const command = experimentalStackStopCommand.pipe( + Command.withHandler((flags) => Effect.sync(() => (parsed = flags.stack))), + ); + return Effect.gen(function* () { + yield* Command.runWith(command, { version: "0.0.0-test" })(["--stack", "feature-a"]); + expect(parsed).toEqual(Option.some("feature-a")); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + }); +}); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index a0c6169921..badaceb5f2 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -1,4 +1,5 @@ import { Effect, Layer } from "effect"; +import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command, type HelpDoc } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; @@ -12,8 +13,6 @@ import { projectsCommand } from "../../commands/projects/projects.command.ts"; import { projectsCreateCommand } from "../../commands/projects/create/create.command.ts"; import { startCommand } from "../../commands/start/start.command.ts"; import { stopCommand } from "../../commands/stop/stop.command.ts"; -import { VALID_TOKEN } from "../../../tests/helpers/command-mocks.ts"; -import { mockOutput, withEnv } from "../../../tests/helpers/mocks.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; import { GoProxy } from "../../command-internal/go-proxy.service.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; @@ -52,6 +51,19 @@ const testRoot = Command.make("supabase").pipe( Command.withGlobalFlags(GLOBAL_FLAGS), ); +function parserCommand( + command: Command.Command, + parsed: Array, +) { + return command.pipe( + Command.withHandler((flags) => + Effect.sync(() => { + parsed.push(flags); + }), + ), + ); +} + const silentCliOutputFormatter: CliOutput.Formatter = { formatCliError: () => "", formatError: () => "", @@ -60,11 +72,6 @@ const silentCliOutputFormatter: CliOutput.Formatter = { formatVersion: () => "", }; -const authenticatedEnv = { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - ...(process.env["SystemRoot"] === undefined ? {} : { SystemRoot: process.env["SystemRoot"] }), -}; - describe("native hidden flags", () => { it("omits hidden flags from help docs for every command that still carries one", () => { expect(buildHelpDoc(startCommand).flags.map((flag) => flag.name)).toEqual([ @@ -116,49 +123,46 @@ describe("native hidden flags", () => { ]); }); - it("still parses and forwards every hidden flag by exact name", async () => { - const proxy = mockGoProxy(); + it("passes hidden flag values to handlers by exact name", async () => { + const parsed: Array = []; + const parserFunctionsCommand = Command.make("functions").pipe( + Command.withSubcommands([ + parserCommand(functionsDownloadCommand, parsed), + parserCommand(functionsDeployCommand, parsed), + parserCommand(functionsServeCommand, parsed), + ]), + ); + const parserRoot = Command.make("supabase").pipe( + Command.withSubcommands([ + parserCommand(startCommand, parsed), + parserCommand(stopCommand, parsed), + parserFunctionsCommand, + ]), + ); + const parserLayer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(silentCliOutputFormatter), + ); + const runParser = (args: ReadonlyArray) => + Command.runWith(parserRoot, { version: "0.0.0-test" })(args).pipe( + Effect.provide(parserLayer), + ); await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - // `start` and `stop` are both natively ported (no longer `GoProxy` forwards), - // so they can fail for workdir/Docker-related reasons in this proxy-only test layer — - // the point here is only to prove the hidden `--preview`/`--backup` flags still parse - // by exact name, not that the commands succeed, matching the `functions deploy`/`serve` - // assertions below. - const startExit = yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ - "start", - "--preview", - ]).pipe(Effect.exit); - expect(JSON.stringify(startExit)).not.toContain("UnrecognizedFlag"); - const stopExit = yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ - "stop", - "--backup=false", - ]).pipe(Effect.exit); - expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); - // `functions download --use-docker` now runs the native Docker-unbundle - // path (CLI-1963) instead of forwarding to `GoProxy` — the - // deliberately-invalid slug makes it fail at `validateSlug` - // (`download.ts`, checked BEFORE `isDockerRunning`/any image pull), - // so the invocation stays fast and side-effect-free even on a CI - // runner with a live Docker daemon (a valid slug here triggered a - // real multi-second `docker pull` and timed this test out), while - // still proving the hidden flag parses by exact name. - // `--legacy-bundle` is the one remaining case that still forwards to the - // proxy, asserted below. - const downloadUseDockerExit = yield* Command.runWith(testRoot, { - version: "0.0.0-test", - })([ + // Recorder handlers cover parser-to-handler values without running command runtimes. + yield* runParser(["start", "--preview"]); + yield* runParser(["stop", "--backup=false"]); + yield* runParser([ "functions", "download", - "Not_A_Valid-Slug!", + "hello", "--project-ref", "abcdefghijklmnopqrst", - "--use-docker", - ]).pipe(Effect.exit); - expect(JSON.stringify(downloadUseDockerExit)).not.toContain("UnrecognizedFlag"); - yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ + "--use-docker=false", + ]); + yield* runParser([ "functions", "download", "hello", @@ -166,45 +170,22 @@ describe("native hidden flags", () => { "abcdefghijklmnopqrst", "--legacy-bundle", ]); - const useDockerExit = yield* Command.runWith(testRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--use-docker"]).pipe(Effect.exit); - const legacyBundleExit = yield* Command.runWith(testRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--legacy-bundle"]).pipe(Effect.exit); - expect(JSON.stringify(useDockerExit)).not.toContain("UnrecognizedFlag"); - expect(JSON.stringify(legacyBundleExit)).not.toContain("UnrecognizedFlag"); - const serveExit = yield* Command.runWith(testRoot, { - version: "0.0.0-test", - })(["functions", "serve", "--all=false"]).pipe(Effect.exit); - expect(JSON.stringify(serveExit)).not.toContain("UnrecognizedFlag"); + yield* runParser(["functions", "deploy", "hello", "--use-docker=false"]); + yield* runParser(["functions", "deploy", "hello", "--legacy-bundle"]); + yield* runParser(["functions", "serve", "--all=false"]); }), - ).pipe( - Effect.provide( - Layer.mergeAll( - withEnv(authenticatedEnv), - proxy.layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, + ), ); - - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello", - "--project-ref", - "abcdefghijklmnopqrst", - "--legacy-bundle", - ], + expect(parsed).toEqual([ + expect.objectContaining({ preview: true }), + expect.objectContaining({ backup: false }), + expect.objectContaining({ useDocker: false }), + expect.objectContaining({ legacyBundle: true }), + expect.objectContaining({ useDocker: false }), + expect.objectContaining({ legacyBundle: true }), + expect.objectContaining({ all: false }), ]); - // Guard, not a correctness assertion: this test drives 8 full command - // invocations through the real CLI tree, which can exceed the 5s default - // under CI file-level parallelism on a loaded runner. - }, 30_000); + }); it("does not leak hidden flag names through unknown-flag suggestions", async () => { const proxy = mockGoProxy();