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
8 changes: 4 additions & 4 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ These commands exist in the TS CLI today but have no direct top-level equivalent

## Quick Start

| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes |
| ----------- | --------- | ---------------------------- | -------------------- | --------------------- | ------------------------------------------- |
| `bootstrap` | `missing` | `missing` | `n/a` | `n/a` | No TS command yet. Wrapped in legacy shell. |
| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes |
| ----------- | --------- | ---------------------------- | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bootstrap` | `missing` | `missing` | `n/a` | `n/a` | No `next/` command yet. Ported to native TS in the legacy shell; the migration-push sub-step is delegated to the Go binary as a documented interim until `db push` is natively ported. |

## Project / Stack Lifecycle

Expand Down Expand Up @@ -265,7 +265,7 @@ Legend:
| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) |
| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) |
| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) |
| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) |
| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) |
| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) |
| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) |
| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) |
Expand Down
136 changes: 92 additions & 44 deletions apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion apps/cli/src/legacy/commands/bootstrap/bootstrap.command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Argument, Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";

import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";
import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts";
import { legacyBootstrapRuntimeLayer } from "./bootstrap.layers.ts";
import { legacyBootstrap } from "./bootstrap.handler.ts";

const config = {
Expand All @@ -19,5 +23,9 @@ export type LegacyBootstrapFlags = CliCommand.Command.Config.Infer<typeof config
export const legacyBootstrapCommand = Command.make("bootstrap", config).pipe(
Command.withDescription("Bootstrap a Supabase project from a starter template."),
Command.withShortDescription("Bootstrap a Supabase project from a starter template"),
Command.withHandler((flags) => legacyBootstrap(flags)),
Command.withHandler((flags) =>
// Go marks no bootstrap flag `markFlagTelemetrySafe`, so no `safeFlags`.
legacyBootstrap(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling),
),
Command.provide(legacyBootstrapRuntimeLayer),
);
174 changes: 174 additions & 0 deletions apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { ApiKeyResponse } from "@supabase/api/effect";

import { apiKeysToEnv } from "../../shared/legacy-api-keys.format.ts";
import { type LegacyDbConfig, toPostgresUrl } from "./bootstrap.pgconfig.ts";

type ApiKey = typeof ApiKeyResponse.Type;

// Env-var keys bootstrap writes / derives. Mirrors the constants in
// `apps/cli-go/internal/bootstrap/bootstrap.go:131-150`.
const SUPABASE_SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY";
const SUPABASE_ANON_KEY = "SUPABASE_ANON_KEY";
const SUPABASE_URL = "SUPABASE_URL";
const POSTGRES_URL = "POSTGRES_URL";
// Derived keys (only populated when present in .env.example).
const POSTGRES_PRISMA_URL = "POSTGRES_PRISMA_URL";
const POSTGRES_URL_NON_POOLING = "POSTGRES_URL_NON_POOLING";
const POSTGRES_USER = "POSTGRES_USER";
const POSTGRES_HOST = "POSTGRES_HOST";
const POSTGRES_PASSWORD = "POSTGRES_PASSWORD";
const POSTGRES_DATABASE = "POSTGRES_DATABASE";
const NEXT_PUBLIC_SUPABASE_ANON_KEY = "NEXT_PUBLIC_SUPABASE_ANON_KEY";
const NEXT_PUBLIC_SUPABASE_URL = "NEXT_PUBLIC_SUPABASE_URL";
const EXPO_PUBLIC_SUPABASE_ANON_KEY = "EXPO_PUBLIC_SUPABASE_ANON_KEY";
const EXPO_PUBLIC_SUPABASE_URL = "EXPO_PUBLIC_SUPABASE_URL";

/**
* Reproduces Go's `writeDotEnv` env-map construction (`bootstrap.go:166-243`).
*
* Seeds the api-key env vars (`SUPABASE_<NAME>_KEY`), the project `SUPABASE_URL`,
* and the pooled `POSTGRES_URL` (transaction mode, port 6543). When a `.env.example`
* map is supplied, each of its keys is merged via Go's switch: the four seeded keys
* are preserved, the derived `POSTGRES_*` / `NEXT_PUBLIC_*` / `EXPO_PUBLIC_*` keys are
* computed from the db config + seeded values, and any other key copies its example
* value verbatim.
*/
export function buildDotEnv(
keys: ReadonlyArray<ApiKey>,
config: LegacyDbConfig,
supabaseUrl: string,
example: Readonly<Record<string, string>> | undefined,
): Record<string, string> {
const initial = apiKeysToEnv(keys);
initial[SUPABASE_URL] = supabaseUrl;
initial[POSTGRES_URL] = toPostgresUrl({ ...config, port: 6543 });

if (example === undefined) {
return initial;
}

for (const [key, value] of Object.entries(example)) {
switch (key) {
// Seeded keys win over any example value.
case SUPABASE_SERVICE_ROLE_KEY:
case SUPABASE_ANON_KEY:
case SUPABASE_URL:
case POSTGRES_URL:
break;
case POSTGRES_PRISMA_URL:
initial[key] = initial[POSTGRES_URL] ?? "";
break;
case POSTGRES_URL_NON_POOLING:
initial[key] = toPostgresUrl(config);
break;
case POSTGRES_USER:
initial[key] = config.user;
break;
case POSTGRES_HOST:
initial[key] = config.host;
break;
case POSTGRES_PASSWORD:
initial[key] = config.password;
break;
case POSTGRES_DATABASE:
initial[key] = config.database;
break;
case NEXT_PUBLIC_SUPABASE_ANON_KEY:
case EXPO_PUBLIC_SUPABASE_ANON_KEY:
initial[key] = initial[SUPABASE_ANON_KEY] ?? "";
break;
case NEXT_PUBLIC_SUPABASE_URL:
case EXPO_PUBLIC_SUPABASE_URL:
initial[key] = initial[SUPABASE_URL] ?? "";
break;
default:
initial[key] = value;
}
}
return initial;
}

// godotenv's `doubleQuoteSpecialChars` (`joho/godotenv/godotenv.go`): backslash,
// newline, carriage return, double-quote, `!`, `$`, backtick.
const DOUBLE_QUOTE_SPECIAL = ["\\", "\n", "\r", '"', "!", "$", "`"] as const;

function doubleQuoteEscape(line: string): string {
let out = line;
for (const char of DOUBLE_QUOTE_SPECIAL) {
const replacement = char === "\n" ? "\\n" : char === "\r" ? "\\r" : `\\${char}`;
out = out.replaceAll(char, replacement);
}
return out;
}

// strconv.Atoi surface: optional sign + base-10 digits, parsed within int range.
const INTEGER_PATTERN = /^[+-]?\d+$/;

/**
* Reproduces `godotenv.Marshal`: each entry renders as `KEY=<int>` when the value
* parses as an integer (Go's `strconv.Atoi` + `%d`), otherwise `KEY="<escaped>"`.
* Lines are sorted lexicographically (Go sorts the rendered lines, which orders
* by key) and joined with `\n` (no trailing newline).
*/
export function marshalDotEnv(env: Readonly<Record<string, string>>): string {
const lines: Array<string> = [];
for (const [key, value] of Object.entries(env)) {
if (INTEGER_PATTERN.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) {
lines.push(`${key}=${parsed}`);
continue;
}
}
lines.push(`${key}="${doubleQuoteEscape(value)}"`);
}
lines.sort();
return lines.join("\n");
}

// godotenv.Parse-compatible enough for `.env.example`: `KEY=VALUE` / `KEY="VALUE"`
// lines, `#` comments, blank lines. A line with an empty / invalid variable name
// throws (Go's `godotenv.Parse` surfaces `unexpected character ... in variable name`).
const EXPORT_PREFIX = /^\s*export\s+/;

/**
* Minimal godotenv parser for `.env.example`. Returns the parsed key/value map.
* Throws an `Error` whose message mirrors Go's parser for a malformed variable
* name so the caller can surface the same failure (`"!="` → unexpected character).
*/
export function parseDotEnv(contents: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of contents.split("\n")) {
const line = rawLine.replace(EXPORT_PREFIX, "").trim();
if (line.length === 0 || line.startsWith("#")) {
continue;
}
const eq = line.indexOf("=");
if (eq <= 0) {
const offending = line.slice(0, eq < 0 ? line.length : eq + 1);
throw new Error(
`unexpected character "${line[0] ?? ""}" in variable name near "${offending}"`,
);
}
const key = line.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(key)) {
throw new Error(`unexpected character "${key[0] ?? ""}" in variable name near "${line}"`);
}
let value = line.slice(eq + 1).trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
// godotenv expands escapes inside double-quoted values: `\n` / `\r` become
// real newlines, and a backslash before any other char (except `$`) is
// dropped (`\"` -> `"`, `\\` -> `\`).
value = value
.slice(1, -1)
.replaceAll("\\n", "\n")
.replaceAll("\\r", "\r")
.replace(/\\([^$])/g, "$1");
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
// Single-quoted values are taken literally (no escape expansion).
value = value.slice(1, -1);
}
result[key] = value;
}
return result;
}
121 changes: 121 additions & 0 deletions apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { ApiKeyResponse } from "@supabase/api/effect";
import { describe, expect, it } from "vitest";

import { buildDotEnv, marshalDotEnv, parseDotEnv } from "./bootstrap.dotenv.ts";
import type { LegacyDbConfig } from "./bootstrap.pgconfig.ts";

type ApiKey = typeof ApiKeyResponse.Type;

// Mirrors Go's `bootstrap_test.go::TestWriteEnv` fixtures.
const API_KEYS: ReadonlyArray<ApiKey> = [
{ name: "anon", api_key: "anonkey" },
{ name: "service_role", api_key: "servicekey" },
];

const DB_CONFIG: LegacyDbConfig = {
host: "db.supabase.co",
port: 5432,
user: "admin",
password: "password",
database: "postgres",
};

const SUPABASE_URL = "https://testing.supabase.co";

describe("buildDotEnv + marshalDotEnv", () => {
it("writes the api keys, project URL and pooled POSTGRES_URL", () => {
const env = buildDotEnv(API_KEYS, DB_CONFIG, SUPABASE_URL, undefined);
expect(marshalDotEnv(env)).toBe(
`POSTGRES_URL="postgresql://admin:password@db.supabase.co:6543/postgres?connect_timeout=10"
SUPABASE_ANON_KEY="anonkey"
SUPABASE_SERVICE_ROLE_KEY="servicekey"
SUPABASE_URL="https://testing.supabase.co"`,
);
});

it("merges the derived keys from a .env.example (every switch branch)", () => {
const example: Record<string, string> = {
POSTGRES_PRISMA_URL: "example",
POSTGRES_URL_NON_POOLING: "example",
POSTGRES_USER: "example",
POSTGRES_HOST: "example",
POSTGRES_PASSWORD: "example",
POSTGRES_DATABASE: "example",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "example",
NEXT_PUBLIC_SUPABASE_URL: "example",
no_match: "example",
SUPABASE_SERVICE_ROLE_KEY: "example",
SUPABASE_ANON_KEY: "example",
SUPABASE_URL: "example",
POSTGRES_URL: "example",
};
const env = buildDotEnv(API_KEYS, DB_CONFIG, SUPABASE_URL, example);
expect(marshalDotEnv(env)).toBe(
`NEXT_PUBLIC_SUPABASE_ANON_KEY="anonkey"
NEXT_PUBLIC_SUPABASE_URL="https://testing.supabase.co"
POSTGRES_DATABASE="postgres"
POSTGRES_HOST="db.supabase.co"
POSTGRES_PASSWORD="password"
POSTGRES_PRISMA_URL="postgresql://admin:password@db.supabase.co:6543/postgres?connect_timeout=10"
POSTGRES_URL="postgresql://admin:password@db.supabase.co:6543/postgres?connect_timeout=10"
POSTGRES_URL_NON_POOLING="postgresql://admin:password@db.supabase.co:5432/postgres?connect_timeout=10"
POSTGRES_USER="admin"
SUPABASE_ANON_KEY="anonkey"
SUPABASE_SERVICE_ROLE_KEY="servicekey"
SUPABASE_URL="https://testing.supabase.co"
no_match="example"`,
);
});

it("mirrors the EXPO_PUBLIC_* keys to the anon key and project URL", () => {
const env = buildDotEnv(API_KEYS, DB_CONFIG, SUPABASE_URL, {
EXPO_PUBLIC_SUPABASE_ANON_KEY: "example",
EXPO_PUBLIC_SUPABASE_URL: "example",
});
expect(env["EXPO_PUBLIC_SUPABASE_ANON_KEY"]).toBe("anonkey");
expect(env["EXPO_PUBLIC_SUPABASE_URL"]).toBe(SUPABASE_URL);
});

it("masks a nullable-null api key as ******", () => {
const env = buildDotEnv(
[{ name: "service_role", api_key: null }],
DB_CONFIG,
SUPABASE_URL,
undefined,
);
expect(env["SUPABASE_SERVICE_ROLE_KEY"]).toBe("******");
});
});

describe("marshalDotEnv", () => {
it("emits integer-valued entries unquoted and escapes special characters", () => {
expect(marshalDotEnv({ COUNT: "42", PATH: 'a"b\\c', NOTE: "hi!" })).toBe(
`COUNT=42\nNOTE="hi\\!"\nPATH="a\\"b\\\\c"`,
);
});
});

describe("parseDotEnv", () => {
it("parses KEY=VALUE lines, skipping comments and blanks, and strips quotes", () => {
expect(parseDotEnv('# comment\nFOO=bar\n\nBAZ="quoted"\nexport QUX=1')).toEqual({
FOO: "bar",
BAZ: "quoted",
QUX: "1",
});
});

it("expands escape sequences in double-quoted values (godotenv parity)", () => {
expect(parseDotEnv('A="line1\\nline2"\nB="a\\"b\\\\c"')).toEqual({
A: "line1\nline2",
B: 'a"b\\c',
});
});

it("takes single-quoted values literally (no escape expansion)", () => {
expect(parseDotEnv("A='line1\\nline2'")).toEqual({ A: "line1\\nline2" });
});

it("throws Go's 'unexpected character' error on a malformed variable name", () => {
expect(() => parseDotEnv("!=")).toThrow(/unexpected character "!" in variable name/);
});
});
54 changes: 54 additions & 0 deletions apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Data } from "effect";

// ---------------------------------------------------------------------------
// Bootstrap-specific tagged errors. Each maps to a Go `errors.New` / failure
// site in `apps/cli-go/cmd/bootstrap.go` + `internal/bootstrap/bootstrap.go`.
// Login / create / api-keys / link failures are surfaced by the extracted
// shared cores (`legacy/shared/legacy-*`), so they are NOT redefined here.
// ---------------------------------------------------------------------------

/** Positional template arg with no case-insensitive match — Go's `"Invalid template: " + name` (`cmd/bootstrap.go:48`). */
export class LegacyBootstrapInvalidTemplateError extends Data.TaggedError(
"LegacyBootstrapInvalidTemplateError",
)<{
readonly message: string;
}> {}

/** GitHub samples listing failure — Go's `failed to list samples` (`bootstrap.go:ListSamples`). */
export class LegacyBootstrapTemplateListError extends Data.TaggedError(
"LegacyBootstrapTemplateListError",
)<{
readonly message: string;
}> {}

/** Reading the target workdir failed — Go's `failed to read workdir: %w` (`bootstrap.go:44`). */
export class LegacyBootstrapWorkdirReadError extends Data.TaggedError(
"LegacyBootstrapWorkdirReadError",
)<{
readonly message: string;
}> {}

/**
* User declined the overwrite prompt — Go returns `errors.New(context.Canceled)`
* (`bootstrap.go:51`). Carries no suggestion frame (cancellation, not a fault).
*/
export class LegacyBootstrapOverwriteDeclinedError extends Data.TaggedError(
"LegacyBootstrapOverwriteDeclinedError",
)<{
readonly message: string;
}> {}

/** Template download failure — Go's `failed to download template: %w` (`bootstrap.go:downloadSample`). */
export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError(
"LegacyBootstrapTemplateDownloadError",
)<{
readonly message: string;
}> {}

/**
* Project health probe failed — Go's `Error status %d: %s` (non-200) or
* `Service not healthy: %s (%s)` (`bootstrap.go:checkProjectHealth`).
*/
export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstrapHealthError")<{
readonly message: string;
}> {}
Loading
Loading