Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@
[submodule ".repos/t3code"]
path = .repos/t3code
url = https://github.com/pingdotgg/t3code.git
[submodule ".repos/opencode"]
path = .repos/opencode
url = https://github.com/anomalyco/opencode.git
1 change: 1 addition & 0 deletions .repos/opencode
Submodule opencode added at 907281
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Use `nx show project <name> --json` to discover available targets before running
## Pull Requests

PR titles must follow conventional-commits format because the `Lint Pull Request` workflow runs `amannn/action-semantic-pull-request` against the title. Use `<type>(<scope>): <subject>` (e.g. `fix(cli): …`, `test(cli): …`, `feat(api): …`). A bare descriptive title like "Build TypeScript CLI as compiled Bun binaries" will fail the lint. When a PR is created (including by the Claude Code UI or someone else), check the title against this rule and update it if needed.
Do not include a validation, test plan, or list of checks in PR descriptions. CI enforces validation for PRs, so PR descriptions should focus on what changed, why it changed, and any reviewer-relevant context that CI cannot infer.

## Refactoring Policy

Expand Down
9 changes: 9 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@effect/vitest": "catalog:",
"@supabase/api": "workspace:*",
"@supabase/config": "workspace:*",
"@supabase/process-compose": "workspace:*",
"@supabase/stack": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
Expand All @@ -67,6 +68,14 @@
"vitest": "catalog:"
},
"optionalDependencies": {
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6",
"@supabase/cli-darwin-arm64": "workspace:*",
"@supabase/cli-darwin-x64": "workspace:*",
"@supabase/cli-linux-arm64": "workspace:*",
Expand Down
13 changes: 11 additions & 2 deletions apps/cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,22 @@ const GO_TARGETS: Record<BunTarget, { goos: string; goarch: string }> = {
"bun-windows-arm64": { goos: "windows", goarch: "arm64" },
};

function libcForBunTarget(target: string): "glibc" | "musl" | "" {
if (!target.startsWith("bun-linux-")) {
return "";
}
return target.endsWith("-musl") ? "musl" : "glibc";
}

async function buildTarget(target: (typeof TARGETS)[number]) {
const binDir = path.join(root, "packages", target.pkg, "bin");
await mkdir(binDir, { recursive: true });

const outfile = path.join(binDir, `supabase${target.ext}`);
const libc = libcForBunTarget(target.bunTarget);

console.log(`[${target.pkg}] Compiling Bun CLI...`);
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --outfile=${outfile}`;
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${outfile}`;
console.log(`[${target.pkg}] Done.`);
}

Expand Down Expand Up @@ -150,8 +158,9 @@ async function buildMuslBinaries() {
await mkdir(binDir, { recursive: true });

const outfile = path.join(binDir, "supabase");
const libc = libcForBunTarget(target.bunTarget);
console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`);
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --outfile=${outfile}`;
await $`bun build ${entrypoint} --compile --minify --target=${target.bunTarget} --define=process.env.SUPABASE_CLI_VERSION=${JSON.stringify(version)} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${outfile}`;

if (shell === "legacy") {
// Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 60_000;
const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000;
const FUNCTIONS_DEV_TEST_TIMEOUT_MS = 75_000;
const FUNCTIONS_DEV_TEST_TIMEOUT_MS = 90_000;

async function waitForFunctionResponse(
url: string,
Expand Down Expand Up @@ -65,6 +65,7 @@ describe("supabase functions dev", () => {
/Edge Functions dev server is running\./,
FUNCTIONS_DEV_STARTUP_TIMEOUT_MS,
);
await new Promise((resolve) => setTimeout(resolve, 500));

const newResult = await runSupabase(["functions", "new", "hello-world"], {
cwd: project.dir,
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/logs/logs.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
spawnSupabase,
} from "../../../../tests/helpers/cli.ts";

const LOGS_TIMEOUT_MS = 15_000;
const LOGS_TIMEOUT_MS = 30_000;
const LOGS_IDLE_WINDOW_MS = 500;
const LIGHTWEIGHT_START_ARGS = [
"start",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/start/start.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts";

const DETACHED_START_TIMEOUT_MS = 15_000;
const DETACHED_START_TIMEOUT_MS = 30_000;
const LIGHTWEIGHT_START_ARGS = [
"start",
"--detach",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/status/status.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts";

const STATUS_TIMEOUT_MS = 15_000;
const STATUS_TIMEOUT_MS = 30_000;
const LIGHTWEIGHT_START_ARGS = [
"start",
"--detach",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/next/commands/stop/stop.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const LIGHTWEIGHT_START_ARGS = [
"--exclude",
"pooler",
] as const;
const STOP_STACK_TIMEOUT_MS = 15_000;
const STOP_STACK_TIMEOUT_MS = 30_000;

describe("supabase stop", () => {
test(
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/next/main.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,17 @@
#!/usr/bin/env bun
import "./cli/main.ts";
import {
enableSupervisorSelfDispatchForCompiledBun,
isSupervisorRuntimeRequested,
runSupervisorRuntimeFromEnv,
} from "@supabase/process-compose";

enableSupervisorSelfDispatchForCompiledBun(import.meta.url);

if (isSupervisorRuntimeRequested()) {
runSupervisorRuntimeFromEnv();
} else if (process.env.SUPABASE_STACK_RUN_DAEMON === "1") {
const { runBunDaemon } = await import("@supabase/stack/daemon-bun");
runBunDaemon();
} else {
await import("./cli/main.ts");
}
57 changes: 51 additions & 6 deletions apps/cli/src/shared/runtime/parcel-file-watcher.layer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as ParcelWatcher from "@parcel/watcher";
import { Cause, Effect, Layer, Queue, Stream } from "effect";
import { createWrapper } from "@parcel/watcher/wrapper";
import type ParcelWatcher from "@parcel/watcher";

import {
FileWatcher,
Expand All @@ -8,6 +9,49 @@ import {
type FileWatchOptions,
} from "./file-watcher.service.ts";

declare const SUPABASE_LIBC: string | undefined;

function wrapBinding(binding: unknown): typeof import("@parcel/watcher") {
return createWrapper(binding);
}

function loadParcelWatcher(): typeof import("@parcel/watcher") {
if (process.platform === "darwin") {
if (process.arch === "arm64") {
return wrapBinding(require("@parcel/watcher-darwin-arm64"));
}
if (process.arch === "x64") {
return wrapBinding(require("@parcel/watcher-darwin-x64"));
}
}

if (process.platform === "linux") {
if (process.arch === "arm64") {
if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") {
return wrapBinding(require("@parcel/watcher-linux-arm64-musl"));
}
return wrapBinding(require("@parcel/watcher-linux-arm64-glibc"));
}
if (process.arch === "x64") {
if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") {
return wrapBinding(require("@parcel/watcher-linux-x64-musl"));
}
return wrapBinding(require("@parcel/watcher-linux-x64-glibc"));
}
}

if (process.platform === "win32") {
if (process.arch === "arm64") {
return wrapBinding(require("@parcel/watcher-win32-arm64"));
}
if (process.arch === "x64") {
return wrapBinding(require("@parcel/watcher-win32-x64"));
}
}

throw new Error(`Unsupported @parcel/watcher platform: ${process.platform}-${process.arch}`);
}

function toParcelOptions(options?: FileWatchOptions): ParcelWatcher.Options | undefined {
if (options?.ignore === undefined) {
return undefined;
Expand All @@ -20,11 +64,12 @@ function toParcelOptions(options?: FileWatchOptions): ParcelWatcher.Options | un
export const parcelFileWatcherLayer = Layer.sync(FileWatcher, () =>
FileWatcher.of({
watch: (path, options) =>
Stream.callback<ReadonlyArray<FileWatchEvent>, FileWatcherError>((queue) =>
Effect.acquireRelease(
Stream.callback<ReadonlyArray<FileWatchEvent>, FileWatcherError>((queue) => {
const watcher = loadParcelWatcher();
return Effect.acquireRelease(
Effect.tryPromise({
try: () =>
ParcelWatcher.subscribe(
watcher.subscribe(
path,
(error, events) => {
if (error !== null) {
Expand All @@ -44,7 +89,7 @@ export const parcelFileWatcherLayer = Layer.sync(FileWatcher, () =>
Effect.promise(() => subscription.unsubscribe()).pipe(
Effect.ignore({ log: true, message: "Failed to unsubscribe file watcher" }),
),
),
),
);
}),
}),
);
3 changes: 3 additions & 0 deletions apps/cli/src/shared/runtime/parcel-watcher-wrapper.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module "@parcel/watcher/wrapper" {
export function createWrapper(binding: unknown): typeof import("@parcel/watcher");
}
98 changes: 67 additions & 31 deletions apps/cli/tests/helpers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ const SHIM_PATH = fileURLToPath(new URL("../../dist/supabase.js", import.meta.ur
const LEGACY_BINARY_PATH = fileURLToPath(
new URL(`../../dist/supabase-legacy${BINARY_EXT}`, import.meta.url),
);
const NEXT_SOURCE_ENTRYPOINT = fileURLToPath(new URL("../../src/next/main.ts", import.meta.url));
const NEXT_BINARY_PATH = fileURLToPath(
new URL(`../../dist/supabase-next${BINARY_EXT}`, import.meta.url),
);

function assertLegacyBuildArtifactsExist(): void {
if (!existsSync(SHIM_PATH) || !existsSync(LEGACY_BINARY_PATH)) {
function assertBuildArtifactsExist(shell: "legacy" | "next", binaryPath: string): void {
if (!existsSync(SHIM_PATH) || !existsSync(binaryPath)) {
throw new Error(
`Missing legacy CLI build artifacts. Run \`pnpm --filter supabase build\` before invoking legacy e2e tests.\n` +
`Missing ${shell} CLI build artifacts. Run \`pnpm --filter supabase build\` before invoking ${shell} e2e tests.\n` +
` expected shim: ${SHIM_PATH}\n` +
` expected binary: ${LEGACY_BINARY_PATH}`,
` expected binary: ${binaryPath}`,
);
}
}
Expand Down Expand Up @@ -188,14 +190,9 @@ export function spawnSupabase(
noteStackProjectHome(options?.cwd, homeDir);
const entrypoint = options?.entrypoint ?? "next";
const usesStartWrapper = args[0] === "start";
// The `next` shell drives the local stack daemon (`start --detach`,
// `functions dev`, ...) and depends on `@parcel/watcher`'s native binding
// — neither path works end-to-end through a `bun --compile` self-contained
// binary yet, so the next-shell e2e suite continues to run against the
// source tree via `bun src/...`. The `legacy` shell has no daemon and no
// native deps, so its e2e suite exercises the real shipped artifact:
// `node dist/supabase.js` (the Node shim) with `SUPABASE_CLI_BINARY_OVERRIDE`
// pointing at the per-shell `bun build --compile` standalone executable.
// Exercise the same shim + compiled shell binary handoff that published
// packages use. `SUPABASE_CLI_BINARY_OVERRIDE` points the shim at the local
// build artifact without needing platform wrapper packages.
let execCmd: string;
let execArgs: string[];
const env: Record<string, string> = {
Expand All @@ -206,20 +203,14 @@ export function spawnSupabase(
...options?.env,
};
if (entrypoint === "legacy") {
assertLegacyBuildArtifactsExist();
assertBuildArtifactsExist("legacy", LEGACY_BINARY_PATH);
env["SUPABASE_CLI_BINARY_OVERRIDE"] = LEGACY_BINARY_PATH;
execCmd = "node";
execArgs = [SHIM_PATH, ...args];
} else {
const sourceLauncher = fileURLToPath(new URL("./source-cli-launcher.mjs", import.meta.url));
if (usesStartWrapper) {
execCmd = "node";
execArgs = [sourceLauncher, NEXT_SOURCE_ENTRYPOINT, ...args];
} else {
execCmd = "bun";
execArgs = [NEXT_SOURCE_ENTRYPOINT, ...args];
}
assertBuildArtifactsExist("next", NEXT_BINARY_PATH);
env["SUPABASE_CLI_BINARY_OVERRIDE"] = NEXT_BINARY_PATH;
}
execCmd = "node";
execArgs = [SHIM_PATH, ...args];
const proc = spawn(execCmd, execArgs, {
cwd: options?.cwd,
env,
Expand All @@ -239,6 +230,26 @@ export function spawnSupabase(

let stdout = "";
let stderr = "";
let closeResult: RunResult | undefined;
let cleanedUpProcessGroup = false;
let disposedOwnHome = false;
const closeWaiters = new Set<(result: RunResult) => void>();

const cleanupProcessGroupOnClose = () => {
if (cleanedUpProcessGroup || !(options?.cleanupProcessGroupOnClose ?? true)) {
return;
}
cleanedUpProcessGroup = true;
killProcessGroup(proc.pid!, "SIGKILL");
};

const disposeOwnHome = () => {
if (disposedOwnHome) {
return;
}
disposedOwnHome = true;
ownHome?.[Symbol.dispose]();
};

stdoutStream.on("data", (data: Buffer) => {
stdout += data.toString();
Expand All @@ -248,6 +259,14 @@ export function spawnSupabase(
stderr += data.toString();
});

proc.once("close", (code) => {
closeResult = { stdout, stderr, exitCode: code ?? 1 };
for (const waiter of closeWaiters) {
waiter(closeResult);
}
closeWaiters.clear();
});

if (options?.stdin !== undefined && proc.stdin) {
proc.stdin.write(options.stdin);
proc.stdin.end();
Expand All @@ -256,6 +275,12 @@ export function spawnSupabase(
const waitForExit = async (
timeoutMs = options?.exitTimeoutMs ?? DEFAULT_EXIT_TIMEOUT_MS,
): Promise<RunResult> => {
if (closeResult) {
cleanupProcessGroupOnClose();
disposeOwnHome();
return closeResult;
}

const result = await new Promise<RunResult>((resolve) => {
const timeout = setTimeout(() => {
killProcessGroup(proc.pid!, "SIGKILL");
Expand All @@ -265,17 +290,17 @@ export function spawnSupabase(
}, timeoutMs);
timeout.unref();

proc.on("close", (code) => {
const onClose = (result: RunResult) => {
clearTimeout(timeout);
if (options?.cleanupProcessGroupOnClose ?? true) {
killProcessGroup(proc.pid!, "SIGKILL");
}
closeWaiters.delete(onClose);
cleanupProcessGroupOnClose();
resolve(result);
};

resolve({ stdout, stderr, exitCode: code ?? 1 });
});
closeWaiters.add(onClose);
});

ownHome?.[Symbol.dispose]();
disposeOwnHome();
return result;
};

Expand All @@ -294,6 +319,17 @@ export function spawnSupabase(
if (pattern.test(stdout)) {
return;
}
if (closeResult) {
throw new Error(
[
`Process exited before output matched ${pattern}`,
`Command: supabase ${args.join(" ")}`,
`PID: ${proc.pid ?? "<unknown>"}`,
outputTail("stdout tail", stdout),
outputTail("stderr tail", stderr),
].join("\n\n"),
);
}

await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
Expand Down
Loading
Loading