diff --git a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md index 32f8b64e6b..87cd6093ff 100644 --- a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md @@ -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. diff --git a/apps/cli/src/legacy/commands/init/init.errors.ts b/apps/cli/src/legacy/commands/init/init.errors.ts new file mode 100644 index 0000000000..2554496ceb --- /dev/null +++ b/apps/cli/src/legacy/commands/init/init.errors.ts @@ -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; +}> {} diff --git a/apps/cli/src/legacy/commands/init/init.handler.ts b/apps/cli/src/legacy/commands/init/init.handler.ts index e3b76db46a..606e747dff 100644 --- a/apps/cli/src/legacy/commands/init/init.handler.ts +++ b/apps/cli/src/legacy/commands/init/init.handler.ts @@ -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) { @@ -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`, }), ); } @@ -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", + suggestion: "Run supabase init --force to overwrite existing config file.", }), ); } diff --git a/apps/cli/src/legacy/commands/init/init.integration.test.ts b/apps/cli/src/legacy/commands/init/init.integration.test.ts index f9703f76c4..39f752b8f8 100644 --- a/apps/cli/src/legacy/commands/init/init.integration.test.ts +++ b/apps/cli/src/legacy/commands/init/init.integration.test.ts @@ -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, @@ -55,17 +59,51 @@ function setup( }; } -function expectFailureTag(exit: Exit.Exit, tag: string) { +function findFailure(exit: Exit.Exit): Record { 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) : {}; +} + +/** + * 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) { + return Effect.gen(function* () { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return []; + } + + const writes: Array = []; + 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", () => { @@ -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* () { @@ -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 }))), ); diff --git a/apps/cli/src/next/commands/init/init.errors.ts b/apps/cli/src/next/commands/init/init.errors.ts new file mode 100644 index 0000000000..5f14c6e0a0 --- /dev/null +++ b/apps/cli/src/next/commands/init/init.errors.ts @@ -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."; + } +} diff --git a/apps/cli/src/next/commands/init/init.handler.ts b/apps/cli/src/next/commands/init/init.handler.ts index 6cd3ca6d32..172ca087d6 100644 --- a/apps/cli/src/next/commands/init/init.handler.ts +++ b/apps/cli/src/next/commands/init/init.handler.ts @@ -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 & { diff --git a/apps/cli/src/next/commands/init/init.integration.test.ts b/apps/cli/src/next/commands/init/init.integration.test.ts index e14986ffcf..7b7474239e 100644 --- a/apps/cli/src/next/commands/init/init.integration.test.ts +++ b/apps/cli/src/next/commands/init/init.integration.test.ts @@ -82,17 +82,20 @@ function mockContextualAnalytics() { return { layer, captured }; } -function expectFailureTag(exit: Exit.Exit, tag: string) { +function expectFailureTag(exit: Exit.Exit, tag: string): Record { 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; + expect(error["_tag"]).toBe(tag); + return error; } describe("init handler", () => { @@ -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(); @@ -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 }))), ); diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts index edcd4e069c..37454006ac 100644 --- a/apps/cli/src/shared/init/project-init.errors.ts +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -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; diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index 8efcc21336..0c73e8f5ed 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -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; }