Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ files remain. The experimental feature flag controls command registration.

## Selection and confirmation

Select the current project/branch/name, `--stack <name>`, or `--stack-id <id>`.
Select the current project/branch/name, `--stack <name>`, or `--stack-id <id or unique prefix>`.
The selectors are mutually exclusive; a missing target fails. Explicit legacy
`-o/--output` is rejected in favor of `--output-format`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ describe("stack destroy", () => {
}).pipe(Effect.provide(live)),
);

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)).map(({ definition }) => definition.id)).toEqual([
other.id,
]);
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",
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ it never launches an owner or starts/stops a service.

## Selection and files

Select the current project/branch/name, `--stack <name>`, or `--stack-id <id>`.
Select the current project/branch/name, `--stack <name>`, or `--stack-id <id or unique prefix>`.
The selectors are mutually exclusive. By default, only composition members are
included. `--service <kind-or-instance-id>` can also select standalone instances.
An unavailable owner, missing stack, or unmatched service fails with status 1.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ controls command registration.

## Selection and configuration

Select the current project/branch/name, `--stack <name>`, or `--stack-id <id>`.
Select the current project/branch/name, `--stack <name>`, or `--stack-id <id or unique prefix>`.
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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,53 @@ describe("stack target resolver", () => {
}).pipe(Effect.provide(BunServices.layer)),
);

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;
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 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,
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 3 stacks`);
expect(failure.message).toContain(id);
expect(failure.message).toContain(twin);
expect(failure.message).toContain(broken);
}).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;
Expand Down Expand Up @@ -180,6 +227,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", () => {
Expand Down
84 changes: 60 additions & 24 deletions apps/cli/src/commands/experimental/stack/stack.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, StackTargetError> =>
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`.",
}),
);

Expand All @@ -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;

Expand All @@ -103,14 +111,48 @@ 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* () {
const settings = yield* CommandSettings;
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 error: Error }> = [];
const matches = yield* stackApi
.discover({
stateRoot,
idPrefix: prefix,
onInvalidState: (id, error) => Effect.sync(() => unreadable.push({ id, error })),
})
.pipe(Effect.mapError(stateError));
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.error.message}`,
reason: "invalid-config",
suggestion:
"Inspect the stack registry under $SUPABASE_HOME/stacks or ~/.supabase/stacks.",
cause: invalid.error,
});
Comment thread
avallete marked this conversation as resolved.
return matches[0];
});
const resolve = Effect.fn("StackTargetResolver.resolve")(function* (input: {
readonly projectRoot: string;
readonly name?: string;
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Comment thread
avallete marked this conversation as resolved.
},
currentShellPlatform(),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ a stack addressed with `--stack-id` when project configuration is missing or
invalid.

Target selection accepts the current project, `--stack <name>`, or
`--stack-id <id>`. `--stack` and `--stack-id` are mutually exclusive. Explicit
`--stack-id <id or unique prefix>`. `--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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, or `--stack-id <id>`.
Select the current project/branch/name, `--stack <name>`, or `--stack-id <id or unique prefix>`.
`--stack` and `--stack-id` are mutually exclusive. `--all` cannot be combined
with either selector. Explicit legacy `-o/--output` is rejected; use
`--output-format` instead.
Expand Down
Loading
Loading