diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts new file mode 100644 index 0000000000..08708019fd --- /dev/null +++ b/apps/cli/src/commands/config/config.load.ts @@ -0,0 +1,50 @@ +import { loadCliConfig } from "@supabase/config/internal"; +import { Effect } from "effect"; + +/** + * `cause.path`/`loaded.path` are anchored under `workdir`; render them + * relative so a message reads `supabase/config.json` like the rest of the + * `config` family, regardless of invocation cwd. + */ +export function legacyRelativeConfigPath(workdir: string, path: string): string { + return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; +} + +/** + * Loads `supabase/config.{toml,json}` for the `config` command family + * (`diff`, `pull`, `push`) with one shared failure shape: a parse failure + * names the file that actually failed — `loadCliConfig` probes + * `supabase/config.json` before falling back to `supabase/config.toml` + * (`findCliProjectPaths`), so hardcoding the `.toml` name would mislabel a + * broken `config.json` — a duplicate `[remotes.*].project_id` keeps its own + * message, and a missing file points at `supabase init`. Every family member + * keeps its own tagged error class; `makeError` builds it from the shared + * message text, mirroring `legacyResolveConfigTarget`'s per-family error + * construction (`config.target.ts`). + */ +export function legacyLoadLocalConfig( + workdir: string, + projectRef: string | undefined, + makeError: (message: string) => E, +) { + return loadCliConfig(workdir, { projectRef, goViperCompat: true }).pipe( + Effect.catchTags({ + CliConfigParseError: (cause) => + Effect.fail( + makeError( + `failed to parse ${legacyRelativeConfigPath(workdir, cause.path)}: ${String(cause.cause)}`, + ), + ), + DuplicateRemoteProjectIdError: (cause) => Effect.fail(makeError(cause.message)), + }), + Effect.flatMap((loaded) => + loaded === null + ? Effect.fail( + makeError( + "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", + ), + ) + : Effect.succeed(loaded), + ), + ); +} diff --git a/apps/cli/src/commands/config/config.read-status.ts b/apps/cli/src/commands/config/config.read-status.ts index be18fb6cf1..418c8c5ec8 100644 --- a/apps/cli/src/commands/config/config.read-status.ts +++ b/apps/cli/src/commands/config/config.read-status.ts @@ -17,8 +17,25 @@ export function legacyUnexpectedStatusMessage(status: number, body: string): str * shared by `config diff`, `config pull`, and `config push`, since all three * read the same endpoint and a bad ref/token fails the same way for each. * Every other status falls back to `legacyUnexpectedStatusMessage`. + * + * `apiHost` (the CLI's own resolved `cliSettings.apiUrl`, not anything the + * response body names) hedges the 404 case: `config push` is an established + * command that used to hit six long-lived v1 endpoints, so a 404 here can + * also mean this v2 endpoint isn't served by the configured API host at all + * (an older self-hosted Management API, a proxy, a `SUPABASE_PROFILE` + * pointing elsewhere) rather than a wrong project ref. `apiUrl` traces back + * to a `SUPABASE_PROFILE` YAML file's `api_url:` value, which is validated + * as a well-formed `http(s)://` URL but not stripped of embedded control + * characters (`legacy-profile-load.ts` returns the raw matched string, not + * a re-serialized one) — sanitized the same way `ref` already is, so a + * crafted profile can't inject terminal control sequences via this message. */ -export function legacyConfigReadStatusMessage(status: number, body: string, ref: string): string { +export function legacyConfigReadStatusMessage( + status: number, + body: string, + ref: string, + apiHost: string, +): string { if (status === 401) { return "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate."; } @@ -26,7 +43,7 @@ export function legacyConfigReadStatusMessage(status: number, body: string, ref: return `Access denied for project ${legacySanitizeInlineName(ref)}: your account does not have permission to view its configuration.`; } if (status === 404) { - return `Project ${legacySanitizeInlineName(ref)} not found. Check the project ref, or run \`supabase projects list\` to see the projects you have access to.`; + return `Could not read configuration for project ${legacySanitizeInlineName(ref)} (404). Check the project ref with \`supabase projects list\`; if the ref is correct, this Supabase API endpoint may not be available at ${legacySanitizeInlineName(apiHost)}.`; } return legacyUnexpectedStatusMessage(status, body); } diff --git a/apps/cli/src/commands/config/config.read-status.unit.test.ts b/apps/cli/src/commands/config/config.read-status.unit.test.ts index 649f447ea0..6c71a12014 100644 --- a/apps/cli/src/commands/config/config.read-status.unit.test.ts +++ b/apps/cli/src/commands/config/config.read-status.unit.test.ts @@ -6,6 +6,7 @@ import { } from "./config.read-status.ts"; const REF = "abcdefghijklmnopqrst"; +const API_HOST = "https://api.supabase.com"; describe("legacyUnexpectedStatusMessage", () => { test("shapes the generic unexpected-status message", () => { @@ -17,25 +18,37 @@ describe("legacyUnexpectedStatusMessage", () => { describe("legacyConfigReadStatusMessage", () => { test("401 points at re-authenticating", () => { - expect(legacyConfigReadStatusMessage(401, '{"message":"unauthorized"}', REF)).toBe( + expect(legacyConfigReadStatusMessage(401, '{"message":"unauthorized"}', REF, API_HOST)).toBe( "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate.", ); }); test("403 names the sanitized ref and denies access", () => { - expect(legacyConfigReadStatusMessage(403, '{"message":"forbidden"}', REF)).toBe( + expect(legacyConfigReadStatusMessage(403, '{"message":"forbidden"}', REF, API_HOST)).toBe( `Access denied for project ${REF}: your account does not have permission to view its configuration.`, ); }); - test("404 names the sanitized ref and suggests projects list", () => { - expect(legacyConfigReadStatusMessage(404, '{"message":"not found"}', REF)).toBe( - `Project ${REF} not found. Check the project ref, or run \`supabase projects list\` to see the projects you have access to.`, + test("404 names the sanitized ref, suggests projects list, and hedges the api host", () => { + expect(legacyConfigReadStatusMessage(404, '{"message":"not found"}', REF, API_HOST)).toBe( + `Could not read configuration for project ${REF} (404). Check the project ref with \`supabase projects list\`; if the ref is correct, this Supabase API endpoint may not be available at ${API_HOST}.`, ); }); + test("404 strips control characters from a hostile api host before embedding it inline", () => { + // `apiHost` traces back to a `SUPABASE_PROFILE` YAML file's `api_url:` + // value — validated as a well-formed `http(s)://` URL, but not stripped + // of embedded control characters (`legacy-profile-load.ts` keeps the raw + // matched string). A crafted profile must not be able to inject terminal + // control sequences via this message, same as `ref` already can't. + const hostileHost = "https://api.supabase.com\x1b[31mFAKE\x1b[0m"; + const message = legacyConfigReadStatusMessage(404, '{"message":"not found"}', REF, hostileHost); + expect(message).not.toContain("\x1b"); + expect(message).toContain("https://api.supabase.com[31mFAKE[0m"); + }); + test("every other status keeps the generic unexpected-status shape", () => { - expect(legacyConfigReadStatusMessage(500, '{"message":"boom"}', REF)).toBe( + expect(legacyConfigReadStatusMessage(500, '{"message":"boom"}', REF, API_HOST)).toBe( 'unexpected status 500: {"message":"boom"}', ); }); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index f9d30c7580..0243b72aa2 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -3,7 +3,7 @@ import { diffProjectConfig, fromApiProjectConfig, } from "@supabase/config/effect"; -import { loadCliConfig, remoteNameForProjectRef } from "@supabase/config/internal"; +import { remoteNameForProjectRef } from "@supabase/config/internal"; import { operationDefinitions } from "@supabase/api/effect"; import { Effect, Option } from "effect"; @@ -24,6 +24,7 @@ import { mapLegacyHttpError, sanitizeLegacyErrorBody, } from "../../../command-internal/legacy-http-errors.ts"; +import { legacyLoadLocalConfig } from "../config.load.ts"; import { legacyResolveConfigTarget } from "../config.target.ts"; import { legacyConfigApiScope, legacyConfigScopeLine } from "../config.format.ts"; import { legacyConfigProjectConfigTry } from "../config.project-config.ts"; @@ -99,41 +100,14 @@ export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( // resolver and the linked-project cache use — so `--workdir ../other` // compares `../other`'s config.toml against `../other`'s linked project, // never the invoking directory's file against another root's project. - // `cause.path` is anchored under the workdir; render it relative so the - // message reads `supabase/config.json` like the family's other messages, - // regardless of invocation cwd. - const relativeConfigPath = (path: string) => - path.startsWith(cliSettings.workdir) - ? path.slice(cliSettings.workdir.length).replace(/^[/\\]/, "") - : path; - + // `legacyLoadLocalConfig` (`../config.load.ts`, shared with `config + // pull`/`config push`) owns the parse/duplicate-remote/missing-file + // message shapes; only this family's own tagged error class is local. const loadLocalConfig = (projectRef: string | undefined) => - loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }).pipe( - // `cause.path` names the file that actually failed to parse — `loadCliConfig` - // probes `supabase/config.json` before falling back to `supabase/config.toml` - // (`findCliProjectPaths`), so hardcoding the `.toml` name here would mislabel a - // broken `config.json`. - Effect.catchTag( - "CliConfigParseError", - (cause) => - new LegacyConfigDiffLoadConfigError({ - message: `failed to parse ${relativeConfigPath(cause.path)}: ${String(cause.cause)}`, - }), - ), - Effect.catchTag( - "DuplicateRemoteProjectIdError", - (cause) => new LegacyConfigDiffLoadConfigError({ message: cause.message }), - ), - Effect.flatMap((loaded) => - loaded === null - ? Effect.fail( - new LegacyConfigDiffLoadConfigError({ - message: - "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", - }), - ) - : Effect.succeed(loaded), - ), + legacyLoadLocalConfig( + cliSettings.workdir, + projectRef, + (message) => new LegacyConfigDiffLoadConfigError({ message }), ); // Written once the comparison target is known, so the linked-project cache @@ -226,7 +200,7 @@ export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( return yield* new LegacyConfigDiffReadStatusError({ status: response.status, body, - message: legacyConfigReadStatusMessage(response.status, body, ref), + message: legacyConfigReadStatusMessage(response.status, body, ref, cliSettings.apiUrl), }); } const responseJson = yield* response.json.pipe( diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 0117721b48..7a8f154f1b 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -13,6 +13,7 @@ import { import { legacyV2ProjectConfigResponse } from "../../../../tests/helpers/legacy-config-fixtures.ts"; import { buildLegacyTestRuntime, + LEGACY_DEFAULT_API_URL, LEGACY_VALID_REF, legacyJsonResponse, legacyTransportFailure, @@ -691,20 +692,24 @@ describe("legacy config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("a 404 on the config read names the sanitized ref and suggests projects list", () => { - const { layer } = setup({ - toml: 'project_id = "test"\n', - v2: { status: 404, body: { message: "not found" } }, - }); - return Effect.gen(function* () { - const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const rendered = JSON.stringify(exit); - expect(rendered).toContain("LegacyConfigDiffReadStatusError"); - expect(rendered).toContain(LEGACY_VALID_REF); - expect(rendered).toContain("supabase projects list"); - }).pipe(Effect.provide(layer)); - }); + it.live( + "a 404 on the config read names the sanitized ref, suggests projects list, and hedges the api host", + () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 404, body: { message: "not found" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffReadStatusError"); + expect(rendered).toContain(`Could not read configuration for project ${LEGACY_VALID_REF}`); + expect(rendered).toContain("supabase projects list"); + expect(rendered).toContain(LEGACY_DEFAULT_API_URL); + }).pipe(Effect.provide(layer)); + }, + ); it.live("other config-read statuses keep the generic unexpected-status message", () => { const { layer } = setup({ diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 7766d8a635..f62a42d87c 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -10,7 +10,6 @@ import { import { applyConfigEdits, decodeCliConfigDocumentForValidationEffect, - loadCliConfig, writeCliConfigDocumentText, type ConfigEdit, type ConfigEditRefusalReason, @@ -39,6 +38,7 @@ import { legacyResolveYes, LegacyOutputFlag } from "../../../shared/legacy/globa import { legacyPromptYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; +import { legacyLoadLocalConfig, legacyRelativeConfigPath } from "../config.load.ts"; import { legacyResolveConfigTarget, type LegacyConfigTarget } from "../config.target.ts"; import { legacyConfigApiScope, @@ -631,39 +631,19 @@ const legacyValidateConfigPullPlan = Effect.fnUntraced(function* (input: { * factory rather than a shared closure so both `legacyOpenConfigPullSource` * (steps 2-3) and `legacyRunConfigPull` (step 6's conditional reload) get * their own, independently testable copy without threading `cliSettings` - * through {@link LegacyConfigPullInput}. */ + * through {@link LegacyConfigPullInput}. `legacyLoadLocalConfig` + * (`../config.load.ts`, shared with `config diff`/`config push`) owns the + * parse/duplicate-remote/missing-file message shapes; only this family's own + * tagged error class is local. */ function makeConfigLoader(cliSettings: { readonly workdir: string }) { - // `cause.path` is anchored under the workdir; render it relative so the - // message reads `supabase/config.json` like the family's other messages, - // regardless of invocation cwd (mirrors `config diff`). const relativeConfigPath = (path: string): string => - path.startsWith(cliSettings.workdir) - ? path.slice(cliSettings.workdir.length).replace(/^[/\\]/, "") - : path; + legacyRelativeConfigPath(cliSettings.workdir, path); const loadLocalConfig = (projectRef: string | undefined) => - loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }).pipe( - Effect.catchTag( - "CliConfigParseError", - (cause) => - new LegacyConfigPullLoadConfigError({ - message: `failed to parse ${relativeConfigPath(cause.path)}: ${String(cause.cause)}`, - }), - ), - Effect.catchTag( - "DuplicateRemoteProjectIdError", - (cause) => new LegacyConfigPullLoadConfigError({ message: cause.message }), - ), - Effect.flatMap((loaded) => - loaded === null - ? Effect.fail( - new LegacyConfigPullLoadConfigError({ - message: - "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", - }), - ) - : Effect.succeed(loaded), - ), + legacyLoadLocalConfig( + cliSettings.workdir, + projectRef, + (message) => new LegacyConfigPullLoadConfigError({ message }), ); return { relativeConfigPath, loadLocalConfig }; @@ -817,7 +797,7 @@ export const legacyRunConfigPull = Effect.fnUntraced(function* (input: LegacyCon return yield* new LegacyConfigPullReadStatusError({ status: response.status, body, - message: legacyConfigReadStatusMessage(response.status, body, ref), + message: legacyConfigReadStatusMessage(response.status, body, ref, cliSettings.apiUrl), }); } const responseJson = yield* response.json.pipe( diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 284589672d..06e50c837e 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -22,6 +22,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, + LEGACY_DEFAULT_API_URL, LEGACY_VALID_REF, legacyJsonResponse, legacyTransportFailure, @@ -1808,7 +1809,7 @@ describe("legacy config pull integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("a 404 on the config read suggests projects list", () => { + it.live("a 404 on the config read suggests projects list and hedges the api host", () => { const { layer } = setup({ toml: 'project_id = "test"\n', v2: { status: 404, body: { message: "not found" } }, @@ -1817,8 +1818,9 @@ describe("legacy config pull integration", () => { const exit = yield* legacyConfigPull(noFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); - expect(rendered).toContain(LEGACY_VALID_REF); + expect(rendered).toContain(`Could not read configuration for project ${LEGACY_VALID_REF}`); expect(rendered).toContain("supabase projects list"); + expect(rendered).toContain(LEGACY_DEFAULT_API_URL); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 23554d196b..4c9af62acf 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -42,7 +42,7 @@ interface StatusErrorArgs { readonly message: string; } -/** TOML parse failure (rewraps the packages/config parse error). Aborts before any network call. */ +/** Local config file missing or unparseable. Aborts before any network call. */ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( "LegacyConfigPushLoadConfigError", ) { diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 6792be35a1..d536a111c0 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -1,6 +1,5 @@ import { dirname } from "node:path"; import { fromApiProjectConfig, fromConfigDocument } from "@supabase/config"; -import { loadCliConfig } from "@supabase/config/internal"; import { diffProjectConfig, findCliProjectRoot, type ConfigChange } from "@supabase/config/effect"; import { operationDefinitions } from "@supabase/api/effect"; import { Clock, Effect, FileSystem, Option, Path } from "effect"; @@ -33,6 +32,7 @@ import { import { legacyPromptYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyCollectDotenvPrivateKeys } from "../../../command-internal/legacy-vault-decrypt.ts"; import { legacyConfigApiScope, legacyConfigScopeLine } from "../config.format.ts"; +import { legacyLoadLocalConfig } from "../config.load.ts"; import { legacyConfigProjectConfigTry } from "../config.project-config.ts"; import { legacyConfigReadStatusMessage, @@ -245,32 +245,18 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // above. // // NOTE (CLI-1489): `config push` needs the fully decoded config (every - // service subset), so it uses `loadCliConfig` rather than the tolerant - // `legacy-db-config.toml-read.ts` subtree reader. `loadCliConfig` raises - // `CliConfigParseError` on `env(...)` refs over numeric/bool fields. - // A duplicate `project_id` across remotes surfaces an established error - // message. - const loaded = yield* loadCliConfig(cliSettings.workdir, { - projectRef: ref, - goViperCompat: true, - }).pipe( - Effect.catchTag( - "CliConfigParseError", - (cause) => - new LegacyConfigPushLoadConfigError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), - Effect.catchTag( - "DuplicateRemoteProjectIdError", - (cause) => new LegacyConfigPushLoadConfigError({ message: cause.message }), - ), + // service subset), so it uses `legacyLoadLocalConfig` (`../config.load.ts`, + // shared with `config diff`/`config pull`) rather than the tolerant + // `legacy-db-config.toml-read.ts` subtree reader. The underlying + // `loadCliConfig` raises `CliConfigParseError` on `env(...)` refs over + // numeric/bool fields; `legacyLoadLocalConfig` catches it (and a + // duplicate-remote/missing-file failure) and converts it to this + // family's own tagged error via the shared message shapes. + const loaded = yield* legacyLoadLocalConfig( + cliSettings.workdir, + ref, + (message) => new LegacyConfigPushLoadConfigError({ message }), ); - if (loaded === null) { - return yield* new LegacyConfigPushLoadConfigError({ - message: "failed to read supabase/config.toml: file not found", - }); - } // Printed from inside config load, before any command output. if (loaded.appliedRemote !== undefined) { yield* output.raw( @@ -416,7 +402,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( return yield* new LegacyConfigPushConfigReadStatusError({ status: response.status, body, - message: legacyConfigReadStatusMessage(response.status, body, ref), + message: legacyConfigReadStatusMessage(response.status, body, ref, cliSettings.apiUrl), }); } const responseJson = yield* response.json.pipe( diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 4b873edd66..e74e210c31 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -13,6 +13,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, + LEGACY_DEFAULT_API_URL, LEGACY_VALID_REF, legacyJsonResponse, legacyStatusCodeFailure, @@ -412,15 +413,50 @@ describe("legacy config push integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("aborts on malformed config.toml before any network call", () => { + it.live("names supabase/config.toml on malformed config.toml, before any network call", () => { const { layer, api } = setup({ toml: "malformed", yes: true }); return Effect.gen(function* () { - const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toContain("failed to parse supabase/config.toml:"); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + it.live("names supabase/config.json (not config.toml) on a malformed config.json", () => { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), "{not valid json"); + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + Effect.succeed(legacyJsonResponse(request, 200, { available_addons: [] })), + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + mockStdin(true), + Layer.succeed(LegacyYesFlag, true), + ); + return Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + Effect.provide(layer), + ); + expect(message).toContain("failed to parse supabase/config.json:"); + expect(api.requests).toHaveLength(0); + }); + }); + it.live("merges a matching [remotes.*] block over the base and pushes it", () => { const { layer, out, api } = setup({ toml: `project_id = "test" @@ -705,7 +741,7 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); - it.live("aborts with exit 1 when no config.toml exists", () => { + it.live("directs a missing config file to supabase init, with exit 1", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ handler: (request) => @@ -724,6 +760,14 @@ max_rows = 1000 return Effect.gen(function* () { const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe( + "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", + ); }).pipe(Effect.provide(layer)); }); @@ -879,7 +923,14 @@ otp_expiry = 120 const cases: ReadonlyArray<{ status: number; expect: ReadonlyArray }> = [ { status: 401, expect: ["Authentication failed", "supabase login"] }, { status: 403, expect: ["Access denied for project", REF] }, - { status: 404, expect: [`Project ${REF} not found`, "supabase projects list"] }, + { + status: 404, + expect: [ + `Could not read configuration for project ${REF} (404)`, + "supabase projects list", + LEGACY_DEFAULT_API_URL, + ], + }, { status: 500, expect: [`unexpected status 500: {"message":"boom"}`] }, ]; return Effect.gen(function* () {