diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 647b806357..6d5f42ccbd 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -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`. diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index cf74207183..bbdd2882bb 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -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"; @@ -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)), ); diff --git a/apps/cli/src/commands/config/pull/pull.e2e.test.ts b/apps/cli/src/commands/config/pull/pull.e2e.test.ts index 9ff46b0bc1..9a440ccdfe 100644 --- a/apps/cli/src/commands/config/pull/pull.e2e.test.ts +++ b/apps/cli/src/commands/config/pull/pull.e2e.test.ts @@ -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 () => { @@ -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 () => { + 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 }); + } + }); }); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 5b6c44198d..65f186c126 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -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"; @@ -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), ); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index e182f81a3f..13d662ed23 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -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`) diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index a82506b225..a45e05076f 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -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"; @@ -40,12 +44,28 @@ const config = { export type StartFlags = CliCommand.Command.Config.Infer; -// `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, @@ -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( diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index b55314207e..d4823afbfd 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -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"; @@ -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"; @@ -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 @@ -89,8 +91,6 @@ export type AllowedRunCliServices = | Stdio.Stdio | TelemetryRuntime | Tty - | CommandPlatformApiFactory - | Stdin | "effect/unstable/cli/GlobalFlag/linked" | "effect/unstable/cli/GlobalFlag/local";