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
10 changes: 9 additions & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@ Every applicable command must preserve these invariants:
[E2E harness](../../packages/cli-test-helpers/src/harness.ts) and
[live fixture](tests/helpers/live.ts) depend on file-path mode.
5. Sibling layers in `Layer.mergeAll` each receive required services explicitly. Production layer
changes require a CLI build and a targeted binary smoke test.
changes require a CLI build and a targeted binary smoke test. Command layer wiring is enforced
by `AllowedRunCliServices` in [src/shared/cli/run.ts](src/shared/cli/run.ts) through the
`CliRootCommand` annotation in [src/cli/root.ts](src/cli/root.ts): a service a command
requires but does not provide fails `tsc` at that annotation. Never widen
`AllowedRunCliServices` to silence that error; provide the layer in the command.
Every production effect graph retaining `promptYesNo`'s `Stdin` requirement provides
`stdinLayer`, even when runtime guards avoid its non-TTY branch. Paths that can enter that
branch require a CLI build and a targeted piped-input binary test; handler-only tests do not
prove this wiring.
6. Honor both output flags. `-o`/`--output` takes precedence over
`--output-format`; a new command may reject `--output` with guidance to use
`--output-format`, as established by `config diff` and `config pull`.
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/commands/config/pull/pull.command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Option } from "effect";
import { Layer, Option } from "effect";
import type * as CliCommand from "effect/unstable/cli/Command";
import { Command, Flag } from "effect/unstable/cli";

import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts";
import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";
import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts";
import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts";
import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts";
import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts";
Expand Down Expand Up @@ -75,5 +76,7 @@ export const configPullCommand = Command.make("pull", config).pipe(
},
]),
Command.withHandler(configPullHandler),
Command.provide(managementApiRuntimeLayer(["config", "pull"])),
// `stdinLayer`: the apply confirmation reads piped stdin via `promptYesNo`
// on a non-TTY stdin.
Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "pull"]), stdinLayer)),
Comment thread
7ttp marked this conversation as resolved.
Comment thread
7ttp marked this conversation as resolved.
);
60 changes: 59 additions & 1 deletion apps/cli/src/commands/config/pull/pull.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";

import { runSupabase } from "../../../../tests/helpers/cli.ts";
import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts";

// A fake-but-well-formed token bypasses the eager SUPABASE_ACCESS_TOKEN check, so the run
// reaches this command's own handler instead of failing generically first.
const TEST_TOKEN = "sbp_" + "a".repeat(40);
const TEST_REF = "abcdefghijklmnopqrst";

describe("config pull CLI surface", () => {
test("plain `config pull` parses — no boolean flag is accidentally required", async () => {
Expand All @@ -33,4 +35,60 @@ describe("config pull CLI surface", () => {
await rm(cwd, { recursive: true, force: true });
}
});

test("reads a piped confirmation through the production command layer", async () => {
Comment thread
7ttp marked this conversation as resolved.
const cwd = await mkdtemp(join(tmpdir(), "supabase-config-pull-piped-e2e-"));
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === `/v2/projects/${TEST_REF}/config`) {
return Response.json(v2ProjectConfigResponse({ ref: TEST_REF }));
}
return new Response("not found", { status: 404 });
},
});
try {
const configDir = join(cwd, "supabase");
const configPath = join(configDir, "config.toml");
const profilePath = join(cwd, "profile.yaml");
const before = `project_id = "${TEST_REF}"\n[api]\nmax_rows = 500\n`;
await mkdir(configDir, { recursive: true });
await writeFile(configPath, before);
await writeFile(
profilePath,
[
"name: config-pull-piped-e2e",
`api_url: ${JSON.stringify(server.url.origin)}`,
`dashboard_url: ${JSON.stringify(server.url.origin)}`,
'project_host: "example.invalid"',
"",
].join("\n"),
);

const { exitCode, stdout, stderr } = await runSupabase(
["config", "pull", "--project-ref", TEST_REF],
{
cwd,
stdin: "n\n",
env: {
SUPABASE_ACCESS_TOKEN: TEST_TOKEN,
SUPABASE_PROFILE: profilePath,
SUPABASE_WORKDIR: cwd,
},
},
);

expect(exitCode, `${stdout}\n${stderr}`).toBe(0);
expect(stderr).toContain(
`Apply 1 change(s) to ${join("supabase", "config.toml")}? [Y/n] n\n`,
);
expect(stdout).toContain("not written (declined)");
expect(await readFile(configPath, "utf8")).toBe(before);
} finally {
await server.stop(true);
await rm(cwd, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { 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 { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts";
import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts";
import { stackDestroy } from "./destroy.handler.ts";

Expand Down Expand Up @@ -31,4 +32,8 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe(
Command.withHandler((flags) =>
stackDestroy(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling),
),
// `stdinLayer` satisfies `promptYesNo`'s `Stdin` requirement. destroy either rejects a
// non-TTY run up front or short-circuits the prompt via `--yes`, so the layer is here for
// the effect's type requirements only.
Command.provide(stdinLayer),
);
7 changes: 6 additions & 1 deletion apps/cli/src/commands/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ One piece of the old Go CLI's `start` remains explicitly **out of scope**:
1. **Linked-project version-check suggestion** — a best-effort Management API call, made
only when a project happens to be linked _and_ the user is logged in, purely to print
an "update available" hint. Omitted entirely — this port has zero Management API
dependency for `start`, by design.
dependency for `start`, by design. The runtime layer does compose the lazy
Management-API factory — a static requirement of the shared storage-credentials
resolver whose hosted branch `start` never reaches — so building the layer loads the
credential subsystem: the keyring module import (skipped under `SUPABASE_NO_KEYRING=1`
and on WSL) and the WSL probe's read of `/proc/sys/kernel/osrelease`. No access token
is read or validated, and no Management API call is ever made.

### Fresh-volume DB setup (`startSetupLocalDatabase`)

Expand Down
32 changes: 28 additions & 4 deletions apps/cli/src/commands/start/start.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import { Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";

import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts";
import { stdinLayer } from "../../shared/runtime/stdin.layer.ts";
import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts";
import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts";
import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts";
import { httpClientLayer } from "../../auth/http-debug.layer.ts";
import { commandSettingsLayer } from "../../config/command-settings.layer.ts";
import { dbConnectionLayer } from "../../command-internal/db-connection.layer.ts";
import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts";
import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts";
import { identityStitchLayer } from "../../command-internal/identity-stitch.ts";
import { stringSliceFlag } from "../../command-internal/string-slice-flag.ts";
import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts";
import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts";
Expand Down Expand Up @@ -40,12 +44,28 @@ const config = {

export type StartFlags = CliCommand.Command.Config.Infer<typeof config>;

// `start` talks directly to Docker with no Management API calls, so it composes its own runtime
// instead of `managementApiRuntimeLayer`. `httpClientLayer` is included explicitly because the
// root runtime doesn't supply `HttpClient.HttpClient`; `dockerRunLayer`/`dbConnectionLayer` back
// the fresh-volume database setup (one-shot migrate jobs plus direct-connection schema SQL).
// `start` talks directly to Docker and makes no Management API calls, so it composes its own
// runtime instead of `managementApiRuntimeLayer`. `httpClientLayer` is included explicitly
// because the root runtime doesn't supply `HttpClient.HttpClient`;
// `dockerRunLayer`/`dbConnectionLayer` back the fresh-volume database setup (one-shot migrate
// jobs plus direct-connection schema SQL).
const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer));
const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer));
const credentials = commandCredentialsLayer.pipe(
Layer.provide(cliSettings),
Layer.provide(debugLoggerLayer),
);

// Exposed because the shared `resolveStorageCredentials` statically requires the (lazy)
// Management-API factory for its hosted branch, even though `start` always passes
// `projectRef: ""` and never reaches it. Mirrors `db reset`'s wiring; laziness keeps the
// local path from ever resolving an access token.
const platformApiFactory = commandPlatformApiFactoryLayer.pipe(
Layer.provide(credentials),
Layer.provide(cliSettings),
Layer.provide(debugLoggerLayer),
Layer.provide(identityStitchLayer),
);

const startRuntimeLayer = Layer.mergeAll(
cliSettings,
Expand All @@ -54,6 +74,10 @@ const startRuntimeLayer = Layer.mergeAll(
dockerRunLayer,
dbConnectionLayer,
httpClient,
platformApiFactory,
// `stdinLayer` satisfies `promptYesNo`'s `Stdin` requirement (seed-buckets runs with
// `yes: true`), so `start` never reads a piped line at runtime — type requirements only.
stdinLayer,
);

export const startCommand = Command.make("start", config).pipe(
Expand Down
10 changes: 5 additions & 5 deletions apps/cli/src/shared/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { Credentials } from "../auth/credentials.service.ts";
import type { CliProjectHome } from "../config/cli-project-home.service.ts";
import type { CliSettings } from "../config/cli-settings.service.ts";
import type { ProjectLinkState } from "../config/project-link-state.service.ts";
import type { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts";
import { jsonCliOutputFormatter } from "../output/json-formatter.ts";
import { textCliOutputFormatter } from "../output/text-formatter.ts";
import { outputLayerFor } from "../output/output.layer.ts";
Expand All @@ -44,7 +43,6 @@ import { ttyLayer } from "../runtime/tty.layer.ts";
import { CommandRuntime } from "../runtime/command-runtime.service.ts";
import { ProcessControl } from "../runtime/process-control.service.ts";
import type { RuntimeInfo } from "../runtime/runtime-info.service.ts";
import type { Stdin } from "../runtime/stdin.service.ts";
import type { Tty } from "../runtime/tty.service.ts";
import type { Analytics } from "../telemetry/analytics.service.ts";
import { aiToolLayer } from "../telemetry/ai-tool.layer.ts";
Expand All @@ -70,7 +68,11 @@ import {
/**
* Services available before evaluating the root command. Keep this list explicit: preserving the
* root command's requirement channel here makes an accidentally unprovided service fail at the
* shell boundary instead of becoming a runtime missing-service defect.
* shell boundary instead of becoming a runtime missing-service defect. Every entry must be
* genuinely satisfied at the root: by `cliProgramFor`'s provide chain (including
* `fallbackCommandLayer`'s root `CommandRuntime` placeholder) or, for the `GlobalFlag`
* identifiers, by the CLI parser itself. Never widen this union to silence a leaked command
* requirement; provide the layer in the command instead.
*/
export type AllowedRunCliServices =
| Analytics
Expand All @@ -89,8 +91,6 @@ export type AllowedRunCliServices =
| Stdio.Stdio
| TelemetryRuntime
| Tty
| CommandPlatformApiFactory
| Stdin
| "effect/unstable/cli/GlobalFlag/linked"
| "effect/unstable/cli/GlobalFlag/local";

Expand Down