Skip to content
Draft
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
14 changes: 14 additions & 0 deletions apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ In interactive mode (`-i`/`--interactive`), may prompt for IDE settings preferen

Success is emitted as raw text even when the legacy shell is invoked with non-text output modes.

When `supabase/config.toml` already exists and `--force` is not set (stderr, byte-matching Go's wrapped `O_EXCL` open error and `CmdSuggestion` on Linux/macOS; on Windows the Go CLI surfaces the OS path separator and errno text instead, which this port does not reproduce):

```
failed to create config file: open supabase/config.toml: file exists
Run supabase init --force to overwrite existing config file.
```

When `--use-orioledb` is passed without `--experimental` (stderr, byte-matching cobra's required-flag message from Go's `PreRun` `MarkFlagRequired("experimental")`; the second line is the generic debug hint Go's `recoverAndExit` appends):

```
required flag(s) "experimental" not set
Try rerunning the command with --debug to troubleshoot the error.
```

## Notes

- Uses the invocation cwd directly and does not recurse upward looking for an existing project.
Expand Down
30 changes: 30 additions & 0 deletions apps/cli/src/legacy/commands/init/init.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Data } from "effect";

/**
* `supabase/config.toml` already exists and `--force` was not set. Reproduces
* Go's wrapped `O_EXCL` open error from `utils.InitConfig`
* (`apps/cli-go/internal/utils/config.go:243-246`) — a `*os.PathError` passed
* through verbatim, so the message reads
* `failed to create config file: open supabase/config.toml: file exists` —
* plus the `utils.CmdSuggestion` set in `apps/cli-go/internal/init/init.go:38-42`.
* Byte parity is scoped to Linux/macOS: Windows Go renders the OS path
* separator and errno text, which this port does not reproduce.
*/
export class LegacyInitConfigExistsError extends Data.TaggedError("LegacyInitConfigExistsError")<{
readonly message: string;
readonly suggestion: string;
}> {}

/**
* `--use-orioledb` without `--experimental`. Reproduces cobra's
* `MarkFlagRequired("experimental")` PreRun error from
* `apps/cli-go/cmd/init.go:32-36`, byte-for-byte
* (`required flag(s) "experimental" not set`). No suggestion — Go's
* `recoverAndExit` appends the generic `--debug` troubleshooting hint, which
* the text output layer's `fail` already adds when `suggestion` is unset.
*/
export class LegacyInitExperimentalRequiredError extends Data.TaggedError(
"LegacyInitExperimentalRequiredError",
)<{
readonly message: string;
}> {}
24 changes: 14 additions & 10 deletions apps/cli/src/legacy/commands/init/init.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@ import { resolve } from "node:path";
import { Effect, Option } from "effect";
import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts";
import { initProject } from "../../../shared/init/project-init.ts";
import {
InitAlreadyExistsError,
InitExperimentalRequiredError,
} from "../../../shared/init/project-init.errors.ts";
import { Output } from "../../../shared/output/output.service.ts";
import {
LegacyExperimentalFlag,
LegacyWorkdirFlag,
legacyResolveYes,
} from "../../../shared/legacy/global-flags.ts";
import { LegacyInitConfigExistsError, LegacyInitExperimentalRequiredError } from "./init.errors.ts";
import type { LegacyInitFlags } from "./init.command.ts";

export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitFlags) {
Expand All @@ -21,10 +18,11 @@ export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitF
const workdir = yield* LegacyWorkdirFlag;

if (flags.useOrioledb && !experimental) {
// Go marks `experimental` required in PreRun (`cmd/init.go:32-36`), so cobra's
// `ValidateRequiredFlags` fails with its standard required-flag message.
return yield* Effect.fail(
new InitExperimentalRequiredError({
detail: "--use-orioledb is only available when experimental features are enabled.",
suggestion: "Rerun the command with `--experimental --use-orioledb`.",
new LegacyInitExperimentalRequiredError({
message: `required flag(s) "experimental" not set`,
}),
);
}
Expand All @@ -43,10 +41,16 @@ export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitF
});

if (!result.created) {
// Go's message embeds the `*os.PathError` from the `O_EXCL` open of
// `utils.ConfigPath`, which is *relative* — so the path in the message is
// always `supabase/config.toml` regardless of cwd or `--workdir`. The full
// literal is the byte-exact Go output on Linux/macOS (POSIX EEXIST text);
// Windows Go prints its own separator and OS errno text and is deliberately
// out of scope for this parity fix.
return yield* Effect.fail(
new InitAlreadyExistsError({
detail: `Config already exists at ${result.configPath}.`,
suggestion: "Run `supabase init --force` to overwrite the existing config.",
new LegacyInitConfigExistsError({
message: "failed to create config file: open supabase/config.toml: file exists",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render the existing-config error per platform

On the published Windows targets, Go constructs ConfigPath with filepath.Join and surfaces the Windows os.PathError, so this hard-coded POSIX message outputs both the wrong path separator and the wrong errno text whenever supabase/config.toml already exists. Select the message using the runtime platform (and cover Windows in the integration test) rather than knowingly limiting the legacy shell's strict output parity to Linux/macOS.

AGENTS.md reference: apps/cli/AGENTS.md:L248-L256

Useful? React with 👍 / 👎.

suggestion: "Run supabase init --force to overwrite existing config file.",
}),
);
}
Expand Down
104 changes: 97 additions & 7 deletions apps/cli/src/legacy/commands/init/init.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
LegacyWorkdirFlag,
LegacyYesFlag,
} from "../../../shared/legacy/global-flags.ts";
import { normalizeCause } from "../../../shared/output/normalize-error.ts";
import { textOutputLayer } from "../../../shared/output/output.layer.ts";
import { Output } from "../../../shared/output/output.service.ts";
import { stripAnsi } from "../../../../tests/helpers/ansi.ts";
import {
mockOutput,
mockRuntimeInfo,
Expand Down Expand Up @@ -55,17 +59,51 @@ function setup(
};
}

function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string) {
function findFailure(exit: Exit.Exit<unknown, unknown>): Record<string, unknown> {
expect(Exit.isFailure(exit)).toBe(true);
if (!Exit.isFailure(exit)) {
return;
return {};
}

const failure = Cause.findErrorOption(exit.cause);
expect(Option.isSome(failure)).toBe(true);
if (Option.isSome(failure)) {
expect((failure.value as { _tag: string })._tag).toBe(tag);
}
return Option.isSome(failure) ? (failure.value as Record<string, unknown>) : {};
}

/**
* Renders a handler failure exactly like the real CLI does — `normalizeCause`
* followed by the production text output layer's `fail` — and returns the
* captured stderr writes (ANSI-stripped). This locks the composed two-line
* stderr contract documented in SIDE_EFFECTS.md, not just the error fields.
*/
function renderFailureToStderr(exit: Exit.Exit<unknown, unknown>) {
return Effect.gen(function* () {
expect(Exit.isFailure(exit)).toBe(true);
if (!Exit.isFailure(exit)) {
return [];
}

const writes: Array<string> = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
writes.push(stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)));
return true;
}) as typeof process.stderr.write;

yield* Effect.gen(function* () {
const out = yield* Output;
yield* out.fail(normalizeCause(exit.cause));
}).pipe(
Effect.provide(textOutputLayer.pipe(Layer.provide(mockTty({})))),
Effect.ensuring(
Effect.sync(() => {
process.stderr.write = originalWrite;
}),
),
);

return writes;
});
}

describe("legacy init", () => {
Expand Down Expand Up @@ -94,7 +132,7 @@ describe("legacy init", () => {
);
});

it.live("requires --experimental when --use-orioledb is set", () => {
it.live("requires --experimental when --use-orioledb is set, with cobra's exact wording", () => {
const tempDir = makeTempDir();

return Effect.gen(function* () {
Expand All @@ -109,7 +147,59 @@ describe("legacy init", () => {
withIntellijSettings: false,
}).pipe(Effect.provide(layer), Effect.exit);

expectFailureTag(exit, "InitExperimentalRequiredError");
// Go marks `experimental` required in PreRun (`cmd/init.go:32-36`), so the
// user sees cobra's standard message. No suggestion — the text output
// layer appends Go's generic `--debug` troubleshooting hint instead.
const error = findFailure(exit);
expect(error["_tag"]).toBe("LegacyInitExperimentalRequiredError");
expect(error["message"]).toBe(`required flag(s) "experimental" not set`);
expect(error["suggestion"]).toBeUndefined();

// Composed stderr byte-matches Go's `recoverAndExit` output.
expect(yield* renderFailureToStderr(exit)).toEqual([
`required flag(s) "experimental" not set\n`,
"Try rerunning the command with --debug to troubleshoot the error.\n",
]);
}).pipe(
Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))),
);
});

it.live("fails with Go's exact error when config.toml already exists", () => {
const tempDir = makeTempDir();

const initFlags = {
interactive: false,
useOrioledb: false,
force: false,
withVscodeWorkspace: false,
withVscodeSettings: false,
withIntellijSettings: false,
};

return Effect.gen(function* () {
const { layer } = setup(tempDir);

yield* legacyInit(initFlags).pipe(Effect.provide(layer));
const exit = yield* legacyInit(initFlags).pipe(Effect.provide(layer), Effect.exit);

// Byte-matches Go: the wrapped `O_EXCL` `*os.PathError` from
// `utils.InitConfig` (`config.go:243-246`) plus the CmdSuggestion from
// `internal/init/init.go:38-42`.
const error = findFailure(exit);
expect(error["_tag"]).toBe("LegacyInitConfigExistsError");
expect(error["message"]).toBe(
"failed to create config file: open supabase/config.toml: file exists",
);
expect(error["suggestion"]).toBe(
"Run supabase init --force to overwrite existing config file.",
);

// Composed stderr byte-matches Go's `recoverAndExit` output (Linux/macOS).
expect(yield* renderFailureToStderr(exit)).toEqual([
"failed to create config file: open supabase/config.toml: file exists\n",
"Run supabase init --force to overwrite existing config file.\n",
]);
}).pipe(
Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))),
);
Expand Down
18 changes: 18 additions & 0 deletions apps/cli/src/next/commands/init/init.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Data } from "effect";

/**
* `--use-orioledb` without `--experimental`. The next shell deliberately keeps
* this friendlier wording; the legacy shell byte-matches Go's cobra
* required-flag message instead (see
* `legacy/commands/init/init.errors.ts`, CLI-1986).
*/
export class InitExperimentalRequiredError extends Data.TaggedError(
"InitExperimentalRequiredError",
)<{
readonly detail: string;
readonly suggestion: string;
}> {
override get message() {
return "The --use-orioledb flag requires --experimental.";
}
}
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/init/init.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Effect } from "effect";
import { Output } from "../../../shared/output/output.service.ts";
import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts";
import { initProject, type ProjectInitOptions } from "../../../shared/init/project-init.ts";
import { InitExperimentalRequiredError } from "../../../shared/init/project-init.errors.ts";
import { InitExperimentalRequiredError } from "./init.errors.ts";

export const init = Effect.fnUntraced(function* (
flags: Omit<ProjectInitOptions, "cwd" | "yes" | "withVscodeSettings" | "withIntellijSettings"> & {
Expand Down
46 changes: 41 additions & 5 deletions apps/cli/src/next/commands/init/init.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,20 @@ function mockContextualAnalytics() {
return { layer, captured };
}

function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string) {
function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string): Record<string, unknown> {
expect(Exit.isFailure(exit)).toBe(true);
if (!Exit.isFailure(exit)) {
return;
return {};
}

const failure = Cause.findErrorOption(exit.cause);
expect(Option.isSome(failure)).toBe(true);
if (Option.isSome(failure)) {
expect((failure.value as { _tag: string })._tag).toBe(tag);
if (!Option.isSome(failure)) {
return {};
}
const error = failure.value as Record<string, unknown>;
expect(error["_tag"]).toBe(tag);
return error;
}

describe("init handler", () => {
Expand Down Expand Up @@ -475,6 +478,33 @@ describe("init handler", () => {
);
});

it.live("prepends a line break even when the existing supabase/.gitignore is empty", () => {
const tempDir = makeTempDir();
const gitignorePath = join(tempDir, "supabase", ".gitignore");

return Effect.gen(function* () {
yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true }));
yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true }));
yield* Effect.tryPromise(() => writeFile(gitignorePath, ""));
const { layer } = buildLayer(tempDir);

yield* init({
interactive: false,
experimental: false,
useOrioledb: false,
force: false,
}).pipe(Effect.provide(layer));

// Go appends `\n` + template to any pre-existing file, even an empty one
// (`apps/cli-go/internal/init/init.go:80-96`).
expect(yield* Effect.tryPromise(() => readFile(gitignorePath, "utf8"))).toBe(
`\n${INIT_GITIGNORE_TEMPLATE}`,
);
}).pipe(
Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))),
);
});

it.live("requires --experimental when --use-orioledb is set", () => {
const tempDir = makeTempDir();

Expand All @@ -488,7 +518,13 @@ describe("init handler", () => {
force: false,
}).pipe(Effect.provide(layer), Effect.exit);

expectFailureTag(exit, "InitExperimentalRequiredError");
// The next shell deliberately keeps this friendlier wording; the legacy
// shell matches Go's cobra message instead (CLI-1986).
const error = expectFailureTag(exit, "InitExperimentalRequiredError");
expect(error["message"]).toBe("The --use-orioledb flag requires --experimental.");
expect(error["suggestion"]).toBe(
"Rerun the command with `supabase init --experimental --use-orioledb`.",
);
}).pipe(
Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))),
);
Expand Down
20 changes: 0 additions & 20 deletions apps/cli/src/shared/init/project-init.errors.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,5 @@
import { Data } from "effect";

export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsError")<{
readonly detail: string;
readonly suggestion: string;
}> {
override get message() {
return "A Supabase project is already initialized in this directory.";
}
}

export class InitExperimentalRequiredError extends Data.TaggedError(
"InitExperimentalRequiredError",
)<{
readonly detail: string;
readonly suggestion: string;
}> {
override get message() {
return "The --use-orioledb flag requires --experimental.";
}
}

export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{
readonly detail: string;
readonly suggestion: string;
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/shared/init/project-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,10 @@ const ensureSupabaseGitignore = Effect.fnUntraced(function* (cwd: string) {
if (existing.includes(INIT_GITIGNORE_TEMPLATE)) {
return;
}
const prefix = existing.length > 0 ? "\n" : "";
yield* fs.writeFileString(gitignorePath, `${existing}${prefix}${INIT_GITIGNORE_TEMPLATE}`);
// Go always prepends a line break when appending to an existing file, even
// an empty one (`apps/cli-go/internal/init/init.go:80-96`: the `err == nil`
// branch of `FileContainsBytes` covers empty files too).
yield* fs.writeFileString(gitignorePath, `${existing}\n${INIT_GITIGNORE_TEMPLATE}`);
return;
}

Expand Down
Loading