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
5 changes: 0 additions & 5 deletions packages/config/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ export class ProjectEnvParseError extends Data.TaggedError("ProjectEnvParseError
readonly line: number;
}> {}

export class MissingProjectEnvVarError extends Data.TaggedError("MissingProjectEnvVarError")<{
readonly configPath: string;
readonly envName: string;
}> {}

export class MissingProjectConfigValueError extends Data.TaggedError(
"MissingProjectConfigValueError",
)<{
Expand Down
1 change: 0 additions & 1 deletion packages/config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export { ProjectConfigSchema, type ProjectConfig, type ProjectConfigJson } from "./base.ts";
export {
MissingProjectConfigValueError,
MissingProjectEnvVarError,
ProjectConfigParseError,
ProjectEnvParseError,
} from "./errors.ts";
Expand Down
33 changes: 27 additions & 6 deletions packages/config/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { Effect, FileSystem, Path, Schema } from "effect";
import * as SmolToml from "smol-toml";
import { ProjectConfigSchema, type ProjectConfig } from "./base.ts";
import { ProjectConfigParseError } from "./errors.ts";
import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts";
import { findProjectPaths } from "./paths.ts";
import { loadProjectEnvironment } from "./project.ts";

const projectConfigSchemaKey = "$schema";

Expand Down Expand Up @@ -203,18 +205,37 @@ function encodeProjectConfigToTomlDocument(
return `${SmolToml.stringify(toConfigDocument(config, schemaRef))}\n`;
}

export const loadProjectConfigFile = Effect.fnUntraced(function* (path: string) {
export const loadProjectConfigFile = Effect.fnUntraced(function* (filePath: string) {
const fs = yield* FileSystem.FileSystem;
const format = path.endsWith(".json") ? "json" : "toml";
const content = yield* fs.readFileString(path);
const path = yield* Path.Path;
const format = filePath.endsWith(".json") ? "json" : "toml";
const content = yield* fs.readFileString(filePath);
const document = yield* Effect.try({
try: () => parseProjectConfigDocument(content, format),
catch: (cause) => new ProjectConfigParseError({ path, format, cause }),
catch: (cause) => new ProjectConfigParseError({ path: filePath, format, cause }),
});

// Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env
// before schema decode. Required for numeric/boolean fields, which would
// otherwise crash the strict decoder with `Expected number` (CLI-1489).
// The config file lives at `<projectRoot>/supabase/config.{toml,json}`, so
// walking two directories up gives us the project root that
// `loadProjectEnvironment` expects.
const projectRoot = path.dirname(path.dirname(filePath));
const projectEnv = yield* loadProjectEnvironment({
cwd: projectRoot,
baseEnv: process.env,
});
const config = yield* parseProjectConfig(document, format, path);
const interpolated = interpolateEnvReferencesAgainstSchema(
document,
projectEnv?.values ?? {},
ProjectConfigSchema,
);

const config = yield* parseProjectConfig(interpolated, format, filePath);

return {
path,
path: filePath,
format,
config,
schemaRef: getSchemaRef(document),
Expand Down
137 changes: 137 additions & 0 deletions packages/config/src/io.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,4 +675,141 @@ major_version = 16
expect(schemaString).toContain("env");
expect(schemaString).not.toContain("versions");
});

test("resolves env() on numeric port fields (CLI-1489)", async () => {
const cwd = makeTempProject();

try {
await mkdir(join(cwd, "supabase"), { recursive: true });
await writeFile(
join(cwd, "supabase", "config.toml"),
`project_id = "ref_123"

[api]
port = "env(SUPABASE_API_PORT)"

[db]
port = "env(SUPABASE_DB_PORT)"

[analytics]
port = "env(SUPABASE_ANALYTICS_PORT)"
`,
);
await writeFile(
join(cwd, "supabase", ".env"),
"SUPABASE_API_PORT=54321\nSUPABASE_DB_PORT=54322\nSUPABASE_ANALYTICS_PORT=54327\n",
);

const loaded = await runConfigEffect(loadProjectConfig(cwd));

expect(loaded).not.toBeNull();
expect(loaded!.config.api.port).toBe(54321);
expect(loaded!.config.db.port).toBe(54322);
expect(loaded!.config.analytics.port).toBe(54327);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

test("resolves env() on boolean fields", async () => {
const cwd = makeTempProject();

try {
await mkdir(join(cwd, "supabase"), { recursive: true });
await writeFile(
join(cwd, "supabase", "config.toml"),
`project_id = "ref_123"

[analytics]
enabled = "env(SUPABASE_ANALYTICS_ENABLED)"
`,
);
await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_ANALYTICS_ENABLED=false\n");

const loaded = await runConfigEffect(loadProjectConfig(cwd));
expect(loaded!.config.analytics.enabled).toBe(false);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

test("preserves env() literals on string fields when the var is unset (Go parity)", async () => {
const cwd = makeTempProject();

try {
await mkdir(join(cwd, "supabase"), { recursive: true });
await writeFile(
join(cwd, "supabase", "config.toml"),
`project_id = "ref_123"

[auth]
jwt_secret = "env(MISSING_SECRET)"
`,
);

const loaded = await runConfigEffect(loadProjectConfig(cwd));
expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

test("fails to decode a numeric field when env var is unset", async () => {
const cwd = makeTempProject();

try {
await mkdir(join(cwd, "supabase"), { recursive: true });
await writeFile(
join(cwd, "supabase", "config.toml"),
`project_id = "ref_123"

[analytics]
port = "env(MISSING_PORT)"
`,
);

const exit = await Effect.runPromiseExit(
loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)),
);

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const failure = Cause.findErrorOption(exit.cause);
expect(Option.isSome(failure)).toBe(true);
if (Option.isSome(failure)) {
expect((failure.value as { _tag: string })._tag).toBe("ProjectConfigParseError");
}
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});

test("falls back to ambient process.env when .env is missing", async () => {
const cwd = makeTempProject();
const previous = process.env.SUPABASE_DB_PORT_TEST;
process.env.SUPABASE_DB_PORT_TEST = "55555";

try {
await mkdir(join(cwd, "supabase"), { recursive: true });
await writeFile(
join(cwd, "supabase", "config.toml"),
`project_id = "ref_123"

[db]
port = "env(SUPABASE_DB_PORT_TEST)"
`,
);

const loaded = await runConfigEffect(loadProjectConfig(cwd));
expect(loaded!.config.db.port).toBe(55555);
} finally {
if (previous === undefined) {
delete process.env.SUPABASE_DB_PORT_TEST;
} else {
process.env.SUPABASE_DB_PORT_TEST = previous;
}
await rm(cwd, { recursive: true, force: true });
}
});
});
Loading
Loading