Skip to content
Merged
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
36 changes: 35 additions & 1 deletion apps/cli/src/cli/main.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { Effect, Schema } from "effect";
import { runSupabaseEffect, withTempCliProject, withTempHome } from "../../tests/helpers/cli.ts";

describe("CLI feature routing", () => {
Expand All @@ -25,4 +25,38 @@ describe("CLI feature routing", () => {
),
),
);

it.live("names the stack opt-in when the legacy backend rejects a stack-only flag", () =>
withTempHome((home) =>
withTempCliProject("supabase-main-routing-e2e-", (project) =>
Effect.gen(function* () {
const options = {
cwd: project.dir,
home: home.dir,
env: { SUPABASE_EXPERIMENTAL_STACK: undefined, SUPABASE_WORKDIR: undefined },
};
const hint =
"Hint: --runtime needs the stack backend. Set SUPABASE_EXPERIMENTAL_STACK=1, or set stack = true under [experimental] in your project config and leave SUPABASE_EXPERIMENTAL_STACK unset.";

const text = yield* runSupabaseEffect(["start", "--runtime", "native"], options);
expect(text.exitCode).toBe(1);
expect(text.stderr).toContain("Unrecognized flag: --runtime");
expect(text.stderr).toContain(hint);

const json = yield* runSupabaseEffect(
["start", "--runtime", "native", "--output-format", "json"],
options,
);
expect(json.exitCode).toBe(1);
const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(
json.stdout,
);
expect(envelope).toMatchObject({
_tag: "Error",
error: { code: "UnrecognizedOption", message: expect.stringContaining(hint) },
});
}),
),
),
);
});
14 changes: 10 additions & 4 deletions apps/cli/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { defaultCompleteDeps, tryComplete } from "./complete.ts";
import { resolveProjectStateSelection } from "../command-internal/stack-backend.ts";
import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts";
import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts";
import { cliEntrypointForFeatures } from "./root.ts";
import { cliEntrypointForFeatures, rootCommandForFeatures } from "./root.ts";

const args = await Effect.runPromise(
Effect.gen(function* () {
Expand All @@ -31,12 +31,17 @@ const selectionExit = await Effect.runPromiseExit(
return { projectState, stackBackend, computeEnabled };
}).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer))),
);
const selectedFeatures = Exit.isSuccess(selectionExit)
? selectionExit.value
: ({ stackBackend: "legacy", computeEnabled: false } as const);
const { rootCommand: selectedRoot, agentDefaultOutputFormat } = cliEntrypointForFeatures(
Exit.isSuccess(selectionExit)
? selectionExit.value
: { stackBackend: "legacy", computeEnabled: false },
selectedFeatures,
args,
);
const stackRootCommand =
selectedFeatures.stackBackend === "stack"
? undefined
: rootCommandForFeatures({ ...selectedFeatures, stackBackend: "stack" });
const selectionCause = Exit.isFailure(selectionExit) ? selectionExit.cause : undefined;
const selectedProjectState = Exit.isSuccess(selectionExit)
? selectionExit.value.projectState
Expand All @@ -63,6 +68,7 @@ if (
: "legacy",
).pipe(Layer.provide(FetchHttpClient.layer)),
agentDefaultOutputFormat,
...(stackRootCommand ? { stackRootCommand } : {}),
afterSuccess: upgradeNoticeHook,
...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}),
}),
Expand Down
97 changes: 97 additions & 0 deletions apps/cli/src/cli/stack-flag-opt-in-hint.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from "@effect/vitest";
import { Console, Effect, Exit, Layer } from "effect";
import { CliOutput, Command } from "effect/cli";
import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts";
import { CliArgs } from "../shared/cli/cli-args.service.ts";
import { withoutParseErrorHelpDump } from "../shared/cli/run.ts";
import { normalizeCause } from "../shared/output/normalize-error.ts";
import { textCliOutputFormatter } from "../shared/output/text-formatter.ts";
import { rootCommandForFeatures } from "./root.ts";

const legacyRoot = rootCommandForFeatures({ stackBackend: "legacy" });
const stackRoot = rootCommandForFeatures({ stackBackend: "stack" });

type Root = typeof legacyRoot;

const optInAdvice =
"Set SUPABASE_EXPERIMENTAL_STACK=1, or set stack = true under [experimental] in your project config and leave SUPABASE_EXPERIMENTAL_STACK unset.";

/** Parses `args` against `root` and returns the normalized failure message `runCli` reports. */
const parse = (
root: Root,
args: ReadonlyArray<string>,
options: { readonly stackRoot?: Root } = {},
) =>
Effect.gen(function* () {
const context = {
rootCommand: root,
args,
...(options.stackRoot ? { stackRootCommand: options.stackRoot } : {}),
};
const exit = yield* withoutParseErrorHelpDump(
Command.runWith(root, { version: "0.0.0-test" })(args),
context,
).pipe(
Effect.provide(
Layer.mergeAll(
emptyEnv(),
CliOutput.layer(textCliOutputFormatter(context)),
Layer.succeed(CliArgs, { args }),
Layer.succeed(Console.Console, fakeConsole().console),
mockOutput({ format: "text" }).layer,
),
),
Effect.exit,
);
if (Exit.isSuccess(exit)) return { failed: false, message: "" };
return { failed: true, message: normalizeCause(exit.cause, context).message };
});

describe("stack-only flags rejected by the legacy backend", () => {
const stackOnlyInvocations: ReadonlyArray<ReadonlyArray<string>> = [
["start", "--runtime", "native"],
["start", "--stack", "feature-a"],
["start", "--stack-id", "feature-a"],
["start", "--preparation", "frozen"],
["start", "--frozen"],
["start", "--eager"],
["stop", "--stack", "feature-a"],
["status", "--stack", "feature-a"],
];

for (const args of stackOnlyInvocations) {
const [command, flag] = args;
it.effect(`\`${command} ${flag}\` names the stack opt-in`, () =>
Effect.gen(function* () {
const result = yield* parse(legacyRoot, args, { stackRoot });
expect(result.failed).toBe(true);
expect(result.message).toContain(`Unrecognized flag: ${flag}`);
expect(result.message).toContain(`Hint: ${flag} needs the stack backend. ${optInAdvice}`);
}),
);
}

it.effect("adds no hint for a flag neither backend accepts", () =>
Effect.gen(function* () {
const { message } = yield* parse(legacyRoot, ["start", "--bogus"], { stackRoot });
expect(message).toContain("Unrecognized flag: --bogus");
expect(message).not.toContain("Hint:");
}),
);

it.effect("adds no hint when the stack opt-in is not offered", () =>
Effect.gen(function* () {
const { message } = yield* parse(legacyRoot, ["start", "--runtime", "native"]);
expect(message).toContain("Unrecognized flag: --runtime");
expect(message).not.toContain("Hint:");
}),
);

it.effect("the stack backend's own start recognizes the flag", () =>
Effect.gen(function* () {
const result = yield* parse(stackRoot, ["start", "--runtime", "bogus"]);
expect(result.failed).toBe(true);
expect(result.message).toContain('Invalid value for flag --runtime: "bogus"');
}),
);
});
Loading
Loading