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
50 changes: 50 additions & 0 deletions apps/cli/src/commands/config/config.load.ts
Original file line number Diff line number Diff line change
@@ -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<E>(
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),
),
);
}
21 changes: 19 additions & 2 deletions apps/cli/src/commands/config/config.read-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,33 @@ 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.";
}
if (status === 403) {
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);
}
25 changes: 19 additions & 6 deletions apps/cli/src/commands/config/config.read-status.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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"}',
);
});
Expand Down
46 changes: 10 additions & 36 deletions apps/cli/src/commands/config/diff/diff.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 19 additions & 14 deletions apps/cli/src/commands/config/diff/diff.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
42 changes: 11 additions & 31 deletions apps/cli/src/commands/config/pull/pull.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import {
applyConfigEdits,
decodeCliConfigDocumentForValidationEffect,
loadCliConfig,
writeCliConfigDocumentText,
type ConfigEdit,
type ConfigEditRefusalReason,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/commands/config/pull/pull.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "../../../../tests/helpers/mocks.ts";
import {
buildLegacyTestRuntime,
LEGACY_DEFAULT_API_URL,
LEGACY_VALID_REF,
legacyJsonResponse,
legacyTransportFailure,
Expand Down Expand Up @@ -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" } },
Expand All @@ -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));
});

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/config/push/push.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)<MessageOnlyArgs> {
Expand Down
Loading
Loading