From 5e72c3c624eb11810d74442f5144a832dd45e9fb Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 2 Oct 2026 22:08:10 +0200 Subject: [PATCH 1/2] fix(cli): accept the short stack id from stack list in --stack-id `stack list` shows an 8-character ID prefix, but every --stack-id selector required the full 64-character id. The shared stack target resolver now accepts any unique lowercase-hex prefix of at least four characters, reports ambiguous prefixes with the matching full ids, and surfaces unreadable matching state instead of reporting it missing. Co-Authored-By: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 5 +- .../stack/destroy/SIDE_EFFECTS.md | 2 +- .../stack/destroy/destroy.command.ts | 2 +- .../stack/destroy/destroy.integration.test.ts | 13 +++ .../experimental/stack/list/SIDE_EFFECTS.md | 4 +- .../experimental/stack/logs/SIDE_EFFECTS.md | 2 +- .../experimental/stack/logs/logs.command.ts | 2 +- .../stack/prepare/prepare.command.ts | 2 +- .../stack/restart/SIDE_EFFECTS.md | 2 +- .../stack/restart/restart.command.ts | 2 +- .../stack/stack.shared.integration.test.ts | 62 ++++++++++++++ .../experimental/stack/stack.shared.ts | 84 +++++++++++++------ .../experimental/stack/start/SIDE_EFFECTS.md | 2 +- .../experimental/stack/start/start.command.ts | 2 +- .../experimental/stack/start/start.handler.ts | 2 +- .../experimental/stack/status/SIDE_EFFECTS.md | 2 +- .../stack/status/status.command.ts | 2 +- .../experimental/stack/stop/SIDE_EFFECTS.md | 2 +- .../experimental/stack/stop/stop.command.ts | 2 +- 19 files changed, 154 insertions(+), 42 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index dd8cbdd896..beb06a8886 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -124,8 +124,9 @@ The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pre project, branch, runtime, and owner availability. Registry entries that cannot be read or decoded are skipped with a warning on stderr identifying each stack; only a failure to read the stacks directory itself fails discovery. -The text table shortens readable IDs for scanning; use `--output-format json` or -`--output-format stream-json` for the complete structured inventory with full IDs. +The text table shortens readable IDs for scanning; every `--stack-id` accepts that short ID, or any +unique prefix of at least 4 characters. Use `--output-format json` or `--output-format stream-json` +for the complete structured inventory with full IDs. Listing is global and has no checkout filter. Owner availability is not service lifecycle or health; use `supabase stack status` for live state. Registry directories without a state file are ignored diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 8f7f722738..fbd23fd571 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -6,7 +6,7 @@ files remain. The experimental feature flag controls command registration. ## Selection and confirmation -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive; a missing target fails. Explicit legacy `-o/--output` is rejected in favor of `--output-format`. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 3a05bced3d..d32d57f57e 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -13,7 +13,7 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Destroy an existing stack by id."), + Flag.withDescription("Destroy an existing stack by id or unique id prefix."), Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 402938083a..d53524e1a0 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -119,6 +119,19 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); + it.live("destroys a stack addressed by the short ID that stack list shows", () => + Effect.gen(function* () { + const f = yield* fixture(true); + + yield* stackDestroy({ ...f.flags, stackId: Option.some(f.stack.id.slice(0, 8)) }).pipe( + Effect.provide(f.layer), + ); + + expect(yield* f.api.discover(f.locations)).toEqual([]); + expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); + }).pipe(Effect.provide(live)), + ); + it.live( "destroys a live namespace and standalone services while preserving caller-owned uploads", () => diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md index d4ada831f3..a006346557 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -19,8 +19,8 @@ Entries contain `id`, `project_root`, `name`, `branch_context`, `runtime` (nativ docker, or podman), and `owner` (reachable or unavailable). Owner availability is not service lifecycle or health. Entries sort by project root, name, then ID. Text shows NAME, PROJECT, BRANCH, RUNTIME, OWNER, and a compact ID. An empty -registry prints `No managed stacks found.` Use `--output-format json` to obtain -the full ID required by `--stack-id`; the text column shows only a prefix. +registry prints `No managed stacks found.` The text column shows an ID prefix that +`--stack-id` accepts while it stays unique; `--output-format json` shows full IDs. JSON emits `{ "stacks": [...], "message": "" }`; stream-json wraps that data in one result event. State entries that cannot be read or decoded are skipped with a warning on diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 2a9a9e92d3..36fd889193 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -6,7 +6,7 @@ it never launches an owner or starts/stops a service. ## Selection and files -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive. By default, only composition members are included. `--service ` can also select standalone instances. An unavailable owner, missing stack, or unmatched service fails with status 1. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index f895e7151d..1a4c8bc34d 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -12,7 +12,7 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Read logs from an existing stack by id."), + Flag.withDescription("Read logs from an existing stack by id or unique id prefix."), Flag.optional, ), service: Flag.string("service").pipe( diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 6ea91d399d..d1a052ffe9 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -8,7 +8,7 @@ import { STACK_PREPARABLE_CAPABILITIES } from "../start/start.options.ts"; const config = { stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Open an existing stack by id."), + Flag.withDescription("Open an existing stack by id or unique id prefix."), Flag.optional, ), runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( diff --git a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md index 33a48bb19e..2710a054af 100644 --- a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ controls command registration. ## Selection and configuration -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive. Missing or unconfigured stacks fail with guidance to run `stack start`. Explicit legacy `-o/--output` is rejected in favor of `--output-format`. diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts index c0ac0707bd..206d37fe57 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -10,7 +10,7 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Restart an existing stack by id."), + Flag.withDescription("Restart an existing stack by id or unique id prefix."), Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index f4f6eef8c4..829dc1e229 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -82,6 +82,49 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("rejects an id prefix that more than one saved stack shares", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, home, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root }); + const twin = `${id.slice(0, 8)}${id.slice(8).replace(/./gu, (c) => (c === "0" ? "1" : "0"))}`; + const saved = yield* fs.readFileString(path.join(home, "stacks", id, "state.json")); + yield* fs.makeDirectory(path.join(home, "stacks", twin)); + yield* fs.writeFileString( + path.join(home, "stacks", twin, "state.json"), + saved.replaceAll(id, twin), + ); + + const failure = yield* resolve({ + projectRoot: root, + id: id.slice(0, 8), + runtime: "auto", + }).pipe(Effect.flip); + + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain(`Stack id prefix ${id.slice(0, 8)} matches 2 stacks`); + expect(failure.message).toContain(id); + expect(failure.message).toContain(twin); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("rejects a stack id shorter than four characters", () => + Effect.gen(function* () { + const { root, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root }); + + const failure = yield* resolve({ + projectRoot: root, + id: id.slice(0, 3), + runtime: "auto", + }).pipe(Effect.flip); + + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain("prefix of at least 4 characters"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("rejects an explicit id when its saved runtime differs", () => Effect.gen(function* () { const { root, register, resolve } = yield* workspace; @@ -180,6 +223,25 @@ describe("stack target resolver", () => { expect(failure.message).toContain(id); }).pipe(Effect.provide(BunServices.layer)), ); + + it.live("surfaces an unreadable saved stack that an id prefix selects", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, home, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root }); + yield* fs.writeFileString(path.join(home, "stacks", id, "state.json"), "{broken"); + + const failure = yield* resolve({ + projectRoot: root, + id: id.slice(0, 8), + runtime: "auto", + }).pipe(Effect.flip); + + expect(failure.reason).toBe("invalid-config"); + expect(failure.message).toContain(`Stack ${id} could not be read`); + }).pipe(Effect.provide(BunServices.layer)), + ); }); describe("stack API layer", () => { diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 49db2ca5f0..4c5af4b6d4 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -71,13 +71,18 @@ export const validateStackTarget = (input: { ) : Effect.void; +const isStackId = Schema.is(StackId); +const STACK_ID_PREFIX = /^[0-9a-f]{4,64}$/; + const validateStackId = (id: string): Effect.Effect => - Schema.is(StackId)(id) + STACK_ID_PREFIX.test(id) ? Effect.succeed(id) : Effect.fail( new StackTargetError({ - message: "--stack-id must be a lowercase SHA-256 stack id", + message: + "--stack-id must be a lowercase hexadecimal stack id or a prefix of at least 4 characters", reason: "flags", + suggestion: "Copy the ID column from `supabase stack list`.", }), ); @@ -95,6 +100,9 @@ export const rejectStackOutput = ( ) : Effect.void; +const stateError = (cause: StackError) => + new StackTargetError({ message: cause.message, reason: "invalid-config", cause }); + const runtimeForFlag = (runtime: "auto" | StackRuntime): StackTarget["runtime"] => runtime === "auto" ? undefined : runtime; @@ -103,7 +111,7 @@ const runtimeMatches = ( requested: StackTarget["runtime"], ): boolean => requested === undefined || saved === requested; -/** Resolves an existing stack by id or by the package identity of the project and stack name. */ +/** Resolves an existing stack by id, unique id prefix, or the identity of the project and stack name. */ export const stackTargetResolverLayer = Layer.effect( StackTargetResolver, Effect.gen(function* () { @@ -111,6 +119,40 @@ export const stackTargetResolverLayer = Layer.effect( const stackApi = yield* StackApi; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const findByIdPrefix = Effect.fn("StackTargetResolver.findByIdPrefix")(function* ( + stateRoot: string, + prefix: string, + ) { + const unreadable: Array<{ readonly id: string; readonly message: string }> = []; + const discovered = yield* stackApi + .discover({ + stateRoot, + onInvalidState: (id, error) => + Effect.sync(() => { + if (id.startsWith(prefix)) unreadable.push({ id, message: error.message }); + }), + }) + .pipe(Effect.mapError(stateError)); + const matches = discovered.filter(({ definition }) => definition.id.startsWith(prefix)); + const matchedIds = [ + ...matches.map(({ definition }) => definition.id), + ...unreadable.map(({ id }) => id), + ]; + yield* Effect.annotateCurrentSpan({ "stack.id_prefix_matches": matchedIds.length }); + if (matchedIds.length > 1) + return yield* new StackTargetError({ + message: `Stack id prefix ${prefix} matches ${matchedIds.length} stacks: ${matchedIds.join(", ")}`, + reason: "flags", + suggestion: "Pass more characters of one of the listed stack ids.", + }); + const [invalid] = unreadable; + if (invalid !== undefined) + return yield* new StackTargetError({ + message: `Stack ${invalid.id} could not be read: ${invalid.message}`, + reason: "invalid-config", + }); + return matches[0]; + }); const resolve = Effect.fn("StackTargetResolver.resolve")(function* (input: { readonly projectRoot: string; readonly name?: string; @@ -120,31 +162,25 @@ export const stackTargetResolverLayer = Layer.effect( const id = input.id === undefined ? undefined : yield* validateStackId(input.id); const requestedRuntime = runtimeForFlag(input.runtime); const stateRoot = path.join(settings.supabaseHome, "stacks"); - const found = yield* stackApi - .find( - id === undefined - ? { - stateRoot, - projectRoot: input.projectRoot, - ...(input.name === undefined ? {} : { name: input.name }), - } - : { stateRoot, id }, - ) - .pipe( - Effect.map(Option.getOrUndefined), - Effect.mapError( - (cause) => - new StackTargetError({ - message: cause.message, - reason: "invalid-config", - cause, - }), - ), - ); + const found = + id === undefined || isStackId(id) + ? yield* stackApi + .find( + id === undefined + ? { + stateRoot, + projectRoot: input.projectRoot, + ...(input.name === undefined ? {} : { name: input.name }), + } + : { stateRoot, id }, + ) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(stateError)) + : yield* findByIdPrefix(stateRoot, id); if (id !== undefined && found === undefined) return yield* new StackTargetError({ message: `Stack ${id} was not found`, reason: "flags", + suggestion: "Run `supabase stack list` to see managed stacks and their IDs.", }); if (found !== undefined && !runtimeMatches(found.definition.runtime, requestedRuntime)) return yield* new StackTargetError({ diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 4c6e60d465..47538ee87a 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -128,7 +128,7 @@ Text output reports progress and `Stack is ready.`, then prints the connection s `stack status` on stdout: API, REST, Functions, Studio, MCP, Mailpit, and database URLs for the members that expose them, the publishable and secret keys, the Storage S3 URL, access keys, and region when the S3 protocol is enabled, a services table, the runtime, and a -pointer to `supabase status --env` that repeats an explicit `--workdir` and any `--stack` or `--stack-id` selector, shell-quoted. Progress lines +pointer to `supabase status --env` that repeats an explicit `--workdir` and any `--stack` selector or the resolved full `--stack-id`, shell-quoted. Progress lines and warnings written while the spinner is shown appear on their own rows. JSON output returns the stack `id`, its saved `runtime`, `endpoints` keyed by service and endpoint diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index e5e7363d16..bcc8f3e806 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -19,7 +19,7 @@ const config = { exclude: excludeFlag, stack: Flag.string("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Open an existing stack by id."), + Flag.withDescription("Open an existing stack by id or unique id prefix."), Flag.optional, ), runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( 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 80ba093d48..83df18a4f5 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -497,7 +497,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags explicitWorkdir: settings.explicitWorkdir, projectRoot: target.projectRoot, ...(Option.isSome(flags.stack) ? { stack: flags.stack.value } : {}), - ...(Option.isSome(flags.stackId) ? { stackId: flags.stackId.value } : {}), + ...(Option.isSome(flags.stackId) ? { stackId: stack.id } : {}), }, currentShellPlatform(), ); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index dd2200f2c3..1c2b7673c6 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -9,7 +9,7 @@ a stack addressed with `--stack-id` when project configuration is missing or invalid. Target selection accepts the current project, `--stack `, or -`--stack-id `. `--stack` and `--stack-id` are mutually exclusive. Explicit +`--stack-id `. `--stack` and `--stack-id` are mutually exclusive. Explicit legacy `-o/--output` values are rejected; use `--output-format` instead, or `--env` in place of the `env` value. diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index 7ba2d86ad7..804ea0f622 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -8,7 +8,7 @@ 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.withDescription("Inspect an existing stack by id or unique id prefix."), Flag.optional, ), env: Flag.boolean("env").pipe( diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index a2bd679794..7aadf9738e 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -6,7 +6,7 @@ The experimental top-level `supabase stop` alias delegates to this handler. ## Selection and output -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. `--stack` and `--stack-id` are mutually exclusive. `--all` cannot be combined with either selector. Explicit legacy `-o/--output` is rejected; use `--output-format` instead. diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 39ec3ec781..76f82b7515 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -14,7 +14,7 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Stop an existing stack by id."), + Flag.withDescription("Stop an existing stack by id or unique id prefix."), Flag.optional, ), } as const; From f3f0a3fb67cc9d4748aa37b79b4283d1417fd186 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 2 Oct 2026 22:17:11 +0200 Subject: [PATCH 2/2] fix(cli): probe only prefix-matched stacks when resolving --stack-id `discover` takes an `idPrefix` so a prefix lookup observes owners only for matching stacks. An unreadable prefix match keeps its state error as the cause and points at the registry. Tests cover a prefix shared by readable and unreadable stacks, destroying one of several stacks by prefix, and the start status pointer repeating the full id. Co-Authored-By: Claude Opus 5.5 --- .../stack/destroy/destroy.integration.test.ts | 12 ++++++++++-- .../stack/stack.shared.integration.test.ts | 8 ++++++-- .../experimental/stack/stack.shared.ts | 16 ++++++++-------- .../stack/start/start.integration.test.ts | 10 ++++++++++ packages/stack/src/effect.ts | 19 ++++++++++++++++--- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index d53524e1a0..b09a00292a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -119,15 +119,23 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("destroys a stack addressed by the short ID that stack list shows", () => + it.live("destroys only the stack addressed by the short ID that stack list shows", () => Effect.gen(function* () { const f = yield* fixture(true); + const other = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "other", + runtime: "native", + }); yield* stackDestroy({ ...f.flags, stackId: Option.some(f.stack.id.slice(0, 8)) }).pipe( Effect.provide(f.layer), ); - expect(yield* f.api.discover(f.locations)).toEqual([]); + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + other.id, + ]); expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); }).pipe(Effect.provide(live)), ); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index 829dc1e229..dcdb889a29 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -82,7 +82,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects an id prefix that more than one saved stack shares", () => + it.live("rejects an id prefix that several readable or unreadable saved stacks share", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -95,6 +95,9 @@ describe("stack target resolver", () => { path.join(home, "stacks", twin, "state.json"), saved.replaceAll(id, twin), ); + const broken = `${id.slice(0, 8)}${"f".repeat(56)}`; + yield* fs.makeDirectory(path.join(home, "stacks", broken)); + yield* fs.writeFileString(path.join(home, "stacks", broken, "state.json"), "{broken"); const failure = yield* resolve({ projectRoot: root, @@ -103,9 +106,10 @@ describe("stack target resolver", () => { }).pipe(Effect.flip); expect(failure.reason).toBe("flags"); - expect(failure.message).toContain(`Stack id prefix ${id.slice(0, 8)} matches 2 stacks`); + expect(failure.message).toContain(`Stack id prefix ${id.slice(0, 8)} matches 3 stacks`); expect(failure.message).toContain(id); expect(failure.message).toContain(twin); + expect(failure.message).toContain(broken); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 4c5af4b6d4..67ed830c7e 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -123,17 +123,14 @@ export const stackTargetResolverLayer = Layer.effect( stateRoot: string, prefix: string, ) { - const unreadable: Array<{ readonly id: string; readonly message: string }> = []; - const discovered = yield* stackApi + const unreadable: Array<{ readonly id: string; readonly error: Error }> = []; + const matches = yield* stackApi .discover({ stateRoot, - onInvalidState: (id, error) => - Effect.sync(() => { - if (id.startsWith(prefix)) unreadable.push({ id, message: error.message }); - }), + idPrefix: prefix, + onInvalidState: (id, error) => Effect.sync(() => unreadable.push({ id, error })), }) .pipe(Effect.mapError(stateError)); - const matches = discovered.filter(({ definition }) => definition.id.startsWith(prefix)); const matchedIds = [ ...matches.map(({ definition }) => definition.id), ...unreadable.map(({ id }) => id), @@ -148,8 +145,11 @@ export const stackTargetResolverLayer = Layer.effect( const [invalid] = unreadable; if (invalid !== undefined) return yield* new StackTargetError({ - message: `Stack ${invalid.id} could not be read: ${invalid.message}`, + message: `Stack ${invalid.id} could not be read: ${invalid.error.message}`, reason: "invalid-config", + suggestion: + "Inspect the stack registry under $SUPABASE_HOME/stacks or ~/.supabase/stacks.", + cause: invalid.error, }); return matches[0]; }); 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 ab3f102e4c..13952ac24f 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 @@ -741,6 +741,16 @@ describe("experimental stack start", () => { expect(text.stdoutText).toContain( `Runtime: native\nRun supabase status --env --workdir ${workdir} --stack 'feature demo' to export these values as environment variables.\n`, ); + + yield* fixture.stack.composition.stop; + const byPrefix = mockOutput(); + yield* stackStart({ + ...flags(excluded), + stackId: Option.some(fixture.stack.id.slice(0, 8)), + }).pipe(Effect.provide(layers(root, fixture, byPrefix, true, true))); + expect(byPrefix.stdoutText).toContain( + `Run supabase status --env --workdir ${workdir} --stack-id ${fixture.stack.id} to export`, + ); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 40e09273f6..36fd546f47 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -969,19 +969,32 @@ export const open = Effect.fn("Stack.open")( Effect.mapError((cause) => failure("open", cause)), ); -/** Lists readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ +/** + * Lists readable saved stacks with their live owners; `onInvalidState` observes skipped entries. + * `idPrefix` limits both, and the owner probes, to stacks whose id starts with it. + */ export const discover = Effect.fn("Stack.discover")( function* ( options: Pick & { + readonly idPrefix?: string; readonly onInvalidState?: (id: string, error: State.StateError) => Effect.Effect; }, ) { + const selected = (id: string) => + options.idPrefix === undefined || id.startsWith(options.idPrefix); + const onInvalidState = options.onInvalidState; const state = yield* State.Service.pipe( Effect.provide( - State.layer({ root: options.stateRoot, onInvalidState: options.onInvalidState }), + State.layer({ + root: options.stateRoot, + onInvalidState: + onInvalidState === undefined + ? undefined + : (id, error) => (selected(id) ? onInvalidState(id, error) : Effect.void), + }), ), ); - const saved = yield* state.list; + const saved = (yield* state.list).filter(({ id }) => selected(id)); return yield* Effect.forEach( saved, (definition) =>