Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
dcc0317
feat(cli): port supabase start command to native TypeScript
Coly010 Jul 9, 2026
623dd8b
Merge remote-tracking branch 'origin/develop' into columferry/cli-132…
Coly010 Jul 9, 2026
b5f1ac1
fix(cli): honor linked-project pins and start flags Go already applies
Coly010 Jul 10, 2026
487c40b
fix(cli): honor db.root_key, Linux host-gateway, and auth.external_ur…
Coly010 Jul 10, 2026
3201d96
fix(cli): honor SMTP table default, custom auth providers, and Postgr…
Coly010 Jul 10, 2026
ab99aaa
fix(cli): create staged secret-file directory as owner-only (0700)
Coly010 Jul 10, 2026
e74d0ae
fix(cli): apply env overrides that start silently dropped before this…
Coly010 Jul 10, 2026
356a9d7
fix(cli): decrypt SMTP/Studio secrets, honor Deno version override, f…
Coly010 Jul 10, 2026
00c4169
fix(cli): apply env overrides that start silently dropped in five mor…
Coly010 Jul 10, 2026
276a3cd
fix(cli): apply env overrides and Go defaults start silently dropped …
Coly010 Jul 10, 2026
0c9fce4
fix(cli): decrypt more Secret-typed fields and fix two more start par…
Coly010 Jul 10, 2026
5a21730
fix(cli): apply five more start env overrides Go resolves before Vipe…
Coly010 Jul 10, 2026
7d24ce6
fix(cli): apply Studio/local_smtp port overrides, fail closed on bad …
Coly010 Jul 10, 2026
a719cfa
fix(cli): stop making a duplicate docker ps request in stop/rollback
Coly010 Jul 10, 2026
795f232
fix(cli): apply pooler/external-provider/nested-auth env overrides, r…
Coly010 Jul 10, 2026
2bc4c16
fix(cli): fail closed on storage size parse, fix auth site URL/Oriole…
Coly010 Jul 10, 2026
6125553
fix(cli): apply Kong TLS/service-role-key/analytics-port/passkey/weba…
Coly010 Jul 10, 2026
5e160fd
fix(cli): apply local_smtp sender/SMS-provider/auth-email env overrid…
Coly010 Jul 10, 2026
316d126
fix(cli): gate image-transformation env override on section presence,…
Coly010 Jul 10, 2026
86d7e03
fix(cli): coerce env(...)-resolved passkey/webauthn raw values, apply…
Coly010 Jul 10, 2026
021e028
fix(cli): recognize no-such-object inspect errors, fail closed on sta…
Coly010 Jul 11, 2026
e528059
fix(cli): stop pre-pulling excluded services' fresh-DB setup-job imag…
Coly010 Jul 11, 2026
03e943c
fix(cli): fail closed on malformed pooler overrides, reject unparsabl…
Coly010 Jul 11, 2026
0ef1d0e
fix(cli): fold api.external_url and storage settings into bucket-seed…
Coly010 Jul 11, 2026
412c30b
test(cli): expect imgproxy disabled by default in start live test
Coly010 Jul 11, 2026
97d0655
fix(cli): skip remote JWKS fetch for an empty third-party issuer URL
Coly010 Jul 11, 2026
0a4aaab
fix(cli): keep Vector's container-facing DOCKER_HOST out of the docke…
Coly010 Jul 11, 2026
61222af
fix(cli): omit empty key_ops from the published JWKS, matching Go's o…
Coly010 Jul 11, 2026
9c82751
fix(cli): preserve JWK use/key_ops/ext from signing_keys_path; honor …
Coly010 Jul 11, 2026
983eab9
fix(cli): reclaim staged start-secrets even when stop's teardown part…
Coly010 Jul 11, 2026
3e5cd58
fix(cli): wrap gate resolution in typed config errors; gate Kong TLS …
Coly010 Jul 11, 2026
f53bf09
fix(start): widen staged secret files to 0644 to match Go's readable-…
Coly010 Jul 20, 2026
8da2268
fix(start): match Go's Docker pull retry count and backoff (review: #…
Coly010 Jul 20, 2026
b0e0afc
fix(start): label the start network with com.docker.compose.project (…
Coly010 Jul 20, 2026
f799a9f
fix(config): preserve fractional seconds in Go duration formatting wi…
Coly010 Jul 20, 2026
5755ee3
fix(start): stop reloading Kong from start's Edge Runtime bring-up (r…
Coly010 Jul 20, 2026
99f0bed
fix(stop): scope staged-secret cleanup to each container's own workdi…
Coly010 Jul 20, 2026
c05bdc2
fix(start): wrap late config-override parsers in a typed error (revie…
Coly010 Jul 20, 2026
74d0f93
fix(start): treat empty TLS cert/key paths as unset (review: #5847)
Coly010 Jul 20, 2026
fabe8c3
fix(start): skip creating built-in Docker networks (review: #5847)
Coly010 Jul 20, 2026
22f18c2
fix(start): apply --ignore-health-check to Postgres's own health wait…
Coly010 Jul 20, 2026
3a30aa0
fix(start): ignore stdio on image inspect to avoid a pipe-fill deadlo…
Coly010 Jul 20, 2026
9c3aae9
fix(start): label the Edge Runtime container with its source workdir …
Coly010 Jul 20, 2026
ae73362
fix(start): rename 6 exports to lead with the legacy prefix (review: …
Coly010 Jul 20, 2026
353acfa
fix(start): preserve raw version pin tags instead of adding a v prefi…
Coly010 Jul 20, 2026
88c3c1e
fix(functions): keep serve lenient on third-party JWKS when auth is d…
Coly010 Jul 20, 2026
7c3937a
fix(functions): clean up stale multiline-env secrets even when none a…
Coly010 Jul 20, 2026
7a7ca2b
fix(config): reject a non-string db.root_key instead of crashing (rev…
Coly010 Jul 20, 2026
38c4520
fix(functions): log 'Skipped serving Function' from the shared bind-m…
Coly010 Jul 20, 2026
3ba7e10
fix(start): reuse bring-up-resolved keys for the final status print (…
Coly010 Jul 20, 2026
998d261
fix(start): scope function discovery to the exact workdir (review: #5…
Coly010 Jul 20, 2026
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
210 changes: 105 additions & 105 deletions apps/cli/docs/go-cli-porting-status.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ProjectConfig } from "@supabase/config";

import { diff } from "./config-sync.diff.ts";
import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts";
import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts";
import { intToUint } from "../../../../shared/legacy-size-units.ts";

/**
Expand Down Expand Up @@ -56,11 +57,6 @@ export interface RemoteApiConfig {
readonly max_rows: number;
}

/** Go `strToArr`: empty string → `[]`, else comma-split (no trimming here). */
function strToArr(v: string): Array<string> {
return v.length === 0 ? [] : v.split(",");
}

/** Projects the loaded `config.api` into the push subset. */
export function apiSubsetFromConfig(config: ProjectConfig): ApiSubset {
const api = config.api;
Expand Down Expand Up @@ -94,8 +90,8 @@ function applyRemoteApiConfig(local: ApiSubset, remote: RemoteApiConfig): ApiSub
return {
...local,
enabled: true,
schemas: strToArr(remote.db_schema).map((s) => s.trim()),
extra_search_path: strToArr(remote.db_extra_search_path).map((s) => s.trim()),
schemas: legacyStrToArr(remote.db_schema).map((s) => s.trim()),
extra_search_path: legacyStrToArr(remote.db_extra_search_path).map((s) => s.trim()),
max_rows: intToUint(remote.max_rows),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ProjectConfig } from "@supabase/config";

import { diff } from "./config-sync.diff.ts";
import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts";
import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts";
import { intToUint } from "../../../../shared/legacy-size-units.ts";
import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts";
import type { AuthEmailContent } from "./config-sync.auth-email-content.ts";
Expand Down Expand Up @@ -838,11 +839,6 @@ const AUTH_FIELDS: ReadonlyArray<TomlField> = [
// Helpers
// ---------------------------------------------------------------------------

/** Go `strToArr`: empty string → `[]`, else comma-split. */
function strToArr(v: string): Array<string> {
return v.length === 0 ? [] : v.split(",");
}

/** Go `cast.IntToUint`: clamp negatives to 0. */
function valOrDefault<T>(v: T | null | undefined, def: T): T {
return v == null ? def : v;
Expand Down Expand Up @@ -1299,7 +1295,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi

// Base scalar fields
const siteUrl = valOrDefault(remote.site_url, "");
const additionalRedirectUrls = strToArr(valOrDefault(remote.uri_allow_list, ""));
const additionalRedirectUrls = legacyStrToArr(valOrDefault(remote.uri_allow_list, ""));
const jwtExpiry = intToUint(valOrDefault(remote.jwt_exp, 0));
const enableRefreshTokenRotation = valOrDefault(remote.refresh_token_rotation_enabled, false);
const refreshTokenReuseInterval = intToUint(
Expand All @@ -1322,7 +1318,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi
webauthn = {
rp_display_name: valOrDefault(remote.webauthn_rp_display_name, ""),
rp_id: valOrDefault(remote.webauthn_rp_id, ""),
rp_origins: strToArr(valOrDefault(remote.webauthn_rp_origins, "")),
rp_origins: legacyStrToArr(valOrDefault(remote.webauthn_rp_origins, "")),
};
}

Expand Down Expand Up @@ -1786,7 +1782,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi

/** Port of Go `sms.fromAuthConfig` → `envToMap`. */
function envToMap(input: string): Record<string, string> {
const env = strToArr(input);
const env = legacyStrToArr(input);
const result: Record<string, string> = {};
for (const kv of env) {
const eqIdx = kv.indexOf("=");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,87 @@ describe("legacy functions serve integration", () => {
});
});

it.live(
"cleans up a stale multiline-env directory from a previous run even when this run has no multiline secrets",
() => {
deployMockState.runHandler = (command, args) => {
if (command !== "docker") {
throw new Error(`unexpected process: ${command}`);
}
if (args[0] === "container" && args[1] === "inspect") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "run") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
return { exitCode: 0, stdout: "", stderr: "" };
}
throw new Error(`unexpected docker args: ${args.join(" ")}`);
};

const childSpawner = mockDockerLogSpawner([
{
exitCode: 1,
stderr: "error running container: exit 1",
},
]);

const staleMultilineEnvDir = join(
tempRoot.current,
"supabase",
".temp",
"start-secrets",
"supabase_edge_runtime_test-project",
"multiline-env",
);

return Effect.gen(function* () {
yield* Effect.promise(() =>
writeProjectConfig(['project_id = "test-project"', ""].join("\n")),
);
yield* Effect.promise(() =>
writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'),
);
yield* Effect.promise(() =>
writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")),
);
// Simulate a stale directory left behind by an earlier run that DID have multiline secrets.
yield* Effect.promise(async () => {
await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 });
await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n");
await writeFile(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n");
});

const { layer } = setupServe({ childSpawner });

const error = yield* legacyFunctionsServe(baseFlags()).pipe(
Effect.provide(layer),
Effect.flip,
);
expect(error).toBeInstanceOf(Error);

expect(existsSync(staleMultilineEnvDir)).toBe(false);

const dockerRun = deployMockState.runCalls.find(
(call) => call.command === "docker" && call.args[0] === "run",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
throw new Error("expected docker run call");
}
expect(
extractFlagValues(dockerRun.args, "-v").some((value) =>
value.endsWith(":/root/.supabase/multiline-env:ro"),
),
).toBe(false);
});
},
);

it.live("fails before startup when a multiline env name is not a shell identifier", () => {
return Effect.gen(function* () {
yield* Effect.promise(() =>
Expand Down Expand Up @@ -1024,6 +1105,19 @@ describe("legacy functions serve integration", () => {
),
).toHaveLength(2);
expect(out.stderrText).toContain("File change detected:");

// `functions serve`'s restart wrapper (`startEdgeRuntime`, Go's
// `restartEdgeRuntime`) reloads Kong after each successful bring-up —
// once for the initial start, once for the file-change-triggered restart.
expect(
deployMockState.runCalls.filter(
(call) =>
call.command === "docker" &&
call.args[0] === "exec" &&
call.args.includes("supabase_kong_test-project") &&
call.args.includes("reload"),
),
).toHaveLength(2);
});
});

Expand Down Expand Up @@ -1518,6 +1612,98 @@ describe("legacy functions serve integration", () => {
},
);

it.live(
"does not fail startup on a malformed third-party provider config when auth is disabled",
() => {
// Go's `Auth.ThirdParty.validate()` (the "required field" check) only runs inside
// `Config.Validate`'s `if Auth.Enabled` block — `functions serve`'s own JWKS resolution
// discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. So a
// workos provider enabled without an `issuer_url` must not block startup here.
deployMockState.runHandler = (command, args) => {
if (command !== "docker") {
throw new Error(`unexpected process: ${command}`);
}
if (args[0] === "container" && args[1] === "inspect") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "run") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
return { exitCode: 0, stdout: "", stderr: "" };
}
throw new Error(`unexpected docker args: ${args.join(" ")}`);
};

const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]);

return Effect.gen(function* () {
const fetchMock = vi.spyOn(globalThis, "fetch");

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
fetchMock.mockRestore();
}),
);

yield* Effect.promise(() =>
writeProjectConfig(
[
'project_id = "test-project"',
"",
"[auth]",
"enabled = false",
"",
"[auth.third_party.workos]",
"enabled = true",
"",
].join("\n"),
),
);
yield* Effect.promise(() =>
writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'),
);
yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'));

const { layer } = setupServe({ childSpawner });
const error = yield* legacyFunctionsServe(baseFlags()).pipe(
Effect.provide(layer),
Effect.flip,
);

expect(error).toBeInstanceOf(Error);
if (error instanceof Error) {
expect(error.message).toContain("jwks logs failed");
}
expect(fetchMock).not.toHaveBeenCalled();

const dockerRun = deployMockState.runCalls.find(
(call) => call.command === "docker" && call.args[0] === "run",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
throw new Error("expected docker run call");
}

const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS="));
expect(jwks).toBeDefined();
if (jwks === undefined) {
throw new Error("missing SUPABASE_JWKS");
}
expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({
keys: expect.arrayContaining([
expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }),
expect.objectContaining({ kty: "oct" }),
]),
});
});
},
);

it.live("includes config-defined edge runtime secrets in the runtime env", () => {
deployMockState.runHandler = (command, args) => {
if (command !== "docker") {
Expand Down
43 changes: 1 addition & 42 deletions apps/cli/src/legacy/commands/services/services.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.ser
import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts";
import { legacyResolveDbImage } from "../../shared/legacy-db-image.ts";
import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts";
import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts";
import { readLegacyServiceVersionOverrides } from "../../shared/legacy-service-version-overrides.ts";
import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts";
import { Output } from "../../../shared/output/output.service.ts";
import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts";
Expand All @@ -20,8 +20,6 @@ import {
formatServicesWarning,
listLocalServiceVersions,
type LocalServiceImageOverrides,
type LocalServiceVersionName,
type LocalServiceVersionOverrides,
mergeRemoteServiceVersions,
renderServicesTable,
renderServicesWarning,
Expand Down Expand Up @@ -185,42 +183,3 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le
function formatConfigLoadError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

const LEGACY_VERSION_FILES = [
["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14],
["postgrest", "rest-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14],
["storage", "storage-version"],
["realtime", "realtime-version"],
["studio", "studio-version"],
["pgmeta", "pgmeta-version"],
["analytics", "logflare-version"],
["pooler", "pooler-version"],
] as const satisfies ReadonlyArray<
readonly [LocalServiceVersionName, string, ((majorVersion: number | undefined) => boolean)?]
>;

const readLegacyServiceVersionOverrides = Effect.fnUntraced(function* (
fs: FileSystem.FileSystem,
path: Path.Path,
workdir: string,
majorVersion: number | undefined,
) {
const paths = legacyTempPaths(path, workdir);
const versions: LocalServiceVersionOverrides = {};

for (const [service, fileName, shouldRead] of LEGACY_VERSION_FILES) {
if (shouldRead !== undefined && !shouldRead(majorVersion)) {
continue;
}

const version = yield* fs.readFileString(path.join(paths.tempDir, fileName)).pipe(
Effect.map((content) => content.trim()),
Effect.orElseSucceed(() => ""),
);
if (version.length > 0) {
versions[service] = version;
}
}

return versions;
});
Loading