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
38 changes: 37 additions & 1 deletion .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
"!apps/cli/tests/helpers/postgres-config-live.ts",
"!apps/cli/tests/helpers/secrets-live.ts",
"!apps/cli/tests/helpers/storage-live.ts",
"!apps/cli/src/command-internal/agent-mode.*",
"!apps/cli/src/command-internal/api-*",
"!apps/cli/src/command-internal/argv-flag-reconcile.*",
"!apps/cli/src/command-internal/bitbucket-pipeline.ts",
"!apps/cli/src/command-internal/branch-ref.*",
"!apps/cli/src/command-internal/branch-target.*",
"!apps/cli/src/command-internal/bundled-postgres-client.*",
"!apps/cli/src/command-internal/colors.*",
"!apps/cli/src/command-internal/connect-errors.*",
"!apps/cli/src/command-internal/container-cli.*",
"!apps/cli/src/command-internal/db-*.ts",
Expand All @@ -36,10 +40,21 @@
"!apps/cli/src/command-internal/drop-objects.ts",
"!apps/cli/src/command-internal/edge-runtime-*.ts",
"!apps/cli/src/command-internal/ensure-login.*",
"!apps/cli/src/command-internal/error-message.ts",
"!apps/cli/src/command-internal/experimental-feature.ts",
"!apps/cli/src/command-internal/experimental-gate.*",
"!apps/cli/src/command-internal/fail-on.*",
"!apps/cli/src/command-internal/get-api-keys.*",
"!apps/cli/src/command-internal/get-tenant-api-keys.*",
"!apps/cli/src/command-internal/git-status.*",
"!apps/cli/src/command-internal/glob.*",
"!apps/cli/src/command-internal/global-flags.*",
"!apps/cli/src/command-internal/go-duration.*",
"!apps/cli/src/command-internal/go-float.*",
"!apps/cli/src/command-internal/go-json.*",
"!apps/cli/src/command-internal/go-output-flag.*",
"!apps/cli/src/command-internal/go-output.encoders.*",
"!apps/cli/src/command-internal/go-quote.ts",
"!apps/cli/src/command-internal/hostname.*",
"!apps/cli/src/command-internal/http-*",
"!apps/cli/src/command-internal/identity-stitch.*",
Expand All @@ -48,11 +63,15 @@
"!apps/cli/src/command-internal/linked-state.*",
"!apps/cli/src/command-internal/local-project-context.*",
"!apps/cli/src/command-internal/login-*",
"!apps/cli/src/command-internal/make-dir.*",
"!apps/cli/src/command-internal/management-api-runtime.*",
"!apps/cli/src/command-internal/migrate-and-seed.*",
"!apps/cli/src/command-internal/migration*",
"!apps/cli/src/command-internal/param-introspection.*",
"!apps/cli/src/command-internal/parent-project-ref.*",
"!apps/cli/src/command-internal/parse-uint.*",
"!apps/cli/src/command-internal/password-requirements.ts",
"!apps/cli/src/command-internal/path-match.*",
"!apps/cli/src/command-internal/pg-dump.*",
"!apps/cli/src/command-internal/pg-net-guidance.*",
"!apps/cli/src/command-internal/pgdelta*",
Expand All @@ -66,11 +85,19 @@
"!apps/cli/src/command-internal/project-create-core.*",
"!apps/cli/src/command-internal/project-environment.*",
"!apps/cli/src/command-internal/project-target.*",
"!apps/cli/src/command-internal/prompt-yes-no.*",
"!apps/cli/src/command-internal/raw-http.*",
"!apps/cli/src/command-internal/ref-patterns.*",
"!apps/cli/src/command-internal/region.format.*",
"!apps/cli/src/command-internal/resolve-token.*",
"!apps/cli/src/command-internal/rune-width.*",
"!apps/cli/src/command-internal/schema-flags.*",
"!apps/cli/src/command-internal/seed*",
"!apps/cli/src/command-internal/service-catalog.*",
"!apps/cli/src/command-internal/service-version-overrides.*",
"!apps/cli/src/command-internal/shell-quote.ts",
"!apps/cli/src/command-internal/size-units.ts",
"!apps/cli/src/command-internal/sql-files-glob.*",
"!apps/cli/src/command-internal/sql-split.*",
"!apps/cli/src/command-internal/stack-api.ts",
"!apps/cli/src/command-internal/stack-backend.ts",
Expand All @@ -79,10 +106,19 @@
"!apps/cli/src/command-internal/stack-local-database.ts",
"!apps/cli/src/command-internal/stack-shadow.ts",
"!apps/cli/src/command-internal/stack-storage.ts",
"!apps/cli/src/command-internal/status-errors.ts",
"!apps/cli/src/command-internal/status-pretty.*",
"!apps/cli/src/command-internal/status-values.*",
"!apps/cli/src/command-internal/storage-*.ts",
"!apps/cli/src/command-internal/string-slice-flag.*",
"!apps/cli/src/command-internal/struct-output.*",
"!apps/cli/src/command-internal/supabase-env.*",
"!apps/cli/src/command-internal/tenant-*",
"!apps/cli/src/command-internal/timestamp.format.*",
"!apps/cli/src/command-internal/upgrade-suggest.*",
"!apps/cli/src/command-internal/vault-decrypt.*",
"!apps/cli/src/command-internal/vault.*"
"!apps/cli/src/command-internal/vault.*",
"!apps/cli/src/command-internal/workdir-project.*",
"!apps/cli/src/command-internal/workdir-validation.ts"
]
}
21 changes: 10 additions & 11 deletions apps/cli/src/command-internal/argv-flag-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { Data, Effect, FileSystem, Option, Path, Result } from "effect";
import { CliArgs } from "../shared/cli/cli-args.service.ts";
import { lastExplicitLongFlagValue, type ArgvFlagScan } from "../shared/cli/flag-groups.ts";
import { ProfileFlag, WorkdirFlag } from "./global-flags.ts";
import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
} from "../shared/telemetry/error-actionability.ts";
import { profileFilePath } from "../config/profile-file.ts";
import { envValue } from "../shared/config/env-option.ts";
import { CommandSettings } from "../config/command-settings.service.ts";
import { loadProfile, type LoadedProfile } from "./profile-load.ts";
import { parseStringSliceFlag } from "./string-slice-flag.ts";
import { validateWorkdirIsDirectory } from "./workdir-validation.ts";
Expand Down Expand Up @@ -136,7 +134,8 @@ export const validateArgvWorkdir = Effect.fnUntraced(function* (
// `serviceOption`: absent outside the real CLI tree (handler-level tests
// provide argv via `Stdio.layerTest`, not the global flag settings).
const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(WorkdirFlag));
const workdir = argvWorkdirValue(scan, parsedWorkdir, yield* envValue("SUPABASE_WORKDIR"));
const envWorkdir = Option.getOrUndefined((yield* CommandSettings).workdirEnvValue);
const workdir = argvWorkdirValue(scan, parsedWorkdir, envWorkdir);
if (Option.isNone(workdir)) {
return;
}
Expand Down Expand Up @@ -205,16 +204,17 @@ export function argvProfileValue(
* persisted-file token) or when the resolved token is empty. On every normal invocation the
* layer's resolution stands unchanged.
*
* `serviceOption` throughout: outside the real CLI tree (handler-level tests provide argv via
* `Stdio.layerTest`) the flag settings and `RuntimeInfo` may be absent, and the reconcile then
* only acts on what the scan itself shows.
* `serviceOption` for all but `CommandSettings`: outside the real CLI tree (handler-level
* tests provide argv via `Stdio.layerTest`) the flag settings may be absent, and the reconcile
* then only acts on what the scan itself shows.
*/
export const resolveArgvProfile = Effect.fnUntraced(function* (
scan: Pick<ArgvFlagScan, "occurrences" | "consumedFlagNames" | "prePathOccurrences">,
) {
const parsedRaw = yield* Effect.serviceOption(ProfileFlag);
const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase");
const env = yield* envValue("SUPABASE_PROFILE");
const settings = yield* CommandSettings;
const env = Option.getOrUndefined(settings.profileEnvValue);
const envProfile = env !== undefined && env.length > 0 ? env : undefined;

// The explicit token the raw-argv scan resolves, vs. the one the config layer's own
Expand Down Expand Up @@ -245,16 +245,15 @@ export const resolveArgvProfile = Effect.fnUntraced(function* (

const fs = yield* Effect.serviceOption(FileSystem.FileSystem);
const path = yield* Effect.serviceOption(Path.Path);
const runtimeInfo = yield* Effect.serviceOption(RuntimeInfo);
if (Option.isNone(fs) || Option.isNone(path) || Option.isNone(runtimeInfo)) {
if (Option.isNone(fs) || Option.isNone(path)) {
return Option.none<LoadedProfile>();
}

// Lowest precedence: the persisted `~/.supabase/profile` file. This reads the raw bytes,
// while the config layer trims and maps empty to the default — a divergence the token
// comparison below surfaces (e.g. a trailing newline fails to load as a profile).
const fileRaw = yield* fs.value
.readFileString(yield* profileFilePath(path.value, runtimeInfo.value.homeDir))
.readFileString(path.value.join(settings.supabaseHome, "profile"))
.pipe(Effect.option);

const argvToken = Option.isSome(argvExplicit)
Expand Down
170 changes: 103 additions & 67 deletions apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer, Option, Result } from "effect";
import { Effect, FileSystem, Layer, Option, Path, Predicate, Result } from "effect";

import { CliArgs } from "../shared/cli/cli-args.service.ts";
import { ProfileFlag } from "./global-flags.ts";
import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts";
import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts";
import {
argvBoolValue,
argvEnumValue,
argvProfileValue,
argvWorkdirValue,
resolveArgvProfile,
validateArgvWorkdir,
} from "./argv-flag-reconcile.ts";

// Sample enum values for the generic enum-reconciliation helper under test.
Expand Down Expand Up @@ -306,82 +303,121 @@ describe("argvProfileValue", () => {
});

describe("resolveArgvProfile", () => {
const withEnvProfile = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) => {
const prev = process.env["SUPABASE_PROFILE"];
process.env["SUPABASE_PROFILE"] = value;
return effect.pipe(
Effect.ensuring(
Effect.sync(() => {
if (prev === undefined) delete process.env["SUPABASE_PROFILE"];
else process.env["SUPABASE_PROFILE"] = prev;
}),
),
);
};

const services = (args: ReadonlyArray<string>, homeDir: string) =>
const services = (args: ReadonlyArray<string>, homeDir: string, profileEnvValue: string) =>
Layer.mergeAll(
BunServices.layer,
Layer.succeed(ProfileFlag, "supabase"),
Layer.succeed(CliArgs, { args }),
mockRuntimeInfo({ homeDir }),
mockCommandSettings({ workdir: homeDir, profileEnvValue: Option.some(profileEnvValue) }),
);

// --domains --profile supabase: the config layer's raw scan shadows the env with the
// swallowed --profile token; the reconcile must see the mismatch and re-load on the env
// profile instead of returning none.
it.effect(
"re-loads the env profile when the layer's scan wrongly shadowed a consumed token",
() => {
const dir = mkdtempSync(join(tmpdir(), "supabase-argv-flag-reconcile-"));
const profilePath = join(dir, "env.yml");
writeFileSync(
profilePath,
[
"name: harness",
"api_url: http://127.0.0.1:45555",
"dashboard_url: http://127.0.0.1:45555",
"project_host: localhost",
].join("\n"),
);
return withEnvProfile(
profilePath,
Effect.gen(function* () {
const resolved = yield* resolveArgvProfile({
occurrences: new Map(),
consumedFlagNames: new Set(["profile"]),
prePathOccurrences: new Map(),
});
expect(Option.isSome(resolved)).toBe(true);
if (Option.isSome(resolved)) {
expect(resolved.value.name).toBe("harness");
expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555");
}
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" });
const profilePath = path.join(dir, "env.yml");
yield* fs.writeFileString(
profilePath,
[
"name: harness",
"api_url: http://127.0.0.1:45555",
"dashboard_url: http://127.0.0.1:45555",
"project_host: localhost",
].join("\n"),
);
const resolved = yield* resolveArgvProfile({
occurrences: new Map(),
consumedFlagNames: new Set(["profile"]),
prePathOccurrences: new Map(),
}).pipe(
Effect.provide(
services(["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"], dir),
services(
["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"],
dir,
profilePath,
),
),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
),
);
expect(Option.isSome(resolved)).toBe(true);
if (Option.isSome(resolved)) {
expect(resolved.value.name).toBe("harness");
expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555");
}
}).pipe(Effect.provide(BunServices.layer)),
);

it.effect("returns none when the scan and the layer agree on an explicit supabase", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" });
const resolved = yield* resolveArgvProfile({
occurrences: new Map([["profile", ["supabase"]]]),
consumedFlagNames: new Set<string>(),
prePathOccurrences: new Map(),
}).pipe(
Effect.provide(services(["sso", "add", "--profile", "supabase"], dir, "rogue-profile")),
);
},
expect(Option.isNone(resolved)).toBe(true);
}).pipe(Effect.provide(BunServices.layer)),
);

it.effect("returns none when the scan and the layer agree on an explicit supabase", () => {
const dir = mkdtempSync(join(tmpdir(), "supabase-argv-flag-reconcile-"));
return withEnvProfile(
"rogue-profile",
Effect.gen(function* () {
const resolved = yield* resolveArgvProfile({
occurrences: new Map([["profile", ["supabase"]]]),
consumedFlagNames: new Set<string>(),
prePathOccurrences: new Map(),
});
expect(Option.isNone(resolved)).toBe(true);
it.effect("fails to load an untrimmed token persisted in the Supabase home", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" });
yield* fs.writeFileString(path.join(dir, "profile"), "snap\n");
const error = yield* resolveArgvProfile({
occurrences: new Map(),
consumedFlagNames: new Set<string>(),
prePathOccurrences: new Map(),
}).pipe(
Effect.provide(services(["sso", "add", "--profile", "supabase"], dir)),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
),
);
});
Effect.provide(
mockCommandSettings({ workdir: path.join(dir, "project"), supabaseHome: dir }),
),
Effect.flip,
);
expect(error.message).toBe(`failed to read profile: unsupported config file type ""`);
}).pipe(Effect.provide(BunServices.layer)),
);
});

describe("validateArgvWorkdir", () => {
const scan = {
occurrences: new Map<string, ReadonlyArray<string>>(),
consumedFlagNames: new Set<string>(),
prePathOccurrences: new Map<string, ReadonlyArray<string>>(),
};

it.effect("fails with the workdir error when SUPABASE_WORKDIR names a missing directory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" });
const missing = path.join(dir, "missing");
const error = yield* validateArgvWorkdir(scan).pipe(
Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: missing })),
Effect.flip,
);
expect(Predicate.isTagged(error, "PflagWorkdirError")).toBe(true);
expect(error.message).toBe(
`failed to change workdir: chdir ${missing}: no such file or directory`,
);
}).pipe(Effect.provide(BunServices.layer)),
);

it.effect("treats an empty SUPABASE_WORKDIR as unset", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-argv-flag-reconcile-" });
yield* validateArgvWorkdir(scan).pipe(
Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: "" })),
);
}).pipe(Effect.provide(BunServices.layer)),
);
});
Loading
Loading