Skip to content
Open
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
25 changes: 24 additions & 1 deletion .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,44 @@
"!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/api-*",
"!apps/cli/src/command-internal/branch-ref.*",
"!apps/cli/src/command-internal/branch-target.*",
"!apps/cli/src/command-internal/connect-errors.*",
"!apps/cli/src/command-internal/db-*.ts",
"!apps/cli/src/command-internal/debug-logger.*",
"!apps/cli/src/command-internal/ensure-login.*",
"!apps/cli/src/command-internal/experimental-feature.ts",
"!apps/cli/src/command-internal/get-api-keys.*",
"!apps/cli/src/command-internal/get-tenant-api-keys.*",
"!apps/cli/src/command-internal/hostname.*",
"!apps/cli/src/command-internal/http-*",
"!apps/cli/src/command-internal/identity-stitch.*",
"!apps/cli/src/command-internal/kong-*",
"!apps/cli/src/command-internal/link-services-core.*",
"!apps/cli/src/command-internal/linked-state.*",
"!apps/cli/src/command-internal/login-*",
"!apps/cli/src/command-internal/management-api-runtime.*",
"!apps/cli/src/command-internal/parent-project-ref.*",
"!apps/cli/src/command-internal/pgpass.*",
"!apps/cli/src/command-internal/pgservicefile.*",
"!apps/cli/src/command-internal/pooler-fallback.*",
"!apps/cli/src/command-internal/postgres-client.run.ts",
"!apps/cli/src/command-internal/profile-load.*",
"!apps/cli/src/command-internal/profile.*",
"!apps/cli/src/command-internal/project-create-core.*",
"!apps/cli/src/command-internal/project-target.*",
"!apps/cli/src/command-internal/raw-http.*",
"!apps/cli/src/command-internal/ref-patterns.*",
"!apps/cli/src/command-internal/resolve-token.*",
"!apps/cli/src/command-internal/schema-flags.*",
"!apps/cli/src/command-internal/stack-api.ts",
"!apps/cli/src/command-internal/stack-backend.ts",
"!apps/cli/src/command-internal/stack-catalog-setup.ts",
"!apps/cli/src/command-internal/stack-config.ts",
"!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/stack-storage.ts",
"!apps/cli/src/command-internal/tenant-*"
]
}
20 changes: 9 additions & 11 deletions apps/cli/src/command-internal/branch-ref.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ export interface BranchRefResolveMappers<EGet, EFind> {
* directory: the UUID endpoint does not use a parent ref, so requiring one
* up front would fail invocations the API itself can serve.
*/
export function resolveBranchProjectRef<EGet, EFind, EParent = never, RParent = never>(
input: string,
projectRef: string | Effect.Effect<string, EParent, RParent>,
mappers: BranchRefResolveMappers<EGet, EFind>,
) {
return Effect.gen(function* () {
export const resolveBranchProjectRef = Effect.fn("BranchRef.resolve")(
function* <EGet, EFind, EParent = never, RParent = never>(
input: string,
projectRef: string | Effect.Effect<string, EParent, RParent>,
mappers: BranchRefResolveMappers<EGet, EFind>,
) {
if (BRANCH_PROJECT_REF_PATTERN.test(input)) {
yield* Effect.annotateCurrentSpan("branch_ref.input_kind", "project_ref");
return input;
Expand All @@ -60,8 +60,6 @@ export function resolveBranchProjectRef<EGet, EFind, EParent = never, RParent =
.getABranch({ ref: parentRef, name: input })
.pipe(Effect.catch(mappers.mapFindError));
return branch.project_ref;
}).pipe(
Effect.tap((ref) => Effect.annotateCurrentSpan("project.ref", ref)),
Effect.withSpan("BranchRef.resolve"),
);
}
},
Effect.tap((ref) => Effect.annotateCurrentSpan("project.ref", ref)),
);
14 changes: 9 additions & 5 deletions apps/cli/src/command-internal/debug-logger.layer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Effect, Layer } from "effect";
import { DateTime, Effect, Layer } from "effect";

import { DebugFlag } from "./global-flags.ts";
import { DebugLogger } from "./debug-logger.service.ts";

const pad = (n: number): string => String(n).padStart(2, "0");

/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */
function formatTimestamp(now: Date): string {
function formatTimestamp(now: DateTime.DateTime): string {
const local = DateTime.toParts(DateTime.setZone(now, DateTime.zoneMakeLocal()));
return (
`${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
`${local.year}/${pad(local.month)}/${pad(local.day)} ` +
`${pad(local.hour)}:${pad(local.minute)}:${pad(local.second)}`
);
}

Expand All @@ -25,7 +26,10 @@ export const debugLoggerLayer = Layer.effect(

return DebugLogger.of({
debug: writeLine,
http: (method, url) => writeLine(`${formatTimestamp(new Date())} HTTP ${method}: ${url}`),
http: (method, url) =>
Effect.flatMap(DateTime.now, (now) =>
writeLine(`${formatTimestamp(now)} HTTP ${method}: ${url}`),
),
});
}),
);
22 changes: 12 additions & 10 deletions apps/cli/src/command-internal/debug-logger.layer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { afterEach, vi } from "vitest";
import { DateTime, Effect, Layer } from "effect";
import { TestClock } from "effect/testing";
import { vi } from "vitest";

import { withEnvVar } from "../../tests/helpers/command-mocks.ts";
import { DebugFlag } from "./global-flags.ts";
import { debugLoggerLayer } from "./debug-logger.layer.ts";
import { DebugLogger } from "./debug-logger.service.ts";
Expand All @@ -14,10 +16,6 @@ function captureStderr() {
return vi.spyOn(process.stderr, "write").mockImplementation(() => true);
}

afterEach(() => {
vi.useRealTimers();
});

describe("debugLoggerLayer", () => {
it.effect("does not write stderr bytes when debug is disabled", () => {
const stderr = captureStderr();
Expand Down Expand Up @@ -47,16 +45,20 @@ describe("debugLoggerLayer", () => {
});

it.effect("http emits Go timestamp order and method/url format", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47));
const stderr = captureStderr();
return Effect.gen(function* () {
const body = Effect.gen(function* () {
const localTime = DateTime.makeZonedUnsafe(
{ year: 2026, month: 6, day: 4, hour: 8, minute: 24, second: 47 },
{ timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
);
yield* TestClock.setTime(DateTime.toEpochMillis(localTime));
const logger = yield* DebugLogger;
yield* logger.http("GET", "https://api.supabase.green/v1/projects");
expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe(
"2026/06/04 08:24:47 HTTP GET: https://api.supabase.green/v1/projects\n",
);
}).pipe(
});
return withEnvVar("TZ", "Asia/Kolkata", body).pipe(
Effect.ensuring(Effect.sync(() => stderr.mockRestore())),
Effect.provide(makeLayer(true)),
);
Expand Down
14 changes: 6 additions & 8 deletions apps/cli/src/command-internal/ensure-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,12 @@ export const browserLogin = Effect.fn("Login.browser")(function* (opts: BrowserL
const failures = failuresSoFar + 1;
if (failures > MAX_LOGIN_RETRIES) {
yield* Effect.annotateCurrentSpan("login.verify_attempt_count", failures);
return yield* Effect.fail(
new LoginFailedError({
message: err.message,
statusCode: err.statusCode,
network: err.network,
decode: err.decode,
}),
);
return yield* new LoginFailedError({
message: err.message,
statusCode: err.statusCode,
network: err.network,
decode: err.decode,
});
}
yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr");
return yield* verifyWithRetries(failures);
Expand Down
54 changes: 33 additions & 21 deletions apps/cli/src/command-internal/hostname.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createHash } from "node:crypto";
import { mkdirSync, 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 { Config, ConfigProvider, Crypto, Effect, FileSystem, Layer, Option, Path } from "effect";
import {
Config,
ConfigProvider,
Crypto,
Effect,
FileSystem,
Layer,
Option,
Path,
Schema,
} from "effect";
import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts";
import {
configureLoopbackProxyBypass,
Expand Down Expand Up @@ -38,38 +45,43 @@ function configLayer(env: Readonly<Record<string, string | undefined>>) {
);
}

function writeDockerConfigDir(options: {
const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));

const writeDockerConfigDir = Effect.fnUntraced(function* (options: {
readonly currentContext?: string;
readonly contexts?: Readonly<Record<string, string>>;
}): string {
const dir = mkdtempSync(join(tmpdir(), "hostname-docker-config-"));
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "hostname-docker-config-" });
if (options.currentContext !== undefined) {
writeFileSync(
join(dir, "config.json"),
JSON.stringify({ currentContext: options.currentContext }),
yield* fs.writeFileString(
path.join(dir, "config.json"),
yield* encodeJson({ currentContext: options.currentContext }),
);
}
for (const [name, host] of Object.entries(options.contexts ?? {})) {
const contextId = createHash("sha256").update(name).digest("hex");
const metaDir = join(dir, "contexts", "meta", contextId);
mkdirSync(metaDir, { recursive: true });
writeFileSync(
join(metaDir, "meta.json"),
JSON.stringify({ Endpoints: { docker: { Host: host } } }),
const metaDir = path.join(dir, "contexts", "meta", contextId);
yield* fs.makeDirectory(metaDir, { recursive: true });
yield* fs.writeFileString(
path.join(metaDir, "meta.json"),
yield* encodeJson({ Endpoints: { docker: { Host: host } } }),
);
}
return dir;
}
});

function withDockerConfig<A>(
options: Parameters<typeof writeDockerConfigDir>[0],
env: Readonly<Record<string, string | undefined>>,
run: () => Effect.Effect<A, Config.ConfigError, HostnameServices>,
): Effect.Effect<A, Config.ConfigError> {
return Effect.acquireUseRelease(
Effect.sync(() => writeDockerConfigDir(options)),
(configDir) => run().pipe(Effect.provide(configLayer({ ...env, DOCKER_CONFIG: configDir }))),
(configDir) => Effect.sync(() => rmSync(configDir, { recursive: true, force: true })),
) {
return writeDockerConfigDir(options).pipe(
Effect.provide(BunServices.layer),
Effect.flatMap((configDir) =>
run().pipe(Effect.provide(configLayer({ ...env, DOCKER_CONFIG: configDir }))),
),
);
}

Expand Down
106 changes: 58 additions & 48 deletions apps/cli/src/command-internal/http-dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export interface DohFetchOptions {
readonly innerFetch?: FetchFn;
}

const interruptOnAbort = (signal: AbortSignal) =>
Effect.callback<never>((resume) => {
if (signal.aborted) return resume(Effect.interrupt);
const onAbort = () => resume(Effect.interrupt);
signal.addEventListener("abort", onAbort, { once: true });
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
});

/**
* Produces a custom `fetch` implementation that DNS-over-HTTPS-resolves the
* request hostname before dialing, then passes `tls.serverName` so Bun
Expand All @@ -90,55 +98,57 @@ export function dohFetch(opts: DohFetchOptions): typeof globalThis.fetch {
const { dnsResolver, resolver = resolveHostsOverHttps } = opts;
const innerFetch: FetchFn = opts.innerFetch ?? globalThis.fetch;

const fetchImpl: FetchFn = async (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
const originalUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const parsed = new URL(originalUrl);
// Strip Bun's IPv6 brackets (e.g. "[::1]") so net.isIP identifies the literal correctly.
const rawHostname = parsed.hostname;
const host =
rawHostname.startsWith("[") && rawHostname.endsWith("]")
? rawHostname.slice(1, -1)
: rawHostname;

if (dnsResolver !== "https" || net.isIP(host) !== 0) {
return innerFetch(input, init);
}

// The request's abort signal must reach the lookup too, or an abort during
// resolution leaves the resolver fiber running until the DoH server answers.
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
const ips = await Effect.runPromise(resolver(host), { signal: signal ?? undefined });
const firstIp = ips[0];
if (firstIp === undefined) {
// resolver guarantees a non-empty result; this is a safety net.
return innerFetch(input, init);
}

const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp);

// `init.headers` may be a plain record, a WHATWG `Headers` instance
// (supabase-js), or an entries array; spreading a `Headers` instance yields
// zero entries, so rebuild through the constructor. A `Request` input with
// no `init.headers` carries its headers on the request itself.
const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
const fetchImpl: FetchFn = (input, init) =>
Effect.runPromise(
Effect.gen(function* () {
const originalUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const parsed = new URL(originalUrl);
// Strip Bun's IPv6 brackets (e.g. "[::1]") so net.isIP identifies the literal correctly.
const rawHostname = parsed.hostname;
const host =
rawHostname.startsWith("[") && rawHostname.endsWith("]")
? rawHostname.slice(1, -1)
: rawHostname;

if (dnsResolver !== "https" || net.isIP(host) !== 0) {
return yield* Effect.promise(() => innerFetch(input, init));
}

// The request's abort signal must reach the lookup too, or an abort during
// resolution leaves the resolver fiber running until the DoH server answers.
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
const ips = yield* signal
? Effect.raceFirst(resolver(host), interruptOnAbort(signal))
: resolver(host);
const firstIp = ips[0];
if (firstIp === undefined) {
// resolver guarantees a non-empty result; this is a safety net.
return yield* Effect.promise(() => innerFetch(input, init));
}

const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp);

// `init.headers` may be a plain record, a WHATWG `Headers` instance
// (supabase-js), or an entries array; spreading a `Headers` instance yields
// zero entries, so rebuild through the constructor. A `Request` input with
// no `init.headers` carries its headers on the request itself.
const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
);
headers.set("Host", hostHeader);
// Bun's fetch sends `tls.serverName` as the SNI extension and validates
// the peer certificate against it, not against the IP used as the URL
// authority.
const rewrittenInit: BunFetchRequestInit = {
...init,
headers,
tls: { serverName },
};

return yield* Effect.promise(() => innerFetch(url, rewrittenInit));
}),
);
headers.set("Host", hostHeader);
// Bun's fetch sends `tls.serverName` as the SNI extension and validates
// the peer certificate against it, not against the IP used as the URL
// authority.
const rewrittenInit: BunFetchRequestInit = {
...init,
headers,
tls: { serverName },
};

return innerFetch(url, rewrittenInit);
};

// `typeof globalThis.fetch` includes Bun's `preconnect` member; attach the
// real one so this override satisfies that type without a cast.
Expand Down
Loading
Loading