diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index bfba4a5e38..d246343641 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -95,6 +95,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -114,6 +115,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -136,6 +138,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -155,6 +158,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -171,6 +175,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Deleted Function"); @@ -189,6 +194,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("does not exist"); @@ -206,6 +212,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -223,6 +230,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -240,6 +248,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -257,6 +266,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index 9ace9e4e12..72f87d279c 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -85,7 +85,7 @@ describe("secrets", () => { describe("secrets:set", () => { testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef]); + const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef, "--yes"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); }); @@ -98,6 +98,7 @@ describe("secrets", () => { "BAZ=qux", "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -112,6 +113,7 @@ describe("secrets", () => { ".env.local", "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -126,7 +128,14 @@ describe("secrets", () => { body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, }), }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); + const result = await run([ + "secrets", + "set", + "FOO=bar", + "--project-ref", + PROJECT_REF, + "--yes", + ]); expect(result.exitCode).not.toBe(0); }); @@ -138,6 +147,7 @@ describe("secrets", () => { "nonexistent.env", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -148,7 +158,14 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); + const result = await run([ + "secrets", + "set", + "FOO=bar", + "--project-ref", + PROJECT_REF, + "--yes", + ]); expect(result.exitCode).not.toBe(0); }); }); @@ -161,7 +178,7 @@ describe("secrets", () => { }); testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef]); + await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef, "--yes"]); const result = await run([ "secrets", "unset", diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index d791f8fdb3..73567581ab 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -225,6 +225,31 @@ text layer. Therefore stdout is payload-only for either machine flag. Do not cha layer to move spinners; that would change text output globally. Handlers check `--output` first and use established encoders before the `output.format` branch. +## Targets and hosted consent + +Every leaf declares its target group and consent default with `withTargetPolicy`; the +classification test in `src/command-internal/target/target-policy.unit.test.ts` fails a leaf +without one. Declare the target flags only through `targetFlags` in +`src/command-internal/target/target-flags.ts`. + +- Database, storage, and seed commands call `resolveTarget`, then `resolveConnection`. Never pass a + default target: the policy owns the default, so a call site cannot fall back to local or linked. +- A command that binds `--password` passes `hasPasswordFlag: true` to `resolveConnection`. +- The linked `Target:` banner comes from `resolveTarget` and `resolveRef`; never build a linked + `ResolvedTarget` by hand. +- Hosted writes call `requireHostedMutationConsent` (or `requireTargetMutationConsent` when the + target may be local) after the plan and before the first persistent effect, and pass the + returned `HostedMutationConsent` to anything that needs proof of it. +- Never read stdin for consent. JSON, piped, and agent runs need `--yes` or a shell + `SUPABASE_YES`; `supabase/.env` and a cwd `.env*` file do not count. Write the action as an + imperative sentence-case phrase with no trailing period (`Delete project `). +- A `sync` or `selectable` leaf with `consent: "none"` declares a `consentExemptReason`. +- Every leaf with consent has a `describeHostedConsent("", …)` test from + `tests/helpers/hosted-consent.ts`. + +See [ADR 0033](../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md) for the +target-default and consent rules. + ## Tests Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md new file mode 100644 index 0000000000..cf0e395b4a --- /dev/null +++ b/apps/cli/docs/command-target.md @@ -0,0 +1,35 @@ +# Command target + +Every command that acts on a database or hosted project announces the target it resolved, once, before it acts. The same identity goes to machine consumers as the JSON `target` object. + +## Banner + +`Target: ` on stderr in every output format, printed once per distinct text. + +| Target | Text | +| ------------------------------------- | ----------------------------------------------------------------------------------- | +| Local stack | `local stack (:)` | +| Local stack, nothing selected, linked | `local stack (:) · default; pass --linked for ()` | +| `--db-url` | `custom Postgres (:)`; the connection string's credentials never appear | +| Linked project the cache names | `project () · production` | +| Linked project nothing vouches for | `project `, or `project ()` when the project picker named it | +| Ref other than the linked project | `project (not the linked project)` | +| Branch found by name or UUID | `project · branch ()`, or `branch ` for a UUID | + +The `default; pass --linked` hint appears only when the checkout is linked and neither a selector flag nor a workspace `target` chose local. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. + +## JSON `target` + +`json` and `stream-json` results carry `target`, tagged by `kind`: + +| `kind` | Fields | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local` | `host`, `port` | +| `db-url` | `host`, `port` | +| `linked` | `project_ref`, `environment` (`production`, `branch` or `unverified`), optional `project_name`, optional `parent_project_ref` (a branch's parent project) | + +`environment` is `production` when the linked-project cache names the resolved ref, `branch` on positive evidence, and `unverified` otherwise. A failed identity check is `unverified`, never `branch`. + +The first announced target becomes the run's `target`. It is merged into object results and into the machine error envelope. Array results are written unchanged. A command that owns extra `target` fields (for example `branch`, `local_scope`, or `config push`'s probed `environment`) adds them in front of the announced identity's, and its own values win on a clash. + +The schema is `CommandTargetJson` in `src/shared/output/command-target.service.ts`; every target is encoded through it before it reaches output. diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f8ae017494..ae8b3a4395 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -47,3 +47,9 @@ settings into an ignored file. To deploy a source directory without a Compute configuration entry, `compute push` requires an explicit `--exposure public` or `--exposure private`. Record the entry in the authoritative config to persist its deployment settings. + +`compute push` and `compute delete` change the remote project, so both ask for +confirmation first, and both default to no. `compute delete` asks for the +compute's name to be typed back. Pass `--yes`, or set `SUPABASE_YES` in your +shell, to confirm in scripts and CI; without it, a run that cannot prompt fails +before changing anything. diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md index 229783224c..503d6849dd 100644 --- a/apps/cli/docs/supabase/config/diff.md +++ b/apps/cli/docs/supabase/config/diff.md @@ -12,4 +12,4 @@ Local values are shown as the configuration your file would produce once pushed, With `--exit-code`, the command exits `2` when any difference is found, keeping exit `1` for errors — so scripts can distinguish drift from failure. -Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. `target` identifies the compared project: `kind` (`linked`), `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, `branch` when it was named, and `local_scope` (`base` or `remotes.`). The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index 3e238c1d59..7cfcfdcd72 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -46,4 +46,4 @@ max_rows = 250 Running the same command again finds no differences and writes nothing. -Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `config_path`, `format`, `target`, `destination`, `dry_run`, `wrote`, `scope`, `changes[]`, `warnings[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays, each carrying `written` and, when unwritten, a `skipped_reason` (`env_reference | local_only | remote_env_reference | unwritable | would_invalidate | declined | dry_run`). A `would_invalidate` warning additionally carries `missing_fields` (`[{path, env_variable?}]`) naming what actually blocked the family. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. Note: the payload spells the root scope `"base"` (`target.local_scope`), while text output calls the same thing `config root`. +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `config_path`, `format`, `target`, `destination`, `dry_run`, `wrote`, `scope`, `changes[]`, `warnings[]`, `masked[]`, `unmanaged[]`, `counts`) — `target` identifies the pulled project the same way `config diff` does (`kind`, `project_ref`, `project_name`, `environment`, `parent_project_ref`, `branch`, `local_scope`) — with per-change `path`s as segment arrays, each carrying `written` and, when unwritten, a `skipped_reason` (`env_reference | local_only | remote_env_reference | unwritable | would_invalidate | declined | dry_run`). A `would_invalidate` warning additionally carries `missing_fields` (`[{path, env_variable?}]`) naming what actually blocked the family. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. Note: the payload spells the root scope `"base"` (`target.local_scope`), while text output calls the same thing `config root`. diff --git a/apps/cli/docs/supabase/config/push.md b/apps/cli/docs/supabase/config/push.md index 5f912a93e8..8c94c34399 100644 --- a/apps/cli/docs/supabase/config/push.md +++ b/apps/cli/docs/supabase/config/push.md @@ -6,6 +6,8 @@ This command allows you to manage project configuration as code by defining sett Pass `--project-ref` to push to a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. -Before anything is pushed, the command reports the project it resolved — its name and ref. When the resolved target is a preview branch that was only _inferred_ (for example after `supabase link `, or via `SUPABASE_PROJECT_ID`) rather than named on this invocation, it says so, names the branch and its parent project where they can be resolved, and asks for confirmation first. Declining — including on an unattended run with no `--yes` and no piped answer — fails the command rather than pushing. Pass `--yes` (or set `SUPABASE_YES`) to skip that confirmation in CI and scripted runs. +Before anything is pushed, the command prints the `Target:` line for the project or branch it resolved (name, ref, and for a branch its parent project), then shows the exact diff for every resource that will change. It then asks once — a single question covering every changed resource, with a default answer of no — and sends nothing until you confirm. If a change enables a paid add-on, its price is printed before the question. -An explicit `--project-ref ` on the same invocation skips the confirmation entirely (the target is still echoed) — naming the branch yourself already counts as confirming it. Because the confirmation reads one line of piped input just like any other prompt, a script piping multiple `y`/`n` answers to an _inferred_ branch target needs one extra leading answer for it, ahead of the per-service prompts it already answers. +A push with nothing to change never asks. Declining fails the command and writes nothing. Piped answers are never read, so a script, a JSON run (`-o json`, `--output-format json`) or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. With `--yes` the command echoes `Confirmed by --yes: …` to stderr and pushes. + +In `json` output the result carries a `target` object with the resolved identity (`kind`, `project_ref`, `project_name`, `environment`, and `parent_project_ref` for a branch) next to the push summary. diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index b434c9f26f..aa9efb02f9 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -1,3 +1,5 @@ ## supabase-db-advisors Inspects the database for common security and performance issues such as missing RLS policies, unindexed foreign keys, exposed auth.users, and more. + +With no target flag the command inspects the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index cdcae8eb42..3090bfc92d 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -2,7 +2,7 @@ Diffs schema changes made to the local or remote database. -Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. +Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. With no target flag the command diffs the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Compares schema differences between the target database and a shadow database, using the bundled pg-delta engine by default. The legacy [djrobstep/migra](https://github.com/djrobstep/migra) engine, which runs in a container, remains available as a fallback (see below). The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. diff --git a/apps/cli/docs/supabase/db/dump.md b/apps/cli/docs/supabase/db/dump.md index 3d5c4fae54..71a04af1a2 100644 --- a/apps/cli/docs/supabase/db/dump.md +++ b/apps/cli/docs/supabase/db/dump.md @@ -1,8 +1,8 @@ ## supabase-db-dump -Dumps contents from a remote database. +Dumps contents from the target database. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Dumps the local database by default, unless `SUPABASE_TARGET` or `target` in `config.toml` selects the linked project. Pass `--linked` to dump the linked project, which requires your local project to be linked by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Runs `pg_dump` in a container with additional flags to exclude Supabase managed schemas. The ignored schemas include auth, storage, and those created by extensions. diff --git a/apps/cli/docs/supabase/db/lint.md b/apps/cli/docs/supabase/db/lint.md index 5858d29113..16201908b2 100644 --- a/apps/cli/docs/supabase/db/lint.md +++ b/apps/cli/docs/supabase/db/lint.md @@ -2,7 +2,7 @@ Lints local database for schema errors. -Requires the local development stack to be running when linting against the local database. To lint against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. +Requires the local development stack to be running when linting against the local database. To lint against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. With no target flag the command lints the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Runs the `plpgsql_check` extension against the local database (native or container) to check for errors in all schemas. The default lint level is `warning` and can be raised to error via the `--level` flag. diff --git a/apps/cli/docs/supabase/db/pull.md b/apps/cli/docs/supabase/db/pull.md index 23d7bd636e..bbad95ea48 100644 --- a/apps/cli/docs/supabase/db/pull.md +++ b/apps/cli/docs/supabase/db/pull.md @@ -2,11 +2,11 @@ Pulls schema changes from a remote database. A new migration file will be created under `supabase/migrations` directory. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Pulls from the linked project by default, so your local project must be linked by running `supabase link`. Pass `--local` to pull from the local database, or `--db-url` to pull from another database such as a self-hosted one. The command prints a `Target:` line on stderr naming the database it resolved. > Note this command requires Docker Desktop (or a running Docker daemon), as it starts a local Postgres container to diff your remote schema. -Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. +After the migration file is written, a new row is inserted into the migration history table to reflect the current state of the remote database. A hosted database asks for one confirmation first (default No), after the diff and before any file is written; declining stops the command without writing anything. Non-interactive, JSON and agent runs need `--yes` (or `SUPABASE_YES` in the shell environment, not in `supabase/.env`). A pull from the local database and `--declarative` ask nothing. If no entries exist in the migration history table, pg-delta (the default diff engine) produces the full migration from the shadow diff alone; with `--diff-engine migra`, the initial pull instead uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. diff --git a/apps/cli/docs/supabase/db/push.md b/apps/cli/docs/supabase/db/push.md index fe0893f90c..19526da48e 100644 --- a/apps/cli/docs/supabase/db/push.md +++ b/apps/cli/docs/supabase/db/push.md @@ -2,7 +2,9 @@ Pushes all local migrations to a remote database. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Pushes to the linked project by default, so your local project must be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag; use `--local` to push to the local database instead. + +Before changing a remote database, the command lists what it will do (migrations, custom roles, seed files and vault secrets) and asks once for confirmation, defaulting to No. Pass `--yes` to confirm without a prompt; JSON output, piped input and agent runs need it. A `SUPABASE_YES` set in `supabase/.env` does not confirm, but one set in your shell does. The first time this command is run, a migration history table will be created under `supabase_migrations.schema_migrations`. After successfully applying a migration, a new row will be inserted into the migration history table with timestamp as its unique id. Subsequent pushes will skip migrations that have already been applied. diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 35d3bf6224..a008b5e254 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -2,6 +2,16 @@ Execute a SQL query against the local or linked database. +With no target flag the command runs against the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved. + +The command cannot tell a read from a write, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before the statement is sent. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + +Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database. Before the statement is sent or any question is asked, every statement must start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW`, `EXPLAIN` or a parenthesised query (after whitespace and comments). Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK` and so on), input that ends inside a comment, quote or dollar quote, and input containing `BEGIN ATOMIC` is rejected. Drop `--read-only` to run one of these. + +The local database or a `--db-url` database then runs the statement in a read-only transaction that is always rolled back, with a 5 second `lock_timeout` so the read cannot wait on heavy locks; Postgres rejects writes inside it. A linked project runs it through the Management API's `read_only` mode, and the API enforces it. + +`--read-only` does not stop a read statement from calling a function that has side effects, such as `pg_terminate_backend` or `dblink_exec`; those still run. + When used by an AI coding agent (auto-detected or via --agent=yes), the default output format is JSON with an untrusted data warning envelope. When used by a human (--agent=no or no agent detected), the default output format is table diff --git a/apps/cli/docs/supabase/db/reset.md b/apps/cli/docs/supabase/db/reset.md index 9a60a67711..58033d5c98 100644 --- a/apps/cli/docs/supabase/db/reset.md +++ b/apps/cli/docs/supabase/db/reset.md @@ -8,6 +8,10 @@ Recreates the local Postgres container and applies all local migrations found in Use the `--no-seed` flag to skip seeding entirely. To override `[db.seed].sql_paths` for a single reset, pass one or more `--sql-paths` flags. Each value accepts the same file path or glob pattern syntax as `sql_paths`, relative to the `supabase` directory. Passing `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. +With no target flag the command resets the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved. + When running db reset with `--linked` or `--db-url` flag, a SQL script is executed to identify and drop all user created entities in the remote database. Since Postgres roles are cluster level entities, any custom roles created through the dashboard or `supabase/roles.sql` will not be deleted by remote reset. +A remote reset asks for one confirmation (default No) after it has worked out what it will do and before anything is dropped. The question lists dropping every user schema, any vault secrets it will upsert, the migrations or schema files it will apply, and the seed files it will run. Declining stops the command without changing the database. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + If you combine `--sql-paths` with `--linked` or `--db-url`, the override seed files are applied to the selected remote database after migrations. Use this only when you intend to seed that remote target. diff --git a/apps/cli/docs/supabase/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md index 92313503b3..4438444124 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -2,7 +2,9 @@ Generate declarative schema files from a database. -Exports the schema of a live database (local, linked, or custom URL) into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. +Exports the schema of a live database into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. + +The database is the local one unless you pass `--linked`, `--project-ref`, or `--db-url`, or the workspace `target` is `remote`. The command never asks which database to use. With the local database, `--reset` recreates it from your migrations first. When the destination already holds files, the command asks before overwriting them (pass `--overwrite` or `--yes` to skip the question). The bundled pg-delta engine writes one directory per schema at the root of that directory (`supabase/schemas/public/tables/users.sql`, `supabase/schemas/public/schema.sql`), with cluster-level objects that belong to no schema under a reserved `_cluster/` directory (`supabase/schemas/_cluster/roles.sql`). When platform default grants (`anon`/`authenticated`/`service_role`) need an overlay `REVOKE ALL`, the export writes `adp_wipes.sql` after `schema.sql` in that schema — or `_cluster/adp_wipes.sql` for cluster-wide overlays — so a load onto a fresh project keeps privileges that were revoked in the source. A schema literally named `_cluster` or `_custom`, in any casing, has its leading underscore percent-encoded (`%5Fcluster/`) so it can never claim a directory the export owns. Hand-authored SQL that pg-delta does not model belongs in `_custom/`, which the export never writes to and never prunes. diff --git a/apps/cli/docs/supabase/db/schema-declarative-sync.md b/apps/cli/docs/supabase/db/schema-declarative-sync.md index ea7e7faa23..ff65de4c31 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-sync.md +++ b/apps/cli/docs/supabase/db/schema-declarative-sync.md @@ -2,6 +2,6 @@ Generate a new migration by diffing your declarative schema files against the current migration state. -When no declarative schema exists yet, the command offers to run `generate` first. After computing the diff, you can optionally name the migration and apply it to the local database. +When no declarative schema exists yet, the command offers to run `generate` first, exporting from the local database. To start from a linked project or a database URL, run `generate --linked` or `generate --db-url` yourself first. After computing the diff, you can optionally name the migration and apply it to the local database. Enabled by default. When `[experimental.pgdelta] enabled = false` is set in config, pass the `--experimental` flag to run it. diff --git a/apps/cli/docs/supabase/domains/activate.md b/apps/cli/docs/supabase/domains/activate.md index 6624839c45..e093ae30cf 100644 --- a/apps/cli/docs/supabase/domains/activate.md +++ b/apps/cli/docs/supabase/domains/activate.md @@ -5,3 +5,5 @@ Activates the custom hostname configuration for a project. This reconfigures your Supabase project to respond to requests on your custom hostname. After the custom hostname is activated, your project's third-party auth providers will no longer function on the Supabase-provisioned subdomain. Please refer to [Prepare to activate your domain](/docs/guides/platform/custom-domains#prepare-to-activate-your-domain) section in our documentation to learn more about the steps you need to follow. + +Asks for confirmation before activating, with a default of No. Pass `--yes` (or set `SUPABASE_YES` in the shell) to run without a prompt, which JSON output and non-interactive runs require. diff --git a/apps/cli/docs/supabase/functions.md b/apps/cli/docs/supabase/functions.md index d0a08de06c..42f0632de3 100644 --- a/apps/cli/docs/supabase/functions.md +++ b/apps/cli/docs/supabase/functions.md @@ -7,3 +7,5 @@ Supabase Edge Functions are server-less functions that run close to your users. Edge Functions allow you to execute custom server-side code without deploying or scaling a traditional server. They're ideal for handling webhooks, custom API endpoints, data validation, and serving personalized content. Edge Functions are written in TypeScript and run on Deno compatible edge runtime, which is a secure runtime with no package management needed, fast cold starts, and built-in security. + +Commands that change the hosted project — `functions deploy` (including `--prune`) and `functions delete` — print the `Target:` line, then ask for confirmation before changing anything; the default answer is no. `functions deploy` asks one question covering every function it will deploy and every function `--prune` will delete. Piped answers are never read, so scripts, JSON runs and agents must pass `--yes` or set `SUPABASE_YES` in the shell (a `SUPABASE_YES` in `supabase/.env` does not count). `functions list` and `functions download` only read and never ask. diff --git a/apps/cli/docs/supabase/gen.md b/apps/cli/docs/supabase/gen.md index f88cbf8c9e..aff58b4e60 100644 --- a/apps/cli/docs/supabase/gen.md +++ b/apps/cli/docs/supabase/gen.md @@ -2,7 +2,7 @@ Automatically generates type definitions based on your Postgres database schema. -This command connects to your database (local or remote) and generates typed definitions that match your database tables, views, and stored procedures. By default, it generates TypeScript definitions, but also supports Go and Swift. +This command connects to your database and generates typed definitions that match your database tables, views, and stored procedures. It reads the local database unless you pass `--linked`, `--project-ref`, or `--db-url`, or set `target = "remote"` in your project configuration. By default, it generates TypeScript definitions, but also supports Go and Swift. Generated types give you type safety and autocompletion when working with your database in code, helping prevent runtime errors and improving developer experience. diff --git a/apps/cli/docs/supabase/migration/list.md b/apps/cli/docs/supabase/migration/list.md index 343bbd4322..1562b35864 100644 --- a/apps/cli/docs/supabase/migration/list.md +++ b/apps/cli/docs/supabase/migration/list.md @@ -1,11 +1,11 @@ ## supabase-migration-list -Lists migration history in both local and remote databases. +Lists the migration files in `supabase/migrations` next to the migration history of the target database. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +By default, it lists the migration history of your local database. Pass `--linked` (or `--project-ref`) to list the linked project instead, or set `target = "remote"` in your project config (or `SUPABASE_TARGET=remote`) to make the linked project the default. Linking requires running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. > Note that URL strings must be escaped according to [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). -Local migrations are stored in `supabase/migrations` directory while remote migrations are tracked in `supabase_migrations.schema_migrations` table. Only the timestamps are compared to identify any differences. +Local migrations are stored in `supabase/migrations` directory while the target database tracks applied migrations in the `supabase_migrations.schema_migrations` table. The second column is headed `Remote` for a linked project or `--db-url` database, and `Local DB` for the local database. Only the timestamps are compared to identify any differences. In case of discrepancies between the local and remote migration history, you can resolve them using the `migration repair` command. diff --git a/apps/cli/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md index 981bb9cb8e..0b00d8649b 100644 --- a/apps/cli/docs/supabase/migration/repair.md +++ b/apps/cli/docs/supabase/migration/repair.md @@ -1,15 +1,16 @@ ## supabase-migration-repair -Repairs the remote migration history table. +Repairs the migration history table. -Requires your local project to be linked to a remote database by running `supabase link`. +By default, it repairs your local database. Pass `--linked` (or `--project-ref`) to repair the linked project, which requires running `supabase link`, or `--db-url` to repair a specific database. Repairing a hosted database asks for confirmation before it changes anything (the default answer is No); pass `--yes` to confirm in scripts and other non-interactive runs. If your local and remote migration history goes out of sync, you can repair the remote history by marking specific migrations as `--status applied` or `--status reverted`. Marking as `reverted` will delete an existing record from the migration history table while marking as `applied` will insert a new record. For example, your migration history may look like the table below, with missing entries in either local or remote. ```bash -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── │ 20230103054303 │ 2023-01-03 05:43:03 @@ -21,7 +22,8 @@ To reset your migration history to a clean state, first delete your local migrat ```bash $ rm supabase/migrations/20230103054315_remote_commit.sql -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── │ 20230103054303 │ 2023-01-03 05:43:03 @@ -30,12 +32,16 @@ $ supabase migration list Then mark the remote migration `20230103054303` as reverted. ```bash -$ supabase migration repair 20230103054303 --status reverted -Connecting to remote database... -Repaired migration history: [20220810154537] => reverted +$ supabase migration repair 20230103054303 --status reverted --linked +Target: project My Project (abcdefghijklmnopqrst) · production + • 20230103054303 +◇ Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production? +│ Yes +Repaired migration history: [20230103054303] => reverted Finished supabase migration repair. -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── ``` @@ -44,13 +50,16 @@ Now you can run `db pull` again to dump the remote schema as a local migration f ```bash $ supabase db pull -Connecting to remote database... +Target: project My Project (abcdefghijklmnopqrst) · production +Write 1 migration file to supabase/migrations. +Record them as applied in the remote migration history. +◇ Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? +│ Yes Schema written to supabase/migrations/20240414044403_remote_schema.sql -Update remote migration history table? [Y/n] -Repaired migration history: [20240414044403] => applied Finished supabase db pull. -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── 20240414044403 │ 20240414044403 │ 2024-04-14 04:44:03 diff --git a/apps/cli/docs/supabase/migration/squash.md b/apps/cli/docs/supabase/migration/squash.md index 150d510e5b..ff0643b986 100644 --- a/apps/cli/docs/supabase/migration/squash.md +++ b/apps/cli/docs/supabase/migration/squash.md @@ -9,3 +9,5 @@ However, one limitation is that data manipulation statements, such as insert, up By default, the latest `_.sql` file will be updated to contain the squashed migration. You can override the target version using the `--version ` flag. If your `supabase/migrations` directory is empty, running `supabase squash` will do nothing. + +Squashing rewrites your local migration files. When the target is a hosted database (`--linked`, `--project-ref`, or a non-local `--db-url`), it also resets the remote migration history table to the squashed migration. That asks for confirmation before any file is changed (the default answer is No); pass `--yes` to confirm in scripts and other non-interactive runs. With the default local target it only squashes the files and suggests running `supabase migration repair --status applied` for the remote history. diff --git a/apps/cli/docs/supabase/network-bans.md b/apps/cli/docs/supabase/network-bans.md index 7f39d21dbc..a2a5fc4a43 100644 --- a/apps/cli/docs/supabase/network-bans.md +++ b/apps/cli/docs/supabase/network-bans.md @@ -3,3 +3,5 @@ Network bans are IPs that get temporarily blocked if their traffic pattern looks abusive (e.g. multiple failed auth attempts). The subcommands help you view the current bans, and unblock IPs if desired. + +`remove` asks for confirmation (default No) before unblocking. Pass `--yes` to confirm without asking; JSON, stream-json, `-o` and non-interactive runs fail without it. diff --git a/apps/cli/docs/supabase/postgres-config/update.md b/apps/cli/docs/supabase/postgres-config/update.md index c7fde38474..5cf2af8197 100644 --- a/apps/cli/docs/supabase/postgres-config/update.md +++ b/apps/cli/docs/supabase/postgres-config/update.md @@ -2,3 +2,4 @@ Overriding the default Postgres config could result in unstable database behavior. Custom configuration also overrides the optimizations generated based on the compute add-ons in use. +The update asks for confirmation (default No) before changing the overrides. Pass `--yes` to confirm without asking; JSON, stream-json, `-o` and non-interactive runs fail without it. diff --git a/apps/cli/docs/supabase/projects.md b/apps/cli/docs/supabase/projects.md index d01c49ed65..b7cb41d639 100644 --- a/apps/cli/docs/supabase/projects.md +++ b/apps/cli/docs/supabase/projects.md @@ -5,3 +5,5 @@ Provides tools for creating and managing your Supabase projects. This command group allows you to list all projects in your organizations, create new projects, delete existing projects, and retrieve API keys. These operations help you manage your Supabase infrastructure programmatically without using the dashboard. Project management via CLI is especially useful for automation scripts and when you need to provision environments in a repeatable way. + +Commands that change your account or projects ask for confirmation first: `create` defaults to yes, `delete` defaults to no. Pass `--yes` (or set `SUPABASE_YES` in your shell) to confirm in scripts and CI; without it, a run that cannot prompt fails before making any change. diff --git a/apps/cli/docs/supabase/pull.md b/apps/cli/docs/supabase/pull.md index 3ac0b53dd7..edaedd474c 100644 --- a/apps/cli/docs/supabase/pull.md +++ b/apps/cli/docs/supabase/pull.md @@ -6,9 +6,11 @@ Pass `--project-ref` to target a specific project, or the name (or UUID) of a br On a fresh checkout, the migration history step runs automatically even without `--with-migration-history`, whenever `supabase/migrations` is missing or a raw directory listing of it turns up no entries at all — the same "existing files" check `migration fetch` itself uses, so a directory holding only a `.gitkeep`/`README.md` already counts as non-empty for this purpose (the listing is unfiltered — any entry at all disqualifies it) and the two commands never disagree about it. There's nothing local to overwrite when the directory is truly empty or missing, and it's what lets the database step run at all: without it, `db pull` would hard-fail comparing an empty local directory against a remote project that already has migration history. Pass `--with-migration-history` to force a re-fetch whenever the directory already has any files in it, including only non-migration ones — unlike the bootstrap case, this DOES overwrite: any local migration file that shares a name with a remote history entry is replaced. -`supabase pull` shows one confirmation before writing anything, covering all four steps. It opens by naming the target (`Pulling from project `, or with `(branch "")` appended when the target is a branch), then lists the four steps in the order they run: the config step's part is a real diff, computed the same way `config diff`/`config pull` compute theirs (or "No config differences found." when there is nothing to change); the migration-history line only appears when that step will actually run this invocation, and calls out that it overwrites same-named local files when it's running because of `--with-migration-history` rather than the bootstrap case; the database line notes that it also updates the remote migration history table and requires Docker; the functions line describes the download. `--dry-run` prints this exact same disclosure body — not a placeholder or a status-only summary — before exiting without writing or changing anything. `--yes` skips the confirmation and takes its default answer. +`supabase pull` prints the resolved target once (`Target: …` on stderr), then shows one confirmation before writing anything, covering all four steps. It opens by naming the target (`Pulling from project `, or with `(branch "")` appended when the target is a branch), then lists the four steps in the order they run: the config step's part is a real diff, computed the same way `config diff`/`config pull` compute theirs (or "No config differences found." when there is nothing to change); the migration-history line only appears when that step will actually run this invocation, and calls out that it overwrites same-named local files when it's running because of `--with-migration-history` rather than the bootstrap case; the database line notes that it also updates the remote migration history table and requires Docker; the functions line describes the download. `--dry-run` prints this exact same disclosure body — not a placeholder or a status-only summary — before exiting without writing or changing anything. -`supabase pull` checks THREE locations for uncommitted or untracked changes in git before writing: `supabase/config.toml` (or `config.json`), `supabase/migrations`, and `supabase/functions`. The config check only fires when the config step itself has work to write — a config that's already fully converged with the target never triggers it. The other two fire unconditionally: the database step always attempts to run and has no way to know in advance whether it will find schema drift and write into `supabase/migrations`, and the functions step always runs with no equivalent "nothing to do" signal available without calling the API first — so both are checked every run to avoid silently missing real uncommitted work. Each location's dirty state is tracked separately: the confirmation body and any abort message name exactly which path(s) are actually dirty, e.g. `supabase/config.toml has uncommitted or untracked changes...` for one, or `supabase/config.toml, supabase/migrations, and supabase/functions have uncommitted or untracked changes...` when all three are. `--force` writes even when any of the three is dirty — without it, an interactive run's confirmation defaults to declining instead of proceeding, and every other case (non-interactive, machine output format, or `--yes` on any TTY) aborts outright rather than silently overwriting local edits. +Because the database step records the pulled migration in the remote migration history table, the confirmation is a hosted-mutation consent that defaults to **No**: `Pull project state and update the remote migration history table on ?`. Declining exits non-zero with `context canceled` and nothing is written. `--yes`, or `SUPABASE_YES` set in the shell environment, confirms it; a `SUPABASE_YES` in `supabase/.env` does not. `--output-format json|stream-json` and non-interactive runs cannot prompt, so they need `--yes`; an answer piped to stdin is ignored. `--yes` also lets the database connection remove a network ban that blocks the temporary login role; without it the step fails and points to `supabase network-bans remove`. + +`supabase pull` checks THREE locations for uncommitted or untracked changes in git before writing: `supabase/config.toml` (or `config.json`), `supabase/migrations`, and `supabase/functions`. The config check only fires when the config step itself has work to write — a config that's already fully converged with the target never triggers it. The other two fire unconditionally: the database step always attempts to run and has no way to know in advance whether it will find schema drift and write into `supabase/migrations`, and the functions step always runs with no equivalent "nothing to do" signal available without calling the API first — so both are checked every run to avoid silently missing real uncommitted work. Each location's dirty state is tracked separately: the confirmation body and any abort message name exactly which path(s) are actually dirty, e.g. `supabase/config.toml has uncommitted or untracked changes...` for one, or `supabase/config.toml, supabase/migrations, and supabase/functions have uncommitted or untracked changes...` when all three are. `--force` writes even when any of the three is dirty — without it, an interactive run's confirmation body carries the warning, and every other case (non-interactive, machine output format, or `--yes` on any TTY) aborts outright rather than silently overwriting local edits. **This command is not purely a local, read-only refresh.** Pulling the database schema also updates that database's own migration history table on the **remote** project — the same behavior `db pull` has always had — not just a local file write. The database step also requires Docker (it runs a shadow Postgres container, and — on the very first pull — a `pg_dump` container) to compute the schema diff. Without Docker running, that one step fails and is reported as such, but the other three steps (config, migration history, functions) still run and complete normally; a project with nothing in its database yet can still successfully pull its config and functions without Docker installed. @@ -42,4 +44,4 @@ supabase pull --remote-label staging Pulls into the `[remotes.staging]` block instead of whatever block the target would otherwise reuse or create. -Machine-readable output is available through `--output-format json|stream-json` — a single object (`schema_version`, `target`, `dry_run`, `confirmed`, `wrote`, `step_order`, `steps`, `counts`) with one entry per step under `steps`, keyed `config`, `migration_history`, `db`, and `functions`, each carrying its own `status` (`changed`, `unchanged`, `skipped`, `planned`, or `failed`), the paths it wrote, and a step-specific `detail` object. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. +Machine-readable output is available through `--output-format json|stream-json` — a single object (`schema_version`, `target`, `dry_run`, `confirmed`, `wrote`, `step_order`, `steps`, `counts`) with one entry per step under `steps`, keyed `config`, `migration_history`, `db`, and `functions`, each carrying its own `status` (`changed`, `unchanged`, `skipped`, `planned`, or `failed`), the paths it wrote, and a step-specific `detail` object. `target` carries `kind: "linked"`, `project_ref`, `environment` (`production`, `branch`, or `unverified`), `project_name` and `parent_project_ref` when known, and `branch` when `--project-ref` named one. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/docs/supabase/secrets.md b/apps/cli/docs/supabase/secrets.md index 975f3456cf..94dd5704b1 100644 --- a/apps/cli/docs/supabase/secrets.md +++ b/apps/cli/docs/supabase/secrets.md @@ -10,3 +10,5 @@ Secrets management through the CLI is useful for: - Managing sensitive credentials securely Secrets can be set individually or loaded from .env files for convenience. + +`secrets set` and `secrets unset` print the `Target:` line, then ask for confirmation before changing anything; the default answer is no, and the question lists the secret names (never their values). Piped answers are never read, so scripts, JSON runs and agents must pass `--yes` or set `SUPABASE_YES` in the shell (a `SUPABASE_YES` in `supabase/.env` does not count). `secrets unset --dry-run` and `secrets list` only read and never ask. diff --git a/apps/cli/docs/supabase/test/db.md b/apps/cli/docs/supabase/test/db.md index 9978bb4535..c79b87ac57 100644 --- a/apps/cli/docs/supabase/test/db.md +++ b/apps/cli/docs/supabase/test/db.md @@ -4,6 +4,10 @@ Executes pgTAP tests against the local database. Requires the local development stack to be started by running `supabase start`. +With no target flag the command tests the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved. + +Running the tests enables the pgTAP extension and drops it again afterwards, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before anything runs. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + Runs `pg_prove` in a container with unit test files volume mounted from `supabase/tests` directory. The test file can be suffixed by either `.sql` or `.pg` extension. Since each test is wrapped in its own transaction, it will be individually rolled back regardless of success or failure. diff --git a/apps/cli/docs/supabase/vanity-subdomains.md b/apps/cli/docs/supabase/vanity-subdomains.md index 92212a0886..16d003d9f9 100644 --- a/apps/cli/docs/supabase/vanity-subdomains.md +++ b/apps/cli/docs/supabase/vanity-subdomains.md @@ -3,3 +3,5 @@ Manage vanity subdomains for Supabase projects. Usage of vanity subdomains and custom domains is mutually exclusive. + +`activate` and `delete` ask for confirmation, with a default of No. Pass `--yes` (or set `SUPABASE_YES` in the shell) to run without a prompt, which JSON output and non-interactive runs require. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index 63e7f6a995..df7d53c366 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -111,7 +111,7 @@ supabase-status: supabase-migration-list: - id: basic-usage name: Basic usage - code: supabase migration list + code: supabase migration list --linked response: |2 LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -140,20 +140,24 @@ supabase-migration-new: supabase-migration-repair: - id: basic-usage name: Mark a migration as reverted - code: supabase migration repair 20230103054303 --status reverted + code: supabase migration repair 20230103054303 --status reverted --linked --yes response: | - Repaired migration history: 20230103054303 => reverted + Target: project My Project (abcdefghijklmnopqrst) · production + Confirmed by --yes: Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production + Repaired migration history: [20230103054303] => reverted - id: mark-applied name: Mark a migration as applied - code: supabase migration repair 20230222032233 --status applied + code: supabase migration repair 20230222032233 --status applied --linked --yes response: | - Repaired migration history: 20230222032233 => applied + Target: project My Project (abcdefghijklmnopqrst) · production + Confirmed by --yes: Mark 1 migration as applied on project My Project (abcdefghijklmnopqrst) · production + Repaired migration history: [20230222032233] => applied supabase-db-diff: - id: basic-usage name: Basic usage code: supabase db diff -f my_table response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Applying migration 20230425064254_remote_commit.sql... Diffing schemas: auth,extensions,public,storage @@ -164,7 +168,7 @@ supabase-db-diff: name: Against linked project code: supabase db diff -f my_table --linked response: | - Connecting to local database... + Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... Diffing schemas: auth,extensions,public,storage Finished supabase db diff on branch main. @@ -175,7 +179,7 @@ supabase-db-diff: name: For a specific schema code: supabase db diff -f my_table --schema auth response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Diffing schemas: auth Finished supabase db diff on branch main. @@ -186,18 +190,28 @@ supabase-db-dump: name: Basic usage code: supabase db dump -f supabase/schema.sql response: | + Target: local stack (127.0.0.1:54322) + Dumping schemas from local database... + Dumped schema to supabase/schema.sql. + - id: linked-project + name: Dump the linked project + code: supabase db dump -f supabase/schema.sql --linked + response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping schemas from remote database... Dumped schema to supabase/schema.sql. - id: role-only name: Role only - code: supabase db dump -f supabase/roles.sql --role-only + code: supabase db dump -f supabase/roles.sql --role-only --linked response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping roles from remote database... Dumped schema to supabase/roles.sql. - id: data-only name: Data only - code: supabase db dump -f supabase/seed.sql --data-only + code: supabase db dump -f supabase/seed.sql --data-only --linked response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping data from remote database... Dumped schema to supabase/seed.sql. supabase-db-lint: @@ -230,16 +244,17 @@ supabase-db-pull: name: Basic usage code: supabase db pull response: | - Connecting to remote database... + Target: project My Project (abcdefghijklmnopqrst) · production + ◇ Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? + │ Yes Schema written to supabase/migrations/20240414044403_remote_schema.sql - Update remote migration history table? [Y/n] Repaired migration history: [20240414044403] => applied Finished supabase db pull. - id: local-studio name: Local studio code: supabase db pull --local response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Seeding globals from roles.sql... Applying migration 20240414044403_remote_schema.sql... @@ -250,7 +265,7 @@ supabase-db-pull: name: Custom schemas code: supabase db pull --schema public,extensions response: | - Connecting to remote database... + Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... Seeding globals from roles.sql... Applying migration 20240414044403_remote_schema.sql... @@ -300,7 +315,7 @@ supabase-db-schema-declarative-sync: Enter a name for this migration (press Enter to keep 'declarative_sync'): add_updated_at Created new migration at supabase/migrations/20260317194051_add_updated_at.sql Apply this migration to local database? [Y/n] - Connecting to local database... + Target: local stack (127.0.0.1:54322) Applying migration 20260317194051_add_updated_at.sql... Migration applied successfully. - id: generate-first diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 0d2caddd89..1a94f0f82e 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -13,7 +13,7 @@ describe("supabase __complete", () => { const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], {}); expect(exitCode).toBe(0); const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines[0]).toBe("list\tList local files and the target database's migration history"); expect(lines.at(-1)).toBe(":4"); }, ); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index a87d951430..7acadaf020 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option } from "effect"; +import { Effect, Layer } from "effect"; import { Command } from "effect/cli"; import { backupsCommand } from "../commands/backups/backups.command.ts"; import { bootstrapCommand } from "../commands/bootstrap/bootstrap.command.ts"; @@ -52,7 +52,10 @@ import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { vanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { whoamiCommand } from "../commands/whoami/whoami.command.ts"; import { CLI_VERSION, cliBuildChannel } from "../shared/cli/version.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { machineErrorContextLayer } from "../shared/output/machine-error-context.layer.ts"; import { outputLayerFor } from "../shared/output/output.layer.ts"; +import { isMachineResourceFormat } from "../shared/output/resource-format.ts"; import { quietProgressTextOutputLayer } from "../output/quiet-progress-text-output.layer.ts"; import { AiTool } from "../shared/telemetry/ai-tool.service.ts"; import { aiToolLayer } from "../shared/telemetry/ai-tool.layer.ts"; @@ -218,20 +221,18 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => // Machine formats keep the text layer's error rendering but suppress the progress // spinner, which would otherwise corrupt the stdout payload. `-o pretty`/`-o table` // (db query's human default) and no `-o` keep the normal text/json layers. - const resourceFormat = Option.getOrUndefined(resourceOutput); - const isMachineResourceFormat = - resourceFormat !== undefined && - resourceFormat !== "pretty" && - resourceFormat !== "table"; - const outputLayer = isMachineResourceFormat + const outputLayer = isMachineResourceFormat(resourceOutput) ? quietProgressTextOutputLayer : outputLayerFor(outputFormat); + // A command that provides its own `machineErrorContextLayer` shadows the default cell. return Layer.mergeAll( options.stackBackend === undefined ? Layer.empty : stackBackendLayer(options.stackBackend), - outputLayer, + commandTargetLayer.pipe(Layer.provideMerge(outputLayer)), + machineErrorContextLayer, + Layer.succeed(AiTool, aiTool), ); }), ), diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index fa0c8c05e3..0d63a45af2 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -63,6 +63,16 @@ export const SUGGEST_ENV_VAR = */ export const SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; +/** Appended to the local-stack hint when the checkout is linked to a hosted project. */ +export const SUGGEST_LINKED_PROJECT = "To use the linked project instead, pass --linked."; + +/** The local-stack hint, plus the `--linked` pointer when the checkout is linked. */ +export function localStackSuggestion(linkedCheckout: boolean | undefined): string { + return linkedCheckout === true + ? `${SUGGEST_LOCAL_STACK}\n${SUGGEST_LINKED_PROJECT}` + : SUGGEST_LOCAL_STACK; +} + /** * Remote-only "Network Restrictions" hint, shown for a connection refused or blocked by IP * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch @@ -78,6 +88,8 @@ export interface ConnectSuggestionContext { readonly dashboardUrl: string; /** Active profile name. */ readonly profileName: string; + /** Whether the checkout is linked to a hosted project, so a local failure can point at `--linked`. */ + readonly linkedCheckout?: boolean; } /** @@ -350,7 +362,9 @@ export function connectSuggestion( } // Don't send the user to the dashboard's Network Restrictions page for a --local connection. if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { - return ctx.isLocal ? SUGGEST_LOCAL_STACK : suggestNetworkRestrictions(ctx.dashboardUrl); + return ctx.isLocal + ? localStackSuggestion(ctx.linkedCheckout) + : suggestNetworkRestrictions(ctx.dashboardUrl); } if ( text.includes("SCRAM exchange: Wrong password") || diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 4255124580..831d9aa3c9 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -274,6 +274,13 @@ describe("connectSuggestion", () => { expect(connectSuggestion(err, { ...ctx, isLocal: true })).toBe(SUGGEST_LOCAL_STACK); }); + it("points a refused local connection at --linked when the checkout is linked", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(connectSuggestion(err, { ...ctx, isLocal: true, linkedCheckout: true })).toBe( + `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --linked.`, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json index a49452a620..40f0945c4a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json index 9c45da7b15..653407f9f2 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json index a0698bac71..9c44bf7c2f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json index c35d4d806b..696752da26 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json index 713f623162..274198ecb9 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json @@ -532,7 +532,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json index a6ab8caf6f..50eccadf1d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", "debug": false, diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index c0c450e4ca..86ba1be874 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -16,15 +16,6 @@ export class DbConfigParseUrlError extends Data.TaggedError("DbConfigParseUrlErr } } -/** `--password` combined with `--db-url` or `--local`, where it can never apply. */ -export class DbPasswordFlagsError extends Data.TaggedError("DbPasswordFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * `supabase/config.toml` exists but could not be read or parsed: the read/decode * error aborts the load, rather than silently running against the default local database. @@ -153,6 +144,20 @@ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConn } } +/** The temporary login role could not connect while network bans were listed and removing them was not approved with `--yes`. */ +export class LinkedDbNetworkBannedError extends Data.TaggedError("LinkedDbNetworkBannedError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase network-bans remove", + }; + } +} + /** * The configured pooler connection string does not match the linked project ref, or its domain * falls outside the active profile (MITM guard). diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 28913f9c0b..78658534c0 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -55,14 +55,20 @@ import { OutputFlag, ProfileFlag, WorkdirFlag, + YesFlag, } from "./global-flags.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; import { DbConfigResolver } from "./db-config.service.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; +import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { StackApi } from "./stack-api.ts"; @@ -100,8 +106,10 @@ function buildResolver( readonly configEnv?: Record; readonly stackApi?: Layer.Layer; readonly flagPassword?: string; + readonly output?: ReturnType; } = {}, ) { + const out = opts.output ?? mockOutput(); const deps = Layer.mergeAll( Layer.succeed( CliConfigFlagInputs, @@ -119,7 +127,8 @@ function buildResolver( }), opts.dbConnection ?? mockDbConnection, opts.debugLogger ?? mockDebugLogger, - mockOutput().layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), mockAnalytics().layer, mockTelemetryRuntime(), mockTty(), @@ -164,14 +173,49 @@ function withWorkdir(toml?: string) { }); } -const resolve = ( +const inputFor = ( + target: ResolvedTarget, + overrides: Partial = {}, +): DbConnectionInput => ({ + target, + dnsResolver: "native", + password: Option.none(), + networkBanRemoval: Option.none(), + ...overrides, +}); + +const localTarget: ResolvedTarget = { kind: "local" }; +const dbUrlTarget = (url: string): ResolvedTarget => ({ + kind: "db-url", + url: Redacted.make(url), +}); +const linkedTarget = ( + ref: string, + overrides: Partial> = {}, +): ResolvedTarget => ({ + kind: "linked", + ref, + source: "file", + matchesLinkedRef: true, + ...overrides, +}); +const explicitRefTarget = (ref: string, overrides: { readonly matchesLinkedRef?: boolean } = {}) => + linkedTarget(ref, { source: "flag", ...overrides }); + +const approval = hostedMutationPreapproval.pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, true), Layer.succeed(CliArgs, { args: [] })), + ), +); + +const resolveConnection = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolve(flags); + return yield* resolver.resolveConnection(input); }).pipe( Effect.provide(buildResolver(workdir, opts)), Effect.provideServiceEffect( @@ -182,12 +226,12 @@ const resolve = ( const resolvePoolerFallback = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolvePoolerFallback(flags); + return yield* resolver.resolvePoolerFallback(input); }).pipe( Effect.provide(buildResolver(workdir, opts)), Effect.provideServiceEffect( @@ -196,22 +240,6 @@ const resolvePoolerFallback = ( ), ); -const localFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "local", - dnsResolver: "native", -}; -const dbUrlFlags = (url: string): DbConfigFlags => ({ - dbUrl: Option.some(url), - connType: "db-url", - dnsResolver: "native", -}); -const linkedFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "linked", - dnsResolver: "native", -}; - const assertDbConfigDrift = ( requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, activeFlags: ActiveFlags, @@ -230,7 +258,7 @@ describe("dbConfigResolver (local + db-url)", () => { const dir = yield* withWorkdir( ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -258,7 +286,7 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags, { + return yield* resolveConnection(dir, inputFor(localTarget), { configEnv: { SUPABASE_SERVICES_HOSTNAME: "host.docker.internal" }, }); }).pipe( @@ -273,10 +301,35 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect( + "local mode on a linked checkout carries the --linked pointer in its connect hint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext).toMatchObject({ linkedCheckout: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("local mode on an unlinked checkout adds no --linked pointer", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext?.linkedCheckout).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("local mode: falls back to default port/password without a config.toml", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -293,7 +346,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: parses the connection string and percent-decodes the password", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://alice:p%40ss@example.com:6543/appdb")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:p%40ss@example.com:6543/appdb")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -319,9 +375,9 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: a 127.0.0.1 url on the configured db.port is detected as local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:postgres@127.0.0.1:54322/postgres"), + inputFor(dbUrlTarget("postgres://postgres:postgres@127.0.0.1:54322/postgres")), ); }).pipe( Effect.tap((r) => @@ -337,9 +393,11 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + inputFor( + dbUrlTarget("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + ), ); }).pipe( Effect.tap((r) => @@ -358,7 +416,10 @@ describe("dbConfigResolver (local + db-url)", () => { const dir = yield* withWorkdir( ["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres@127.0.0.1:54322/postgres")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -376,7 +437,10 @@ describe("dbConfigResolver (local + db-url)", () => { () => { return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:s3cret@ bad host/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:s3cret@ bad host/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -401,7 +465,7 @@ describe("dbConfigResolver (local + db-url)", () => { "postgres://postgres:pw@example.com:5432/postgres?sslmode=verify-full&options=reference%3Dabcdefghijklmnop"; return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags(url)); + return yield* resolveConnection(dir, inputFor(dbUrlTarget(url))); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -417,7 +481,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: accepts a libpq keyword/value DSN", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("host=pg.example.com port=6543 user=admin dbname=app")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("host=pg.example.com port=6543 user=admin dbname=app")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -440,7 +507,10 @@ describe("dbConfigResolver (local + db-url)", () => { // throws. It must surface as a normal parse failure, not an untyped defect. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:p%zz@example.com/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:p%zz@example.com/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -462,44 +532,6 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); -describe("dbConfigResolver --password rejection", () => { - const failureTag = (exit: Exit.Exit) => - Exit.isFailure(exit) ? JSON.stringify(exit.cause) : ""; - - const targets: ReadonlyArray = [ - ["--db-url", dbUrlFlags("postgresql://u:p@db.example.com:5432/postgres")], - ["--local", localFlags], - ["the default local target", { ...localFlags, connType: undefined }], - ]; - for (const [label, flags] of targets) { - it.effect(`rejects --password with ${label}`, () => - Effect.gen(function* () { - const dir = yield* withWorkdir(); - return yield* resolve(dir, { ...flags, password: Option.some("pw") }); - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failureTag(exit)).toContain("DbPasswordFlagsError"); - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ), - ); - } - - it.effect("accepts an absent --password with --db-url", () => - Effect.gen(function* () { - const dir = yield* withWorkdir(); - return yield* resolve(dir, { - ...dbUrlFlags("postgresql://u:p@db.example.com:5432/postgres"), - password: Option.none(), - }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); -}); - describe("dbConfigResolver (db-url under the stack backend)", () => { const STACK_SQL_PORT = 54329; const stackUrl = (port: number) => @@ -598,7 +630,10 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { dir: string, url: string, stackApi: Layer.Layer = projectStackApi(dir, "running"), - ) => resolve(dir, dbUrlFlags(url), { stackApi }).pipe(Effect.provide(stackBackendLayer("stack"))); + ) => + resolveConnection(dir, inputFor(dbUrlTarget(url)), { stackApi }).pipe( + Effect.provide(stackBackendLayer("stack")), + ); it.effect("treats the url printed by `stack status --env` as the local database", () => Effect.gen(function* () { @@ -703,8 +738,7 @@ describe("dbConfigResolver (linked config ordering)", () => { "", ].join("\n"), ); - // The linked ref is sourced via the project-ref resolver's env fallback. - return yield* resolve(dir, linkedFlags, { configEnv: { SUPABASE_PROJECT_ID: ref } }); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref))); }).pipe( Effect.exit, Effect.tap((exit) => @@ -722,34 +756,6 @@ describe("dbConfigResolver (linked config ordering)", () => { ); }); - it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { - // The ref file is seeded as a directory (not a file), with no project_id or env fallback, - // to force a real read error. - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* withWorkdir(); - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { - recursive: true, - }); - return yield* resolve(dir, linkedFlags); - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to load project ref"); - expect(causeText).not.toContain("Cannot find project ref"); - } - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }); - it.effect("ad-hoc project refs ignore the linked workdir password and saved pooler URL", () => { const linkedRef = "abcdefghijklmnopqrst"; const adHocRef = "qrstabcdefghijklmnop"; @@ -846,13 +852,9 @@ describe("dbConfigResolver (linked config ordering)", () => { path.join(dir, "supabase", ".temp", "pooler-url"), `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor(explicitRefTarget(adHocRef, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1014,11 +1016,7 @@ describe("dbConfigResolver (linked config ordering)", () => { ); return yield* resolvePoolerFallback( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor(explicitRefTarget(adHocRef, { matchesLinkedRef: false })), { dbConnection, configEnv: { @@ -1162,21 +1160,14 @@ describe("dbConfigResolver (linked config ordering)", () => { path.join(dir, "supabase", ".temp", "pooler-url"), "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", ); - return yield* resolvePoolerFallback( - dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(linkedRef), - }, - { - projectHost: "supabase.co", - dbConnection, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "linked-password", - }, + return yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(linkedRef)), { + projectHost: "supabase.co", + dbConnection, + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "linked-password", }, - ); + }); }).pipe( Effect.tap((connOpt) => Effect.sync(() => { @@ -1214,11 +1205,10 @@ describe("dbConfigResolver (linked config ordering)", () => { }); }); -// An explicit `--project-ref`/`linkedProjectRef` on a non-ad-hoc `db` command must -// independently unlock the Management API pooler fetch on an IPv4-only network — it must not -// stay confined to the workdir's saved `.temp/pooler-url` the way the plain `--linked` default -// path is. -describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProjectRef)", () => { +// An explicit `--project-ref` must unlock the Management API pooler fetch on an IPv4-only +// network, even when it names the linked project, rather than staying confined to the workdir's +// saved `.temp/pooler-url` the way the plain `--linked` path is. +describe("dbConfigResolver (an explicit --project-ref fetches the pooler config)", () => { it.effect( "an unlinked workdir + explicit --project-ref resolves via the API pooler config, honoring the ambient password with no login-role mint", () => { @@ -1278,12 +1268,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje // so there is no `.temp/project-ref` and no `.temp/pooler-url` to reuse. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(ref), - }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1396,12 +1383,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje path.join(dir, "supabase", ".temp", "pooler-url"), `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(targetRef), - }, + inputFor(explicitRefTarget(targetRef, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1444,136 +1428,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, ); - it.effect( - "an explicit --project-ref with no password mints a login role and clears network bans after three failed temp-role connects", - () => { - const ref = "banrecoveryrefabcdef"; - - const previousFetch = globalThis.fetch; - const requests: Array<{ readonly method: string; readonly path: string }> = []; - let connectAttempts = 0; - const session: DbSession = { - exec: () => Effect.void, - execBatch: () => Effect.void, - query: () => Effect.succeed([]), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), - }; - const dbConnection = Layer.succeed(DbConnection, { - connect: () => - Effect.suspend(() => { - connectAttempts += 1; - return connectAttempts <= 3 - ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) - : Effect.succeed(session); - }), - }); - const json = (status: number, body: unknown) => - Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ); - const fetchMock = Object.assign( - (input: string | URL | Request, init?: RequestInit): Promise => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { - return json(200, [ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/cli/login-role`) { - return json(201, { - role: "cli_login_role", - password: "temporary-role-password", - ttl_seconds: 3600, - }); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/network-bans/retrieve`) { - return json(200, { banned_ipv4_addresses: ["203.0.113.7"] }); - } - if (method === "DELETE" && url.pathname === `/v1/projects/${ref}/network-bans`) { - return json(200, {}); - } - return json(404, { message: "unexpected request" }); - }, - { preconnect: previousFetch.preconnect }, - ); - - globalThis.fetch = fetchMock; - - return Effect.gen(function* () { - const retries = yield* Queue.unbounded(); - const debugLogger = Layer.succeed(DebugLogger, { - debug: (message) => - message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, - http: () => Effect.void, - }); - const dir = yield* withWorkdir(); - const resolved = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { - projectHost: "invalid", - dbConnection, - debugLogger, - configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, - }, - ).pipe(Effect.forkChild({ startImmediately: true })); - for (let failedConnects = 1; failedConnects <= 3; failedConnects++) { - yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(resolved))); - yield* TestClock.adjust("10 seconds"); - } - return yield* Fiber.join(resolved); - }).pipe( - Effect.tap((r) => - Effect.sync(() => { - expect(r.conn).toMatchObject({ - host: "aws-0-us-east-1.pooler.supabase.com", - user: `cli_login_role.${ref}`, - password: "temporary-role-password", - }); - expect(connectAttempts).toBe(4); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - { method: "POST", path: `/v1/projects/${ref}/cli/login-role` }, - { method: "POST", path: `/v1/projects/${ref}/network-bans/retrieve` }, - { method: "DELETE", path: `/v1/projects/${ref}/network-bans` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); - }), - ), - Effect.ensuring( - Effect.sync(() => { - globalThis.fetch = previousFetch; - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }, - ); - it.effect( "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", () => { @@ -1587,7 +1441,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - return yield* resolve(dir, linkedFlags, { projectHost: "invalid" }); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref)), { + projectHost: "invalid", + }); }).pipe( Effect.exit, Effect.tap((exit) => @@ -1637,7 +1493,7 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, localFlags); + const r = yield* resolveConnection(dir, inputFor(localTarget)); expect(r.isLocal).toBe(true); noApiCalls(requests, { local: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1648,7 +1504,10 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, dbUrlFlags("postgres://alice:secret@example.com:6543/appdb")); + const r = yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:secret@example.com:6543/appdb")), + ); expect(r.isLocal).toBe(false); noApiCalls(requests, { "db-url": true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1666,9 +1525,9 @@ describe("dbConfigResolver (Management API calls)", () => { : undefined, ); const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection: noDirectConnect, @@ -1698,40 +1557,6 @@ describe("dbConfigResolver (Management API calls)", () => { }, ); - it.effect("an empty --project-ref still fetches the pooler config for the linked project", () => { - const ref = "emptyprojectrefabcde"; - const requests: Array<{ readonly method: string; readonly path: string }> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, (method, path) => - method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` - ? Response.json(primaryPoolerConfigs(ref)) - : undefined, - ); - const dir = yield* withWorkdir(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - const r = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some("") }, - { - projectHost: "invalid", - dbConnection: noDirectConnect, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "ambient-password", - }, - }, - ); - expect(r.conn).toMatchObject({ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432 }); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": "" }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }); - it.effect("an empty --password still mints a login role", () => { const ref = "emptypasswordrefabcd"; const requests: Array<{ readonly method: string; readonly path: string }> = []; @@ -1757,9 +1582,11 @@ describe("dbConfigResolver (Management API calls)", () => { return undefined; }); const dir = yield* withWorkdir(); - const r = yield* resolve( + const r = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("") }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + password: Option.some(""), + }), { projectHost: "invalid", dbConnection: Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }), @@ -1833,9 +1660,11 @@ describe("dbConfigResolver (Management API calls)", () => { http: () => Effect.void, }); const dir = yield* withWorkdir(); - const resolved = yield* resolve( + const resolved = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), { projectHost: "invalid", dbConnection, @@ -1903,11 +1732,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { return undefined; }); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(ref)), { + dbConnection: connects, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); expect(Option.isSome(pooler)).toBe(true); if (Option.isSome(pooler)) { expect(pooler.value).toMatchObject({ @@ -1935,7 +1763,7 @@ describe("dbConfigResolver (container pooler fallback)", () => { const dir = yield* withWorkdir(); const pooler = yield* resolvePoolerFallback( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") }, + inputFor(explicitRefTarget(ref), { password: Option.some("secret") }), { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, ); expect(pooler).toEqual( @@ -1955,11 +1783,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { : undefined, ); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(ref)), { + dbConnection: connects, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); expect(Option.isNone(pooler)).toBe(true); expect(requests).toEqual([{ method: "GET", path: poolerPath }]); assertFallbackDrift(requests, { linked: true }); @@ -1971,10 +1798,331 @@ describe("dbConfigResolver (container pooler fallback)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback(dir, localFlags); + const pooler = yield* resolvePoolerFallback(dir, inputFor(localTarget)); expect(Option.isNone(pooler)).toBe(true); expect(requests).toEqual([]); assertFallbackDrift(requests, { local: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); }); + +describe("dbConfigResolver.resolveConnection", () => { + const failureText = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + + it.effect("local target connects to the local stack and announces it", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir( + ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), + ); + const r = yield* resolveConnection(dir, inputFor({ kind: "local" }), { output }); + expect(r.conn).toMatchObject({ host: "127.0.0.1", port: 55555, password: "hunter2" }); + expect(r.isLocal).toBe(true); + expect(r.target).toEqual({ kind: "local" }); + expect(output.stderrText).toBe("Target: local stack (127.0.0.1:55555)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("db-url target announces the host and never the credentials", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@db.example.com:6543/appdb"); + const r = yield* resolveConnection(dir, inputFor({ kind: "db-url", url }), { output }); + expect(r.conn).toMatchObject({ host: "db.example.com", port: 6543, user: "alice" }); + expect(r.isLocal).toBe(false); + expect(output.stderrText).toBe("Target: custom Postgres (db.example.com:6543)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("a db-url that fails to parse echoes the url with the password redacted", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@:notaport/appdb"); + const exit = yield* Effect.exit(resolveConnection(dir, inputFor({ kind: "db-url", url }))); + expect(failureText(exit)).toContain("DbConfigParseUrlError"); + expect(failureText(exit)).not.toContain("secret"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + const directTargets: ReadonlyArray = [ + ["local", { kind: "local" }], + [ + "db-url", + { kind: "db-url", url: Redacted.make("postgres://u:p@db.example.com:5432/postgres") }, + ], + ]; + for (const [label, target] of directTargets) { + it.effect(`rejects --password for a ${label} target before announcing it`, () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const exit = yield* Effect.exit( + resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { output }), + ); + expect(failureText(exit)).toContain("TargetPasswordConflictError"); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect(`accepts an empty --password for a ${label} target`, () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + yield* resolveConnection(dir, inputFor(target, { password: Option.some("") })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + } + + it.effect("a linked target that matches the linked workdir reuses the saved pooler URL", () => { + const ref = "savedpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + ); + const output = mockOutput(); + const target = linkedTarget(ref); + const r = yield* resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { + projectHost: "invalid", + output, + }); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + password: "pw", + }); + expect(r.ref).toEqual(Option.some(ref)); + expect(r.target).toEqual(target); + expect(requests).toEqual([]); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("a linked target that is not the linked workdir ignores the saved pooler URL", () => { + const linkedRef = "abcdefghijklmnopqrst"; + const ref = "otherpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@stale.pooler.supabase.com:6543/postgres`, + ); + const r = yield* resolveConnection( + dir, + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + password: Option.some("pw"), + }), + { projectHost: "invalid", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + describe("network bans on the temporary login role", () => { + const ref = "banrecoveryrefabcdef"; + const bannedRequests = ( + requests: Array<{ readonly method: string; readonly path: string }>, + bans: ReadonlyArray, + ) => + stubManagementApi(requests, (method, path) => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPoolerConfigs(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { + return Response.json({ banned_ipv4_addresses: bans }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return Response.json({}); + } + return undefined; + }); + const failingConnect = (failures: number) => { + const attempts = { count: 0 }; + const layer = Layer.succeed(DbConnection, { + connect: () => + Effect.suspend(() => { + attempts.count += 1; + return attempts.count <= failures + ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) + : Effect.succeed(session); + }), + }); + return { attempts, layer }; + }; + const forkResolve = ( + input: DbConnectionInput, + connection: Layer.Layer, + output?: ReturnType, + ) => + Effect.gen(function* () { + const retries = yield* Queue.unbounded(); + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => + message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, + http: () => Effect.void, + }); + const dir = yield* withWorkdir(); + const fiber = yield* resolveConnection(dir, input, { + projectHost: "invalid", + dbConnection: connection, + debugLogger, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + output, + }).pipe(Effect.forkChild({ startImmediately: true })); + const advance = (retryMessages: number) => + Effect.gen(function* () { + for (let seen = 1; seen <= retryMessages; seen++) { + yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(fiber))); + yield* TestClock.adjust("1 minute"); + } + }); + return { fiber, advance }; + }); + + it.effect("reports the listed bans once the retries run out and removes nothing", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7", "198.51.100.2"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + message: expect.stringContaining("203.0.113.7, 198.51.100.2"), + suggestion: + "Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip 203.0.113.7,198.51.100.2 --experimental --yes.", + }), + ), + ); + expect(connection.attempts.count).toBe(9); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("connects without removing bans that are not blocking the caller", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("keeps retrying without removing anything when no ban is listed", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("removes the bans and says so once the caller holds a --yes approval", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + const output = mockOutput(); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + output, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests).toContainEqual({ + method: "DELETE", + path: `/v1/projects/${ref}/network-bans`, + }); + expect(output.stderrText).toContain("Removing network bans (203.0.113.7)...\n"); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("does not remove bans for an approval obtained by prompting", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + // The brand is type-only; only the consent module mints tokens in production. + const promptApproval = Option.some({ + action: "Push", + via: "prompt", + } as HostedMutationConsent); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: promptApproval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + }); +}); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 9f7272f916..fb76d33369 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -1,6 +1,6 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; -import { Crypto, Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Duration, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; @@ -8,7 +8,7 @@ import { type CliConfigKeyOrigin } from "../config/cli-config-key.ts"; import { CliConfigKeys } from "../config/cli-config-keys.ts"; import { CliConfigValues, type ResolvedCliConfig } from "../config/cli-config-values.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; +import { projectRefPicker } from "../config/project-ref.layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -16,6 +16,7 @@ import { ProfileFlag, WorkdirFlag, } from "./global-flags.ts"; +import { CommandTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; @@ -37,8 +38,7 @@ import { } from "./db-config.parse.ts"; import { DbConfigResolver, type DbConfigError } from "./db-config.service.ts"; import { loadProjectEnvValues, readDbToml } from "./db-config.toml-read.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; -import { rejectPasswordWithDirectTarget } from "./db-target-flags.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; @@ -46,6 +46,9 @@ import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; +import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; +import { isCheckoutLinked } from "../shared/config/temp-paths.ts"; +import { rejectTargetPassword } from "./target/target-password.ts"; const linkedEntries: ReadonlyArray> = [ { @@ -62,13 +65,6 @@ const linkedEntries: ReadonlyArray> = [ context: "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, - { - operationId: "v1-get-pooler-config", - kind: "required", - when: [{ flag: "project-ref", oneOf: [""] }], - context: - "an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", - }, { operationId: "v1-list-all-network-bans", kind: "best-effort", @@ -83,20 +79,19 @@ const linkedEntries: ReadonlyArray> = [ context: "after three failed attempts to connect through the pooler as the temporary login role", }, + ...projectRefPicker.operations, ]; /** * The same calls as {@link dbConfig} without its target condition, for a command that reaches the - * linked path some other way: `db diff --from linked`, a `--linked` flag with no default target, or - * a target prompt. + * linked path some other way, such as `db diff --from linked`. */ export const dbConfigLinked = block("db-config", linkedEntries); /** * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands * include it with `withDbTarget`. The container retry that `db dump` and `db pull` run is - * {@link dbConfigPoolerFallback}; `gen types` fetches the - * pooler config without `--project-ref`, so that command declares its own entry. + * {@link dbConfigPoolerFallback}. */ export const dbConfig = block( "db-config", @@ -155,6 +150,10 @@ export const dbConfigPoolerFallback = block( })), ); +/** Only a `--yes`-sourced approval may clear bans; a prompt answer never does, since removal clears every listed address. */ +const canRemoveNetworkBans = (input: DbConnectionInput): boolean => + Option.exists(input.networkBanRemoval, (approval) => approval.via === "yes"); + const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; @@ -239,26 +238,42 @@ const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( return { ...conn, user: role.role, password: role.password }; }); -const listAndUnban = Effect.fnUntraced(function* (ref: string) { +const listBans = Effect.fnUntraced(function* (ref: string) { const api = yield* (yield* CommandPlatformApiFactory).make; const bans = yield* api.v1.listAllNetworkBans({ ref }).pipe(Effect.catch(listBansErrorMapper)); - const addrs = bans.banned_ipv4_addresses; - if (addrs.length === 0) return; + return bans.banned_ipv4_addresses; +}); + +const removeBans = Effect.fnUntraced(function* (ref: string, addrs: ReadonlyArray) { + const output = yield* Output; + const api = yield* (yield* CommandPlatformApiFactory).make; + yield* output.raw(`Removing network bans (${addrs.join(", ")})...\n`, "stderr"); yield* api.v1 .deleteNetworkBans({ ref, ipv4_addresses: [...addrs], requester_ip: false }) .pipe(Effect.catch(unbanErrorMapper)); }); -// Verify-connect with backoff while the pooler refreshes the temp password. On attempt ≥ 3, -// clear any network ban on the requester. +const networkBannedError = (addrs: ReadonlyArray) => + new Errors.LinkedDbNetworkBannedError({ + message: `Network bans (${addrs.join(", ")}) may be blocking the connection. Removing them clears every listed address, so it needs --yes.`, + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip ${addrs.join(",")} --experimental --yes.`, + }); + +// Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look +// for network bans on the project: with approval they are removed, without it the last listing is +// reported if the retries run out. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", + removeNetworkBans: boolean, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; - const attempt = (n: number): Effect.Effect => + const attempt = ( + n: number, + listedBans: ReadonlyArray, + ): Effect.Effect => // The temp-role probe always targets the remote Supavisor pooler, so it connects with TLS // and honors `--dns-resolver`. Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( @@ -268,34 +283,43 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { return Effect.fail( - new Errors.DbConfigConnectTempRoleError({ - message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, - }), + listedBans.length > 0 + ? networkBannedError(listedBans) + : new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_ENV_VAR, + }), ); } - // From the 3rd failure onward, clear any network ban on the requester. Uses a - // deterministic backoff curve rather than jittered, since jitter only matters under - // concurrent pooler refreshes. - const unban = n >= 3 ? listAndUnban(ref) : Effect.void; + // Deterministic backoff rather than jittered, since jitter only matters under concurrent + // pooler refreshes. const delayMs = Math.min( Duration.toMillis(BACKOFF_INITIAL) * 1.5 ** (n - 1), Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { - // A transient ban-list/unban failure must not propagate out of the retry loop; log - // it to --debug, then discard. - yield* unban.pipe( - Effect.tapError((banError) => debug.debug(banError.message)), - Effect.ignore, - ); + // A transient ban-list/unban failure must not propagate out of the retry loop; log it + // to --debug, then discard. + const bans = + n < 3 + ? listedBans + : yield* listBans(ref).pipe( + Effect.flatMap((addrs) => + addrs.length > 0 && removeNetworkBans + ? removeBans(ref, addrs).pipe(Effect.as([])) + : Effect.succeed(addrs), + ), + Effect.catch((banError) => + debug.debug(banError.message).pipe(Effect.as(listedBans)), + ), + ); yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); - return yield* attempt(n + 1); + return yield* attempt(n + 1, bans); }); }), ); - return yield* attempt(1); + return yield* attempt(1, []); }); /** @@ -329,16 +353,6 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { } }; -/** - * A `--password` the caller bound on {@link DbConfigFlags} is passed to - * {@link resolveLinkedPassword} as the caller's password, so it wins over `SUPABASE_DB_PASSWORD` and - * the project `.env` just like the flag tier does, and no login role is minted for it. An - * explicitly empty `--password` asks for a temporary login role even when the env holds one; the - * flag binding drops empty strings, so this is the only way that request reaches the resolver. - */ -const passwordFlag = (password: Option.Option | undefined): Option.Option => - password ?? Option.none(); - const loadFailureToDbConfigError = ( error: E, ) => @@ -412,18 +426,19 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( // no `.temp/pooler-url` is saved; the resolve-time IPv6 path uses the saved URL only and // errors otherwise, so this defaults off. fetchFromApi = false, - // For an ad-hoc `--project-id` ref the saved `.temp/pooler-url` belongs to the + // For an ad-hoc `--project-ref` the saved `.temp/pooler-url` belongs to the // (possibly different) linked workdir, so ignore it and resolve the pooler for // `ref` from the Management API instead. ignoreSavedUrl = false, resolveVaultSecrets = true, + removeNetworkBans: boolean, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // Linked-path read: merge the `[remotes.]` override, so this matches the ref-aware read - // on the main linked branch rather than validating base config. For an ad-hoc `--project-id` - // ref, skip the saved workdir pooler URL because it belongs to the linked project, not - // necessarily the explicit ref. + // on the main linked branch rather than validating base config. For an ad-hoc `--project-ref`, + // skip the saved workdir pooler URL because it belongs to the linked project, not necessarily + // the explicit ref. const tomlValues = yield* readDbToml(fs, path, workdir, ref, { resolveVaultSecrets, }); @@ -463,7 +478,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( const withRole = yield* initLoginRole(ref, poolerConn); const finalUser = originalUser.endsWith(`.${ref}`) ? `${withRole.user}.${ref}` : withRole.user; const tempConn = { ...withRole, user: finalUser }; - yield* waitForTempRole(ref, tempConn, dnsResolver); + yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans); return Option.some(tempConn); }); @@ -488,27 +503,28 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio dnsResolver: "native" | "https", explicitPassword: Option.Option, options: { + /** Whether the temp-role retry may clear listed network bans; only a `--yes`-sourced approval sets it. */ + readonly removeNetworkBans: boolean; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** * Requests the Management API pooler-config fetch on an IPv4-only network - * independent of `adHocProjectRef`'s saved-URL semantics — see - * `DbConfigFlags.linkedProjectRef`'s doc comment. Set when the caller - * supplied an explicit ref (`--project-ref`/`--project-id`) rather than - * falling back to `.temp/project-ref`, so an unlinked or mismatched-tenant - * workdir still reaches the primary pooler config instead of dead-ending in - * the "run supabase link" IPv6 error. + * independent of `adHocProjectRef`'s saved-URL semantics. Set when the caller + * supplied an explicit `--project-ref` rather than falling back to + * `.temp/project-ref`, so an unlinked or mismatched-tenant workdir still reaches + * the primary pooler config instead of dead-ending in the "run supabase link" IPv6 error. */ readonly fetchPoolerFromApi?: boolean; /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ readonly hasPasswordFlag?: boolean; - } = {}, + }, ) { const { adHocProjectRef = false, resolveVaultSecrets = true, fetchPoolerFromApi = false, hasPasswordFlag = false, + removeNetworkBans, } = options; const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); const host = `db.${ref}.${projectHost}`; @@ -527,8 +543,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio return yield* initLoginRole(ref, base); } - // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-id` - // ref the command already holds a Management API token, so fall back to the API pooler config + // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-ref` + // the command already holds a Management API token, so fall back to the API pooler config // (and ignore the workdir's saved pooler URL) rather than failing with the IPv6 suggestion. An // explicit `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still rejects a @@ -544,6 +560,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio adHocProjectRef || fetchPoolerFromApi, adHocProjectRef, resolveVaultSecrets, + removeNetworkBans, ); if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ @@ -568,10 +585,11 @@ export const dbConfigResolverLayer = Layer.effect( const output = yield* Output; const dbConn = yield* DbConnection; const configValues = yield* CliConfigValues; + const commandTarget = yield* CommandTarget; // `resolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions that yield their // own `FileSystem`/`Path`/`DebugLogger`/`Output`/`DbConnection` (so bootstrap can call them // directly from its own ambient context). Calling them from here would otherwise leak those - // services into `resolve`/`resolvePoolerFallback`'s R (the interface promises `never`), so + // services into `resolveConnection`/`resolvePoolerFallback`'s R (the interface promises `never`), so // every call site below re-closes the gap by additionally providing this layer of // already-resolved values alongside `linkedDbResolverRuntimeLayer`. const localAmbientServices = Layer.mergeAll( @@ -592,7 +610,7 @@ export const dbConfigResolverLayer = Layer.effect( }; // Capture the ambient services the Management API stack needs, so the - // lazily-built linked stack is fully self-provided and `resolve`'s R stays + // lazily-built linked stack is fully self-provided and `resolveConnection`'s R stays // `never` (handler tests can mock this resolver without wiring the whole // management runtime). None of these resolves an access token — only the // platform API layer does, and that is built only on the `--linked` branch. @@ -606,13 +624,14 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(DebugFlag, yield* DebugFlag), // `linkedDbResolverRuntimeLayer`'s platform-API factory provides a DoH fetch layer that // reads `DnsResolverFlag`; snapshot it so the lazily built linked stack stays fully - // self-provided (`resolve`'s R stays `never`). + // self-provided (`resolveConnection`'s R stays `never`). Layer.succeed(DnsResolverFlag, yield* DnsResolverFlag), Layer.succeed(RuntimeInfo, yield* RuntimeInfo), Layer.succeed(Analytics, yield* Analytics), Layer.succeed(TelemetryRuntime, yield* TelemetryRuntime), Layer.succeed(Tty, yield* Tty), Layer.succeed(Output, yield* Output), + Layer.succeed(CommandTarget, commandTarget), // The per-command identity stitcher, shared with the linked stack's lazy platform-API // factory + linked-project cache. Provided to this layer by each command runtime. Layer.succeed(IdentityStitch, yield* IdentityStitch), @@ -628,13 +647,13 @@ export const dbConfigResolverLayer = Layer.effect( // Compile-time guard: if `linkedDbResolverRuntimeLayer`'s requirements ever // grow a service not captured above, this assignment fails to type-check (the // lazy `Effect.provide` in the `--linked` branch would otherwise leak that - // service into `resolve`'s R and only surface as a runtime panic). Mirrors the + // service into `resolveConnection`'s R and only surface as a runtime panic). Mirrors the // `_serviceCoverageCheck` pattern in `management-api-runtime.layer.ts`. const _ambientCoverageCheck: Layer.Layer = ambientLayer; void _ambientCoverageCheck; - // `resolve`'s R is `never`, so capture StackApi at layer build. + // `resolveConnection`'s R is `never`, so capture StackApi at layer build. const stackDatabaseConn = stackLocalDatabaseConn.pipe( Effect.provideService(CommandSettings, cliSettings), Effect.provideService(StackApi, stackApi), @@ -642,151 +661,75 @@ export const dbConfigResolverLayer = Layer.effect( Effect.provideService(Path.Path, path), ); - const resolve = (flags: DbConfigFlags) => + // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or remote-Docker + // setups, defaulting to 127.0.0.1. Only env feeds the hostname, so the config file stays unread. + const resolveLocalHost = Effect.gen(function* () { + const hostConfig = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + tolerateInvalid: true, + }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + return yield* getHostname(hostConfig).pipe( + Effect.provideService(RuntimeInfo, runtimeInfo), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.catchTag("CliConfigLoadError", (error) => + Effect.fail(new Errors.DbConfigLoadError({ message: error.message })), + ), + ); + }); + + const resolveDbUrl = (dbUrl: string, localHost: string, resolveVaultSecrets: boolean) => Effect.gen(function* () { - yield* rejectPasswordWithDirectTarget(flags.connType, flags.password); - const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; - // Config is read per branch, not unconditionally up front: the linked branch resolves - // the ref first and reads the `[remotes.]`-merged config (below). A base read here - // would validate base config (db.major_version, deno_version, …) before the ref is - // known, failing a linked run that should succeed once the ref is known. Only - // `--db-url`/`--local` read base config, since neither merges a remote block. - // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or - // remote-Docker setups, defaulting to 127.0.0.1. - // Only env feeds the hostname, so the config file stays unread here. - const hostConfig = yield* configValues - .load({ - workdir: cliSettings.workdir, - projectRef: Option.none(), - ignoreConfigFile: true, - tolerateInvalid: true, - }) - .pipe(Effect.mapError(loadFailureToDbConfigError)); - const localHost = yield* getHostname(hostConfig).pipe( - Effect.provideService(RuntimeInfo, runtimeInfo), + const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { + resolveVaultSecrets, + }); + // The project `.env*` files populate the environment that the libpq `PG*` fallbacks + // read. Layer the project env under the shell env (`loadProjectEnvValues` already excludes + // shell-set keys, so the shell still wins) and feed it to the parser. + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); + const conn = yield* parseConnectionString(dbUrl, yield* layeredParseEnv(projectEnv)).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.catchTag("CliConfigLoadError", (error) => - Effect.fail(new Errors.DbConfigLoadError({ message: error.message })), - ), ); - - // --db-url (direct) takes precedence. - if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { - const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { - resolveVaultSecrets, + if (conn === undefined) { + return yield* new Errors.DbConfigParseUrlError({ + // Redact the password component before echoing the URL back + // (CWE-209): a malformed `--db-url` often still carries a secret. + message: `failed to parse connection string: ${redactConnectionString(dbUrl)}`, }); - // The project `.env*` files populate the environment that the libpq `PG*` fallbacks - // read. Layer the project env under the shell env (`loadProjectEnvValues` already excludes - // shell-set keys, so the shell still wins) and feed it to the parser. - const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); - const conn = yield* parseConnectionString( - flags.dbUrl.value, - yield* layeredParseEnv(projectEnv), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - if (conn === undefined) { - return yield* new Errors.DbConfigParseUrlError({ - // Redact the password component before echoing the URL back - // (CWE-209): a malformed `--db-url` often still carries a secret. - message: `failed to parse connection string: ${redactConnectionString(flags.dbUrl.value)}`, - }); - } - // A multi-host URL stays remote: local disables TLS for every fallback host as well. - const singleHost = conn.fallbacks === undefined; - const legacyLocal = - singleHost && - isLocalDatabase( - conn.host, - localHost, - conn.port, - tomlValues.port, - tomlValues.shadowPort, - ); - // The stack backend publishes its database on a runtime-assigned port rather than - // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. - const stackConn = - singleHost && - (yield* currentStackBackend).kind === "stack" && - mayBeStackDatabaseHost(conn.host, localHost) - ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) - : undefined; - const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; - const isLocal = legacyLocal || onStack; - // A local direct URL fills an empty password from the local database's own - // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` - // still authenticates. - const localPassword = onStack ? stackConn.password : tomlValues.password; - return { - conn: - isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, - isLocal, - }; - } - - // --linked. The lazy Management API runtime (project-ref resolver + lazy platform API - // factory) is provided here at runtime so it is only built on this branch — - // `--local`/`--db-url` never touch it. The factory resolves the access token only on - // first use (minting a temp role), so a `--linked --password` invocation stays - // auth-free. - if (flags.connType === "linked") { - const linked = yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - // Load-or-fail with no prompt: use `loadProjectRef` (not the soft - // `resolveOptional`, which swallows a read error to `None`) — an unlinked workdir - // fails with a not-linked error, a bad ref with the invalid-ref error, and an - // unreadable ref file surfaces the filesystem problem, for every caller of this - // resolver (`test db --linked`, dump, declarative). - const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); - // The `[remotes.]`-merged config (e.g. an unsupported remote - // `db.major_version`/`edge_runtime.deno_version`) is validated as a pure config - // error before any network work. The base read in `resolve` above only validates - // remote `project_id`s, not the ref-merged block — so validate the merged config - // here, before the TCP probe / pooler / temp-role Management API calls, rather than - // letting those mask (or run side effects ahead of) the real config error. - yield* readDbToml(fs, path, cliSettings.workdir, ref, { - resolveVaultSecrets, - }); - const resolved = yield* resolveLinkedConn( - ref, - cliSettings.workdir, - cliSettings.projectHost, - cliSettings.poolerHost, - flags.dnsResolver, - passwordFlag(flags.password), - { - adHocProjectRef: flags.adHocProjectRef ?? false, - resolveVaultSecrets, - // An explicit ref (the eight `db` commands' `--project-ref`, or - // `gen types --project-id`) independently unlocks the Management API - // pooler fetch, regardless of `adHocProjectRef`'s saved-URL semantics - // — see `DbConfigFlags.linkedProjectRef`'s doc comment. - fetchPoolerFromApi: Option.isSome(flags.linkedProjectRef ?? Option.none()), - hasPasswordFlag: flags.password !== undefined, - }, - ); - // The linked-project telemetry cache (GET /v1/projects/{ref}) is not issued here: - // it's cached in a post-run hook, after the command's own API calls, so each linked - // command owns that GET in its post-run finalizer. Issuing it mid-resolve would - // reorder the request log ahead of the command's GETs. - return { conn: resolved, ref }; - }).pipe( - Effect.provide( - Layer.mergeAll( - linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), - localAmbientServices, - ), - ), - ); - // Surface the resolved ref so the caller can re-read config with a matching - // `[remotes.]` override applied (merged into the linked config). - return { conn: linked.conn, isLocal: false, ref: Option.some(linked.ref) }; } + // A multi-host URL stays remote: local disables TLS for every fallback host as well. + const singleHost = conn.fallbacks === undefined; + const legacyLocal = + singleHost && + isLocalDatabase(conn.host, localHost, conn.port, tomlValues.port, tomlValues.shadowPort); + // The stack backend publishes its database on a runtime-assigned port rather than + // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. + const stackConn = + singleHost && + (yield* currentStackBackend).kind === "stack" && + mayBeStackDatabaseHost(conn.host, localHost) + ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) + : undefined; + const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; + const isLocal = legacyLocal || onStack; + // A local direct URL fills an empty password from the local database's own + // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // still authenticates. + const localPassword = onStack ? stackConn.password : tomlValues.password; + return { + conn: isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, + isLocal, + }; + }); - // --local (default). + const resolveLocalStack = (localHost: string, resolveVaultSecrets: boolean) => + Effect.gen(function* () { const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { resolveVaultSecrets, }); @@ -806,39 +749,125 @@ export const dbConfigResolverLayer = Layer.effect( }; }); + // Validates the `[remotes.]`-merged config before any network work, so a config error is + // never masked by the TCP probe, pooler or login-role calls. The linked-project cache GET is + // left to each command's post-run hook so it stays after the command's own requests. + const resolveLinkedConnection = ( + ref: string, + options: { + readonly dnsResolver: "native" | "https"; + readonly password: Option.Option; + readonly resolveVaultSecrets: boolean; + readonly adHocProjectRef: boolean; + readonly fetchPoolerFromApi: boolean; + readonly hasPasswordFlag: boolean; + readonly removeNetworkBans: boolean; + }, + ) => + Effect.gen(function* () { + yield* readDbToml(fs, path, cliSettings.workdir, ref, { + resolveVaultSecrets: options.resolveVaultSecrets, + }); + return yield* resolveLinkedConn( + ref, + cliSettings.workdir, + cliSettings.projectHost, + cliSettings.poolerHost, + options.dnsResolver, + options.password, + { + adHocProjectRef: options.adHocProjectRef, + resolveVaultSecrets: options.resolveVaultSecrets, + fetchPoolerFromApi: options.fetchPoolerFromApi, + hasPasswordFlag: options.hasPasswordFlag, + removeNetworkBans: options.removeNetworkBans, + }, + ); + }); + + // Built per linked call so `--local`/`--db-url` never touch it; the platform API factory + // resolves the access token only when a temp role is minted, so `--password` stays auth-free. + const withLinkedRuntime = (effect: Effect.Effect) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), + localAmbientServices, + ), + ), + ); + + const resolveConnection = (input: DbConnectionInput) => + Effect.gen(function* () { + const { target } = input; + const linkedCheckout = + target.kind === "local" && (yield* isCheckoutLinked(fs, path, cliSettings.workdir)); + if (target.kind !== "linked") { + yield* rejectTargetPassword(target.kind, input.password, linkedCheckout); + } + const resolveVaultSecrets = input.resolveVaultSecrets ?? true; + if (target.kind === "linked") { + const conn = yield* withLinkedRuntime( + resolveLinkedConnection(target.ref, { + dnsResolver: input.dnsResolver, + password: input.password, + resolveVaultSecrets, + adHocProjectRef: !target.matchesLinkedRef, + fetchPoolerFromApi: target.source === "flag", + hasPasswordFlag: input.hasPasswordFlag ?? false, + removeNetworkBans: canRemoveNetworkBans(input), + }), + ); + return { conn, isLocal: false, ref: Option.some(target.ref), target }; + } + const localHost = yield* resolveLocalHost; + if (target.kind === "db-url") { + const resolved = yield* resolveDbUrl( + Redacted.value(target.url), + localHost, + resolveVaultSecrets, + ); + yield* commandTarget.announce(renderDbUrlTarget(resolved.conn.host, resolved.conn.port)); + return { ...resolved, target }; + } + const resolved = yield* resolveLocalStack(localHost, resolveVaultSecrets); + yield* commandTarget.announce(renderLocalTarget(resolved.conn.host, resolved.conn.port)); + return { + ...resolved, + conn: linkedCheckout + ? { ...resolved.conn, suggestionContext: { ...suggestionContext, linkedCheckout } } + : resolved.conn, + target, + }; + }); + // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct // host is reachable from the CLI process but not from inside Docker), it resolves the - // project's IPv4 transaction pooler and retries once. This exposes that pooler resolution - // for the dump handler to invoke on demand. Returns `None` when the path is not - // pooler-eligible (`--linked` only) or no pooler URL is configured, so the caller keeps the - // original container error. - const resolvePoolerFallback = (flags: DbConfigFlags) => + // project's IPv4 transaction pooler and retries once. Returns `None` for a local or `--db-url` + // target or when no pooler URL is configured, so the caller keeps the original container error. + const resolvePoolerFallback = (input: DbConnectionInput) => Effect.gen(function* () { - if (flags.connType !== "linked") return Option.none(); + const { target } = input; + if (target.kind !== "linked") return Option.none(); return yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - const refOpt = yield* projectRef.resolveOptional(flags.linkedProjectRef ?? Option.none()); - if (Option.isNone(refOpt)) return Option.none(); - const ref = refOpt.value; - if (!PROJECT_REF_PATTERN.test(ref)) return Option.none(); - const adHocProjectRef = flags.adHocProjectRef ?? false; const password = yield* resolveLinkedPassword( - ref, + target.ref, cliSettings.workdir, - passwordFlag(flags.password), - flags.password !== undefined, + input.password, + input.hasPasswordFlag ?? false, ); // Container-fallback: fetch the primary pooler config from the Management API when // no `.temp/pooler-url` is saved. return yield* resolvePoolerConn( - ref, + target.ref, cliSettings.workdir, cliSettings.poolerHost, - flags.dnsResolver, + input.dnsResolver, password, true, - adHocProjectRef, - flags.resolveVaultSecrets ?? true, + !target.matchesLinkedRef, + input.resolveVaultSecrets ?? true, + canRemoveNetworkBans(input), ); }).pipe( Effect.provide( @@ -854,27 +883,27 @@ export const dbConfigResolverLayer = Layer.effect( // so each connecting command inherits the hint without per-call-site wiring. const withSuggestion = (conn: PgConnInput): PgConnInput => ({ ...conn, - suggestionContext, + suggestionContext: conn.suggestionContext ?? suggestionContext, }); return DbConfigResolver.of({ - resolve: (flags) => - resolve(flags).pipe( + resolveConnection: (input) => + resolveConnection(input).pipe( Effect.provideService(CliConfigValues, configValues), Effect.tap((r) => Effect.annotateCurrentSpan("db.is_local", r.isLocal)), Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) })), - Effect.withSpan("DbConfig.resolve", { - attributes: { "db.conn_type": flags.connType ?? "local" }, + Effect.withSpan("DbConfig.resolveConnection", { + attributes: { "db.conn_type": input.target.kind }, }), ), - resolvePoolerFallback: (flags) => - resolvePoolerFallback(flags).pipe( + resolvePoolerFallback: (input) => + resolvePoolerFallback(input).pipe( Effect.provideService(CliConfigValues, configValues), Effect.tap((pooler) => Effect.annotateCurrentSpan("db.pooler.found", Option.isSome(pooler)), ), Effect.map(Option.map(withSuggestion)), Effect.withSpan("DbConfig.resolvePoolerFallback", { - attributes: { "db.conn_type": flags.connType ?? "local" }, + attributes: { "db.conn_type": input.target.kind }, }), ), }); diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index be1f96ffeb..6300c0e81d 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -23,19 +23,19 @@ import type { DbConfigPoolerLoginError, DbConfigUnbanNetworkError, DbConfigUnbanStatusError, - DbPasswordFlagsError, + LinkedDbNetworkBannedError, } from "./db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "./db-config.types.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; import type { CliConfigFlagConflictError, CliConfigValueError, } from "../config/cli-config.errors.ts"; +import type { TargetPasswordConflictError } from "./target/target.errors.ts"; /** Every error the resolver can raise across the direct / local / linked paths. */ export type DbConfigError = | Config.ConfigError | DbConfigParseUrlError - | DbPasswordFlagsError | DbConfigLoadError | CliConfigValueError | CliConfigFlagConflictError @@ -55,6 +55,8 @@ export type DbConfigError = | HttpBody.HttpBodyError | DbConfigIpv6Error | DbConfigConnectTempRoleError + | LinkedDbNetworkBannedError + | TargetPasswordConflictError | DbConfigPoolerLoginError | DbConnectError // The `--linked` path resolves the access token lazily via @@ -72,18 +74,24 @@ export type DbConfigError = // first API use, so its auth-required error surfaces through the resolver effect (folded into // `DbConfigError`) rather than a layer-build error channel. interface DbConfigResolverShape { - readonly resolve: (flags: DbConfigFlags) => Effect.Effect; + /** + * Connects to an already-resolved target and announces it: the local stack, a `--db-url` + * database, or the linked project's temp-role connection. + */ + readonly resolveConnection: ( + input: DbConnectionInput, + ) => Effect.Effect; /** * Resolves the IPv4 transaction pooler connection for a linked dump's container-level - * fallback. Returns `None` when the path is not pooler-eligible (`--linked` only) or no - * pooler URL is configured, so the caller keeps the original error. + * fallback. Returns `None` when the target is not the linked project or no pooler URL is + * configured, so the caller keeps the original error. */ readonly resolvePoolerFallback: ( - flags: DbConfigFlags, + input: DbConnectionInput, ) => Effect.Effect, DbConfigError>; } -/** Resolves a Postgres connection from the `--db-url` / `--local` / `--linked` flags. */ +/** Resolves a Postgres connection for a resolved `local`, `db-url` or `linked` target. */ export class DbConfigResolver extends Context.Service()( "supabase/cli/DbConfigResolver", ) {} diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 9f7914dc0c..b4a3d0c2ad 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -1,52 +1,7 @@ import type { Option } from "effect"; import type { PgConnInput } from "./db-connection.service.ts"; -import type { DbConnType } from "./db-target-flags.ts"; - -/** - * The connection-resolution flags shared by `db lint`, `db advisors`, and `test db`. - * - * `connType` records which selector flag the user explicitly set, derived from raw argv via - * `resolveDbTargetFlags`: - * - "db-url" → `--db-url` was set (read `dbUrl.value`) - * - "linked" → `--linked` was set (Management API path) - * - "local" → `--local` was set (explicit local path) - * - undefined → no selector was set; resolver defaults to local - * - * `--db-url` / `--linked` / `--local` are mutually exclusive. `dnsResolver` carries the global - * `--dns-resolver` value, used when the resolver opens its own remote connection (the linked - * pooler temp-role probe); the handler passes the same value to its primary `connect`. - */ -export interface DbConfigFlags { - readonly dbUrl: Option.Option; - readonly connType: DbConnType | undefined; - readonly dnsResolver: "native" | "https"; - /** - * Whether config resolution should decrypt and materialize `[db.vault]` values. - * Defaults to true; `db push --skip-vault` is the only caller that disables it. - */ - readonly resolveVaultSecrets?: boolean; - /** - * The `--password`/`-p` flag value, passed so the resolver can reject it with `--db-url` or - * `--local`. The linked path reads the password itself through `CliConfigValues`, where the - * flag beats `SUPABASE_DB_PASSWORD`. - */ - readonly password?: Option.Option; - /** - * Optional explicit linked project ref override, letting e.g. `gen types --project-id ` - * reuse the linked resolver's temp-role and pooler fallback without the workdir being linked. - * Shares only `SUPABASE_PROJECT_ID`'s ref-resolution precedence (flag > env > - * `.temp/project-ref`) — it never drives the local container id or pg-delta project id the way - * that env var does. `None` preserves the normal `--linked` fallback. - */ - readonly linkedProjectRef?: Option.Option; - /** - * Marks `linkedProjectRef` as an ad-hoc remote target (e.g. `gen types --project-id `) - * rather than the current linked workdir, so the resolver must not reuse the saved - * `.temp/pooler-url`, fetching pooler config from the Management API instead. Absent for the - * normal `--linked` path, which may reuse it. - */ - readonly adHocProjectRef?: boolean; -} +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; /** * A resolved Postgres connection plus whether it points at the local stack. `isLocal` decides @@ -62,3 +17,20 @@ export interface ResolvedDbConfig { */ readonly ref?: Option.Option; } + +/** What {@link DbConfigResolver.resolveConnection} needs: the resolved target plus how to authenticate. */ +export interface DbConnectionInput { + readonly target: ResolvedTarget; + readonly dnsResolver: "native" | "https"; + /** Rejected unless the target is the linked project. */ + readonly password: Option.Option; + readonly resolveVaultSecrets?: boolean; + /** Set when the command binds `--password`, so the withheld-password notice can name it. */ + readonly hasPasswordFlag?: boolean; + /** A `--yes`-sourced `Some` lets the temp-role retry clear network bans; otherwise a ban surfaces as `LinkedDbNetworkBannedError`. */ + readonly networkBanRemoval: Option.Option; +} + +export interface ResolvedDbConnection extends ResolvedDbConfig { + readonly target: ResolvedTarget; +} diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index a905cb17a4..71cf5e1cb7 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -8,12 +8,12 @@ import { ConnectionError, SqlError } from "effect/sql/SqlError"; import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; import { - SUGGEST_LOCAL_STACK, connectFailureMessage, connectSuggestion, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, + localStackSuggestion, } from "./connect-errors.ts"; import { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts"; import { makePoolSqlClient } from "./db-connection.pool-client.ts"; @@ -199,13 +199,14 @@ export function batchFailureError( error: Error, batch: { readonly completed: number; readonly outcome: BatchOutcome } | undefined, isLocal: boolean, + linkedCheckout?: boolean, ): DbExecError | DbConnectError { if (batch === undefined || batch.outcome === "unsent") { return new DbConnectError({ message: `${BATCH_CONNECTION_LOST}: ${error.message}`, // The checkout failure a tick earlier carries this same hint, so losing the // connection mid-batch must not silently drop it. - ...(isLocal ? { suggestion: SUGGEST_LOCAL_STACK } : {}), + ...(isLocal ? { suggestion: localStackSuggestion(linkedCheckout) } : {}), }); } const mapped = toExecError(error); @@ -960,7 +961,16 @@ const connect = ( resume(Effect.void); return; } - resume(Effect.fail(batchFailureError(error, batchQuery, options.isLocal))); + resume( + Effect.fail( + batchFailureError( + error, + batchQuery, + options.isLocal, + cfg.suggestionContext?.linkedCheckout, + ), + ), + ); }; batchQuery = new PgBatchQuery(statements, finish); try { diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index 71d3983532..ab5c5e3be5 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -791,6 +791,19 @@ describe("batchFailureError", () => { expect("suggestion" in remote).toBe(false); }); + it("adds the --linked pointer to the local-stack hint on a linked checkout", () => { + const error = batchFailureError( + new Error("socket died"), + { completed: 0, outcome: "unsent" }, + true, + true, + ); + + expect(error).toMatchObject({ + suggestion: `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --linked.`, + }); + }); + it("keeps a partially written batch on the statement path, blaming statement 0", () => { // A poisoned batch is corked, so `completed` stays 0 and statement 0 is blamed. const error = batchFailureError( diff --git a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts index bda9747d08..ac18f4d10e 100644 --- a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts +++ b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { CliOutput, Command } from "effect/cli"; import { rootCommandForFeatures } from "../cli/root.ts"; @@ -54,10 +54,22 @@ const selectorArgs = (selector: "db-url" | "local" | "none") => ? ["--local"] : []; -const runCommand = (entry: WalkedCommand, selector: "db-url" | "local" | "none") => +const runCommand = ( + entry: WalkedCommand, + selector: "db-url" | "local" | "none", + options: { readonly linked?: boolean } = {}, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-password-rejection-" }); + if (options.linked === true) { + yield* fs.makeDirectory(path.join(home, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + } const args = [ ...entry.path, ...requiredFlagArgs(entry), @@ -103,18 +115,18 @@ describe("--password with a direct database target", () => { const failure = yield* runCommand(entry, selector); expect(failure).toMatchObject({ - _tag: "DbPasswordFlagsError", + _tag: "TargetPasswordConflictError", message: selector === "db-url" ? "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" - : "--password can't be used with --local. The local database uses [db].password from supabase/config.toml.", + : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", }); }), ); } } - it.effect("explains that migration squash defaults to local when --password has no target", () => + it.effect("rejects migration squash --password when the workspace target is local", () => Effect.gen(function* () { const entry = passwordCommands.find( (candidate) => candidate.path.join(" ") === "migration squash", @@ -123,9 +135,25 @@ describe("--password with a direct database target", () => { const failure = yield* runCommand(entry!, "none"); expect(failure).toMatchObject({ - _tag: "DbPasswordFlagsError", + _tag: "TargetPasswordConflictError", + message: + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", + }); + }), + ); + + it.effect("points a local --password rejection at --linked when the checkout is linked", () => + Effect.gen(function* () { + const entry = passwordCommands.find( + (candidate) => candidate.path.join(" ") === "migration list", + ); + expect(entry).toBeDefined(); + const failure = yield* runCommand(entry!, "local", { linked: true }); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", message: - "migration squash targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.", + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml. To use the linked project instead, pass --linked.", }); }), ); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 3dc830dc87..d19327a994 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -1,26 +1,25 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; -import { - DebugFlag, - DnsResolverFlag, - NetworkIdFlag, - resolveYesWithProjectEnv, -} from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { CliConfigKeys } from "../config/cli-config-keys.ts"; import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { bold } from "./colors.ts"; -import { promptYesNo } from "./prompt-yes-no.ts"; import { ipv6Suggestion, isIPv6ConnectivityError } from "./connect-errors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; +import { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { flagsForTarget } from "./target/target-flags.ts"; import { resolveDbImage } from "./db-image.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { loadProjectEnvValues, readDbToml, resolveDeclarativeDir } from "./db-config.toml-read.ts"; -import type { DbConnType } from "./db-target-flags.ts"; import { makeDir } from "./make-dir.ts"; import { toPostgresURL } from "./postgres-url.ts"; import { buildLocalDbContainerInputs } from "./db-bootstrap/local-container-inputs.ts"; @@ -45,6 +44,7 @@ import { import { emitPoolerFallbackWarning, isDirectLinkedHost, + orNoPoolerFallback, runWithPoolerFallback, } from "../commands/db/shared/pooler-fallback.ts"; import { dumpSchemaScript } from "./pg-dump.scripts.ts"; @@ -60,12 +60,7 @@ import { currentStackBackend } from "./stack-backend.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase } from "./stack-shadow.ts"; import type { DbPullFlags as DbPullCommandFlags } from "../commands/db/pull/pull.command.ts"; -import { - DbPullDumpError, - DbPullEngineConflictError, - DbPullTargetFlagsError, - DbPullWriteError, -} from "../commands/db/pull/pull.errors.ts"; +import { DbPullDumpError, DbPullWriteError } from "../commands/db/pull/pull.errors.ts"; import { DbPullInSyncError, DbPullMigrationConflictError } from "./db-pull-run.errors.ts"; import { listRemoteMigrations, @@ -76,10 +71,13 @@ import { updateMigrationHistory } from "../commands/db/pull/pull.sync.ts"; /** * `runDbPull`'s parameter type: the `db pull` command flags without the tombstoned - * `--use-pg-delta`, so an in-process caller (`pull`) can build one without reaching into - * `commands/db/**` directly. + * `--use-pg-delta` and the target selectors, so an in-process caller (`pull`) can build one + * without reaching into `commands/db/**` directly. */ -export type DbPullRunFlags = Omit; +export type DbPullRunFlags = Omit< + DbPullCommandFlags, + "usePgDelta" | "dbUrl" | "linked" | "local" | "projectRef" +>; /** * Explains the in-sync non-zero exit instead of the generic "Try rerunning the command with @@ -93,11 +91,10 @@ const MIGRATION_FILE_MODE = 0o644; export type DbPullInvoke = { /** - * Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for an - * in-process caller that already ran its own aggregated confirmation. - * `undefined` keeps the existing resolution. + * The consent an in-process caller already collected for the remote history update; `Some` + * skips this run's own question. */ - readonly assumeYes?: boolean; + readonly preapproved?: Option.Option; /** Project `.env*` values the caller already loaded; `undefined` loads them here. */ readonly projectEnv?: Record; }; @@ -116,13 +113,14 @@ export type DbPullOutcome = }; /** - * Runs `db pull`'s target resolution and pull (declarative or migration mode) - * without emitting the final `output.success`/"Finished" line — the caller - * (`dbPull` for the standalone command, or an in-process orchestrator) - * owns that emission based on the returned {@link DbPullOutcome}. + * Runs `db pull` against an already-resolved target (declarative or migration mode) without + * emitting the final `output.success`/"Finished" line — the caller (`dbPull` for the + * standalone command, or an in-process orchestrator) owns that emission based on the returned + * {@link DbPullOutcome}. */ export const runDbPull = Effect.fn("db.pull.run")(function* ( flags: DbPullRunFlags, + target: ResolvedTarget, invoke?: DbPullInvoke, ) { const output = yield* Output; @@ -138,71 +136,25 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const debug = yield* DebugFlag; const configValues = yield* CliConfigValues; - // `--yes` or `SUPABASE_YES`. The project `.env` is loaded before the migration - // history prompt, so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms - // the native initial-migra history repair too. const projectEnv = invoke?.projectEnv ?? (yield* loadProjectEnvValues(fs, path, cliSettings.workdir)); - const yes = yield* resolveYesWithProjectEnv(projectEnv); + const preapproved = invoke?.preapproved ?? Option.none(); - let linkedRefForCache: string | undefined; + const connType = target.kind; + // `--local`/`--db-url` never merge a `[remotes.*]` block, so only a linked target has a ref. + const linkedRef = target.kind === "linked" ? target.ref : undefined; return yield* Effect.gen(function* () { const name = Option.getOrElse(flags.name, () => "remote_schema"); const useDeclarative = Option.getOrElse(flags.declarative, () => false); - // Mutually exclusive flag groups: `[db-url linked local]`, `[declarative diff-engine]`. - // "set" means the flag was explicitly passed. - const targetSet: Array = []; - if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); - if (Option.isSome(flags.linked)) targetSet.push("linked"); - if (Option.isSome(flags.local)) targetSet.push("local"); - if (targetSet.length > 1) { - return yield* new DbPullTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }); - } - if (Option.isSome(flags.declarative) && Option.isSome(flags.diffEngine)) { - return yield* new DbPullEngineConflictError({ - message: - "if any flags in the group [declarative diff-engine] are set none of the others can be; [declarative diff-engine] were all set", - }); - } - - const connType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : Option.isSome(flags.local) - ? "local" - : "linked"; yield* Effect.annotateCurrentSpan({ "db.conn_type": connType, "db.pull.mode": useDeclarative ? "declarative" : "migration", }); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbPullTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Pre-load the ref and re-read config here, before `resolver.resolve()` below, so the - // override print and merged-config validation happen before the connection attempt: a - // `resolve()` failure (bad password, unreachable host, network-ban lookup, …) must still - // tell the user which `[remotes.*]` block matched. `--local`/`--db-url` never merge a - // remote block, so only the linked path pre-resolves a ref. - let linkedRef: string | undefined; - if (connType === "linked") { - const projectRefResolver = yield* ProjectRefResolver; - linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - // Cache the ref the moment it's known, not after `toml`/`localInputs` below resolve - // (both fallible): the project cache should still be written even when a later step - // (config validation, connection, the pull itself) fails. - linkedRefForCache = linkedRef; - } + // Read the config for the matching `[remotes.]` block before connecting, so a + // connection failure still reports which override matched. const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); @@ -224,20 +176,27 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( debug, // So the shadow's own container spec reflects the matching `[remotes.]` // override, same as `toml` above. - connType === "linked" ? linkedRef : undefined, + linkedRef, ); - const resolved = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const connectionInput = { + target, dnsResolver, password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - }); - if (linkedRef === undefined) { - linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); - } - if (linkedRef !== undefined) linkedRefForCache = linkedRef; + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, + }; + const resolved = yield* resolver.resolveConnection(connectionInput); + const requireHistoryConsent = (migrations: number) => + Option.isSome(preapproved) + ? Effect.void + : requireTargetMutationConsent(resolved, { + action: "Update the remote migration history table", + details: [ + `Write ${migrations} migration file${migrations === 1 ? "" : "s"} to supabase/migrations.`, + "Record them as applied in the remote migration history.", + ], + }).pipe(Effect.asVoid); const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, @@ -270,17 +229,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }) && isIPv6ConnectivityError(error.message) ) { - // A pooler resolution failure is treated as "no fallback" (re-fail the - // original diff error), not surfaced as its own error. - const pooler = yield* resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - }) - .pipe(Effect.orElseSucceed(() => Option.none())); + const pooler = yield* orNoPoolerFallback( + resolver.resolvePoolerFallback(connectionInput), + ); if (Option.isSome(pooler)) { yield* emitPoolerFallbackWarning(resolved.conn.host); return yield* attempt({ @@ -309,13 +260,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const diffEngine = usePgDeltaDiff ? "pg-delta" : "migra"; const pgDeltaDebug = yield* isPgDeltaDebugEnabled; - // Connectivity check, run before dialing. return yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(resolved.conn, { isLocal: resolved.isLocal, dnsResolver, @@ -331,9 +277,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( target, schema: flags.schema, formatOptions, - ...(connType === "linked" && linkedRef !== undefined - ? { projectRef: linkedRef } - : {}), + ...(linkedRef !== undefined ? { projectRef: linkedRef } : {}), debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); @@ -383,7 +327,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( path, path.join(cliSettings.workdir, "supabase", "migrations"), ); - const sync = reconcileMigrations(remote, local, connType === "local"); + const sync = reconcileMigrations(remote, local, flagsForTarget(target)); if (sync.kind === "conflict") { return yield* new DbPullMigrationConflictError({ message: @@ -402,6 +346,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( let seedWroteBytes = false; if (seededFromDump) { + yield* requireHistoryConsent(1); yield* makeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new DbPullWriteError({ message: cause.message })), ); @@ -498,15 +443,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( isLocal: resolved.isLocal, projectHost: cliSettings.projectHost, resolvePooler: () => - resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - }) - .pipe(Effect.orElseSucceed(() => Option.none())), + orNoPoolerFallback(resolver.resolvePoolerFallback(connectionInput)), runWithConn: runSchemaDump, reprintOnRetry: Effect.void, }); @@ -644,6 +581,11 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }); } + // The initial migra pull asked before its dump wrote the file. + if (!seededFromDump) { + yield* requireHistoryConsent(usePgDeltaDiff ? (diffOutcome.files?.length ?? 0) : 1); + } + // Build the list of migration files to record in the remote history. The // migra engine writes exactly one file (the dump-seeded or freshly written // migrationPath); the pg-delta engine writes one ordered file per @@ -734,34 +676,18 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ); } - // Prompt to update the remote migration history table. Returns the default - // (`true`) on `--yes`, on an empty non-interactive stdin, or on any prompt error — - // it never fails the command. - let remoteHistoryUpdated = false; - const updateHistoryTitle = "Update remote migration history table?"; - // `invoke?.assumeYes` overrides this resolution entirely for an in-process - // caller that already ran its own aggregated confirmation. Otherwise, honors - // `--yes`, scans piped stdin on a non-TTY before falling back to the default, - // and otherwise prompts on a real TTY. - const shouldUpdate = - invoke?.assumeYes !== undefined - ? invoke.assumeYes - : yield* promptYesNo(output, yes, updateHistoryTitle, true); - if (shouldUpdate) { - // The migration file(s) in `writtenMigrations` are already on disk at this point, so - // a failure here must still report them as written: re-raise the same - // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. - yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( - Effect.mapError( - (cause) => - new DbPullWriteError({ - message: cause.message, - writtenSoFar: writtenMigrations.map((written) => written.path), - }), - ), - ); - remoteHistoryUpdated = true; - } + // The migration file(s) in `writtenMigrations` are already on disk at this point, so + // a failure here must still report them as written: re-raise the same + // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. + yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( + Effect.mapError( + (cause) => + new DbPullWriteError({ + message: cause.message, + writtenSoFar: writtenMigrations.map((written) => written.path), + }), + ), + ); return { kind: "migration", @@ -769,17 +695,13 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // writes one file per unit); `dbPull`'s emission derives the released `schemaWritten` // string field from its first entry. schemaFiles: writtenMigrations.map((written) => written.path), - remoteHistoryUpdated, + remoteHistoryUpdated: true, engine: diffEngine, } as const; }), ); }).pipe( - Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, - ), - ), + Effect.ensuring(linkedRef !== undefined ? linkedProjectCache.cache(linkedRef) : Effect.void), Effect.ensuring(telemetryState.flush), ); }); diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index e00eaff26d..a279533d37 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -1,14 +1,6 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; -import { promptYesNo } from "./prompt-yes-no.ts"; -import { - SEED_CANCELLED_MESSAGE, - type DbSeedInput, - confirmSeedIntoMatchedRemote, - seedCancelledSuggestion, - seedConsentDryRunNote, -} from "./seed-remote-consent.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; +import type { DbSeedPlan } from "./seed-remote-consent.ts"; import { Output } from "../shared/output/output.service.ts"; import { listLocalMigrations } from "./migration-list.ts"; import { @@ -21,7 +13,6 @@ import { import { type SeedFile, getPendingSeeds, seedData } from "./seed-ops.ts"; import { DbPushApplyError, - DbPushCancelledError, DbPushMissingLocalError, DbPushMissingRemoteError, DbPushRolesError, @@ -31,6 +22,8 @@ import type { DbTomlValues } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { applyMigrations, seedGlobals } from "./migration-apply.ts"; import { listRemoteMigrations, suggestRevertHistory } from "./migration-history.ts"; +import { requireTargetMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { upsertVaultSecrets } from "./vault.ts"; const CUSTOM_ROLES_PATH = "supabase/roles.sql"; @@ -45,6 +38,13 @@ const confirmSeedAll = (seeds: ReadonlyArray): string => .map((seed) => ` • ${bold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) .join(""); +const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? "" : "s"}`; + +const joinParts = (parts: ReadonlyArray): string => + parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; + +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + const applyError = (message: string) => new DbPushApplyError({ message }); /** @@ -62,14 +62,8 @@ export interface DbPushCoreInput { readonly projectRef: string; readonly conn: PgConnInput; readonly isLocal: boolean; - /** - * Whether `--local` (not `--db-url`/`--linked`) was the explicit target selector, distinct - * from `isLocal` (whether the resolved connection happens to point at a local address, e.g. a - * `--db-url` pointing at `127.0.0.1`). Only feeds the "missing local migrations" repair - * suggestion's `--local` flag; `bootstrap` never selects `--local`, so it is always `false` - * there. - */ - readonly repairSuggestsLocalFlag: boolean; + /** Flags that point the "missing local migrations" repair suggestion at the pushed target. */ + readonly repairTargetFlags: string; /** * Gates the "DRY RUN: …" heads-up line, the "Would push/seed/create …" plan, and the JSON * `dryRun` field. @@ -78,13 +72,13 @@ export interface DbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; - readonly seed: DbSeedInput; + readonly seed: DbSeedPlan; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ readonly toml: DbTomlValues; - /** Already resolved confirm-prompt default, e.g. via `resolveYesWithProjectEnv`. */ - readonly yes: boolean; + /** Consent the caller already obtained for this push; `None` asks once after the plan is known (never for a local database). */ + readonly preapproved: Option.Option; /** * Standalone `db push` emits a `--output-format` json/stream-json success result for its own * invocation; `bootstrap` suppresses it since it emits its own top-level result instead. @@ -103,7 +97,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn projectRef, conn, isLocal, - repairSuggestsLocalFlag, + repairTargetFlags, dryRun, includeAll, includeRoles, @@ -111,7 +105,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn includeVault, dnsResolver, toml, - yes, + preapproved, emitStructuredResult, seed, } = input; @@ -124,12 +118,10 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); } - const databaseName = isLocal ? "local database" : "remote database"; const statusTarget = isLocal ? "Local database" : "Remote database"; yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); let pending: ReadonlyArray = []; @@ -146,7 +138,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn if (result.kind === "missing-local") { return yield* new DbPushMissingLocalError({ message: ERR_MISSING_LOCAL, - suggestion: suggestRevertHistory(result.versions, repairSuggestsLocalFlag), + suggestion: suggestRevertHistory(result.versions, repairTargetFlags), }); } if (result.kind === "missing-remote") { @@ -166,13 +158,13 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn let seeds: ReadonlyArray = []; if (includeSeed) { - if (!seed.enabled) { + if (!seed.input.enabled) { yield* output.raw( `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, "stderr", ); } else { - seeds = yield* getPendingSeeds(session, fs, path, seed.sqlPaths, workdir); + seeds = yield* getPendingSeeds(session, fs, path, seed.input.sqlPaths, workdir); } } @@ -223,35 +215,41 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn if (seeds.length > 0) { yield* output.raw("Would seed these files:\n", "stderr"); yield* output.raw(confirmSeedAll(seeds), "stderr"); - if (seed.consent !== undefined) yield* seedConsentDryRunNote(seed.consent, yes); } } else { - const seedConsented = seeds.length > 0 && seed.consent !== undefined; - if (seeds.length > 0 && seed.consent !== undefined) { - const consented = yield* confirmSeedIntoMatchedRemote({ - command: "push", - target: seed.consent, - files: seeds.map((s) => s.path), - yes, - }); - if (!consented) { - return yield* new DbPushCancelledError({ - message: SEED_CANCELLED_MESSAGE, - suggestion: seedCancelledSuggestion("push"), - }); - } + if (Option.isNone(preapproved)) { + const parts = [ + ...(globals.length > 0 ? ["create custom roles"] : []), + ...(pending.length > 0 ? [`apply ${plural(pending.length, "migration")}`] : []), + ...(seeds.length > 0 ? [`seed ${plural(seeds.length, "file")}`] : []), + ]; + const seedDetail = seed.detail(seeds.map((s) => s.path)); + yield* requireTargetMutationConsent( + { conn, isLocal }, + { + action: capitalize(joinParts(parts)), + details: [ + ...(globals.length > 0 ? [`Create custom roles from ${bold(globals[0]!)}`] : []), + ...(pending.length > 0 + ? [ + `Apply these migrations:\n${confirmPushAll(pending.map((p) => path.basename(p))).trimEnd()}`, + ...(includeVault && vaultSecrets.some((secret) => secret.resolved) + ? ["Update vault secrets from config.toml"] + : []), + ] + : []), + ...(seeds.length > 0 + ? [ + `Seed these files:\n${confirmSeedAll(seeds).trimEnd()}`, + ...(seedDetail === undefined ? [] : [capitalize(seedDetail)]), + ] + : []), + ], + }, + ); } if (globals.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - "Do you want to create custom roles in the database cluster?", - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } yield* seedGlobals( session, fs, @@ -262,15 +260,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (pending.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } if (includeVault) { yield* upsertVaultSecrets(session, vaultSecrets); } @@ -280,17 +269,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (seeds.length > 0) { - const ok = - seedConsented || - (yield* promptYesNo( - output, - yes, - `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, - true, - )); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } yield* seedData(session, fs, workdir, path, seeds, applyError); } else if (includeSeed) { yield* output.raw("Seed files are up to date.\n", "stderr"); diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts deleted file mode 100644 index 007426b9a4..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * Flag-presence helpers for the `--db-url / --linked / --local` target selection shared by - * `db lint`, `db advisors`, and `test db`. - * - * Parsed flags carry no "was this explicitly set" bit, so presence is re-derived from raw argv, - * skipping the value token of space-form value flags (`--schema --linked` does not set `--linked`). - */ - -import { Effect, Option } from "effect"; - -import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; -import { DbPasswordFlagsError } from "./db-config.errors.ts"; - -export type DbConnType = "db-url" | "linked" | "local"; - -export interface DbTargetSelection { - /** Explicitly-set selector flags, alphabetically sorted. */ - readonly setFlags: ReadonlyArray; - /** db-url > local > linked; `undefined` when no selector was set (callers default to local). */ - readonly connType: DbConnType | undefined; -} - -/** - * Long flags that consume the next token in space form. Also used by `extractChangedFlagNames` - * across every command's argv, so it lists every value flag under `commands/`; the unit test - * enforces coverage for directly-declared flags, helper-built ones are listed by hand. - */ -export const VALUE_CONSUMING_LONG_FLAGS = new Set([ - "db-url", - "password", // db push/pull/dump/remote (short -p) - "sql-paths", - "schema", - "level", - "fail-on", - "type", - "output-dir", - "cache-control", - "content-type", - "jobs", - "output", - "output-format", - "profile", - "workdir", - "network-id", - "dns-resolver", - "agent", - // `--log-level` and `--completions` are unlisted: the real parse or an early exit precedes - // every scan here. - "add-domains", - "algorithm", - "attribute-mapping-file", - "auth", - "config", - "custom-hostname", - "db-allow-cidr", - "db-password", - "db-unban-ip", - "desired-subdomain", - "diff-engine", - "domains", - "env-file", - "exclude", - "exp", - "exposure", - "file", - "from", - "from-backup", - "git-branch", - "import-map", - "inspect-mode", - "instances", - "kind", - "lang", - "last", - "metadata-file", - "metadata-url", - "name", - "name-id-format", - "notify-url", - "org-id", - "override-name", - "payload", - "plan", - "postgres-engine", - "project-id", - "project-ref", - "query-timeout", - "region", - "release-channel", - "remote-label", - "remove-domains", - "role", - "runtime", - "size", - "source", - "status", - "sub", - ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, - "tail", - "template", - "timestamp", - "to", - "token", - "valid-for", - "version", - // Built by `issueOptionalTextFlag`, invisible to the static scan. - "additional-context", - "area", - "command", - "actual-output", - "expected-behavior", - "reproduce", - "crash-report-id", - "docker-services", - "problem", - "proposed-solution", - "alternatives", - "link", - "issue-type", - "improvement", - "capability", - "service", - "since", - "stack", - "stack-id", - "preparation", -]); - -/** Single-character short flags (no `-` prefix) that consume the next token. */ -export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ - "s", // --schema / -s - "o", // --output / -o - "p", // --password / -p (migration list, db push/pull/dump/remote) - "j", // --jobs / -j (storage cp) - "f", // --file / -f (db dump/diff/query, db schema declarative sync) - "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) - "x", // --exclude / -x (start, db dump) -]); - -/** - * Which of `--linked` / `--local` were explicitly set (including `--no-` forms), sorted, for the - * mutually-exclusive-flag error of `seed buckets` and `storage ls/cp/mv/rm`. - */ -export function changedLinkedLocalFlags(args: ReadonlyArray): ReadonlyArray { - let linked = false; - let local = false; - let skipNext = false; - - for (const token of args) { - if (skipNext) { - skipNext = false; - continue; - } - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - if (name === "linked" || name === "no-linked") { - linked = true; - continue; - } - if (name === "local" || name === "no-local") { - local = true; - continue; - } - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) skipNext = true; - continue; - } - - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(token.charAt(1))) { - skipNext = true; - } - } - } - - const setFlags: Array = []; - if (linked) setFlags.push("linked"); - if (local) setFlags.push("local"); - return setFlags; -} - -/** Resolves the DB target selection from raw args; `setFlags` is sorted so conflict text is stable. */ -export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelection { - let dbUrlChanged = false; - let linkedChanged = false; - let localChanged = false; - - let skipNext = false; - for (const token of args) { - // A pending value is consumed even when it is "--". - if (skipNext) { - skipNext = false; - continue; - } - - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - - if (name === "db-url") { - dbUrlChanged = true; - if (isBare) skipNext = true; - continue; - } - if (name === "linked" || name === "no-linked") { - linkedChanged = true; - continue; - } - if (name === "local" || name === "no-local") { - localChanged = true; - continue; - } - - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) { - skipNext = true; - } - continue; - } - - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - const shortName = token.charAt(1); - // `-svalue` carries its value attached; only bare `-s` skips the next token. - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(shortName)) { - skipNext = true; - } - } - } - - const setFlags: Array = []; - if (dbUrlChanged) setFlags.push("db-url"); - if (linkedChanged) setFlags.push("linked"); - if (localChanged) setFlags.push("local"); - - let connType: DbConnType | undefined; - if (dbUrlChanged) { - connType = "db-url"; - } else if (localChanged) { - connType = "local"; - } else if (linkedChanged) { - connType = "linked"; - } - - return { setFlags, connType }; -} - -/** - * `--password` only authenticates against a linked project, so it fails when paired with a target - * that carries its own credentials (`--db-url`) or reads them from config (`--local`, also the - * default when no selector is set). `localByDefaultFor` names the command whose unset selector - * fell back to local, so the message can point at `--linked` instead of a `--local` the user never typed. - */ -export const rejectPasswordWithDirectTarget = ( - connType: DbConnType | undefined, - password: Option.Option | undefined, - options: { readonly localByDefaultFor?: string } = {}, -): Effect.Effect => { - if ( - password === undefined || - Option.isNone(Option.filter(password, (value) => value.length > 0)) || - connType === "linked" - ) { - return Effect.void; - } - const target = connType ?? "local"; - const message = - target === "db-url" - ? "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" - : options.localByDefaultFor !== undefined - ? `${options.localByDefaultFor} targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.` - : "--password can't be used with --local. The local database uses [db].password from supabase/config.toml."; - return Effect.fail(new DbPasswordFlagsError({ message })); -}; diff --git a/apps/cli/src/command-internal/db-target-flags.unit.test.ts b/apps/cli/src/command-internal/db-target-flags.unit.test.ts deleted file mode 100644 index aebc2584e2..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Option, Path } from "effect"; -import { fileURLToPath } from "node:url"; -import { CLI_CONFIG_FLAGS } from "../config/cli-config-key-annotations.ts"; -import { cliConfigRegistry } from "../config/cli-config-keys.ts"; -import { - changedLinkedLocalFlags, - rejectPasswordWithDirectTarget, - resolveDbTargetFlags, - VALUE_CONSUMING_LONG_FLAGS, - VALUE_CONSUMING_SHORT_FLAGS, -} from "./db-target-flags.ts"; - -describe("resolveDbTargetFlags", () => { - it("returns empty setFlags and undefined connType when no args", () => { - const result = resolveDbTargetFlags([]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("detects --linked as changed (connType='linked')", () => { - const result = resolveDbTargetFlags(["--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --linked=false as changed (Changed, not value)", () => { - const result = resolveDbTargetFlags(["db", "lint", "--linked=false"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --no-linked as changed (boolean negation is still Changed)", () => { - const result = resolveDbTargetFlags(["--no-linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --db-url as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("detects --db-url= as changed", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("--local=false --linked produces setFlags length 2 with alphabetical order [linked local]", () => { - const result = resolveDbTargetFlags(["--local=false", "--linked"]); - expect(result.setFlags).toEqual(["linked", "local"]); - expect(result.setFlags).toHaveLength(2); - expect(result.connType).toBe("local"); - }); - - it("--db-url=postgres://x --linked produces setFlags [db-url linked] with connType=db-url", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked"]); - expect(result.connType).toBe("db-url"); - }); - - it("tokens after bare -- are not scanned (end-of-options sentinel)", () => { - const result = resolveDbTargetFlags(["--", "--linked"]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("--db-url (key only, value as next arg) is still detected as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("setFlags order is always alphabetical [db-url, linked, local] regardless of argv order", () => { - const result = resolveDbTargetFlags(["--local", "--db-url=x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked", "local"]); - }); - - it("Changed-first precedence: db-url > local > linked", () => { - const all = resolveDbTargetFlags(["--db-url=x", "--linked", "--local"]); - expect(all.connType).toBe("db-url"); - - const localLinked = resolveDbTargetFlags(["--linked", "--local"]); - expect(localLinked.connType).toBe("local"); - - const linkedOnly = resolveDbTargetFlags(["--linked"]); - expect(linkedOnly.connType).toBe("linked"); - }); - - it("skips value token after bare --schema so --linked is not a false positive", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --level so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--level", "error", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema=value (attached form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["--schema=public", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -s (short for --schema)", () => { - const result = resolveDbTargetFlags(["-s", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-svalue (attached short form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["-spublic", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare --output so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--output", "json", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--output-dir does NOT mark --local as changed (value consumed)", () => { - const result = resolveDbTargetFlags(["--output-dir", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("--output-dir= (attached form) DOES mark --local as changed", () => { - const result = resolveDbTargetFlags(["--output-dir=./reports", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema -- --linked: -- consumed as schema value, --linked is a real flag", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("bare -- with no pending skip still stops the scan", () => { - const result = resolveDbTargetFlags(["--linked", "--", "--local"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -p so --local is the password value, not a target", () => { - const result = resolveDbTargetFlags(["migration", "list", "-p", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --password so --linked is its value, not a flag", () => { - const result = resolveDbTargetFlags(["--password", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-ppwd (attached short password) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["-ppwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--password=pwd (attached long form) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["--password=pwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); -}); - -describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { - // `extractChangedFlagNames` relies on these two sets across every command, not just the - // db-target subset this file's other describe block covers — see the doc comment on - // `VALUE_CONSUMING_LONG_FLAGS` in `db-target-flags.ts`. This scan is static-source-based - // rather than importing every command module, so it can only see flag names declared as a - // literal string; a name passed through a helper function is registered by hand instead. - // Config-bound flags (`CliConfigKeys..flag({ name })`) take their value-consuming kind - // from the key's codec in the registry. - const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); - const INDIRECT_NAME_FILES = new Set(["issue.command.ts"]); - const VALUE_FLAG_KINDS = ["String", "Int", "Finite", "Literals", "ChoiceWithValue"]; - const configFlagPath = new Map( - Object.entries(CLI_CONFIG_FLAGS).flatMap(([keyPath, { names }]) => - names.map((name) => [name, keyPath] as const), - ), - ); - - const walk = Effect.fnUntraced(function* (dir: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const entries = yield* fs.readDirectory(dir, { recursive: true }); - return entries - .filter((entry) => entry.endsWith(".command.ts")) - .map((entry) => path.join(dir, entry)); - }); - - interface DeclaredFlag { - readonly file: string; - readonly name: string; - readonly alias: string | undefined; - } - - const consumesValue = (kind: string | undefined, name: string): boolean => { - if (kind !== undefined) return VALUE_FLAG_KINDS.includes(kind); - const keyPath = configFlagPath.get(name); - const codecKind = - keyPath === undefined ? undefined : cliConfigRegistry.keyAt(keyPath)?.codec.kind; - return codecKind !== "bool"; - }; - - function extractDeclaredFlags(filePath: string, source: string): Array { - const callRegex = /\bFlag\.([A-Z]\w*)\(|\.flag\(\{/g; - const calls = Array.from(source.matchAll(callRegex), (match) => ({ - index: match.index, - kind: match[1], - })); - - const declared: Array = []; - for (let i = 0; i < calls.length; i++) { - const current = calls[i]!; - - // Name declared as a literal string (e.g. `Flag.String("schema")` or - // `.flag({ name: "sql-paths" })`); a name passed as an identifier doesn't match and is - // silently skipped — see INDIRECT_NAME_FILES above. - const remainder = source.slice(current.index); - const nameMatch = remainder.match(/^(?:Flag\.\w+\(|\.flag\(\{\s*name:)\s*"([a-zA-Z0-9-]+)"/); - if (!nameMatch || !consumesValue(current.kind, nameMatch[1]!)) continue; - - // The alias, if any, is somewhere in the `.pipe(...)` chain or options object between - // this flag declaration and the next one. - const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; - const window = source.slice(current.index, windowEnd); - const aliasMatch = window.match( - /withAlias\(\s*"([a-zA-Z0-9])"\s*\)|\balias:\s*"([a-zA-Z0-9])"/, - ); - - declared.push({ - file: filePath, - name: nameMatch[1]!, - alias: aliasMatch?.[1] ?? aliasMatch?.[2], - }); - } - return declared; - } - - it.effect("finds both directly-declared and config-bound value flags in the scan", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const names = new Set(); - for (const filePath of yield* walk(commandsDir)) { - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - names.add(flag.name); - } - } - - expect(names).toContain("schema"); - expect(names).toContain("sql-paths"); - expect(names).toContain("password"); - expect(names).not.toContain("no-seed"); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { - missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { - missing.push( - `-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`, - ); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - -describe("changedLinkedLocalFlags", () => { - it("returns nothing when neither selector is present", () => { - expect(changedLinkedLocalFlags(["seed", "buckets"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "ls", "ss:///"])).toEqual([]); - }); - - it("returns a single selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--local"])).toEqual(["local"]); - }); - - it("returns both selectors in sorted order when both are set", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("handles = forms", () => { - expect(changedLinkedLocalFlags(["--local=true", "--linked=false"])).toEqual([ - "linked", - "local", - ]); - }); - - it("treats the --no-* negation form as changed", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["storage", "ls", "--no-local"])).toEqual(["local"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("does not treat a value-consuming flag's value as a selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--workdir", "--linked"])).toEqual([]); - expect(changedLinkedLocalFlags(["-o", "--linked", "--local"])).toEqual(["local"]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--content-type", "--local", "a", "b"]), - ).toEqual([]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--cache-control", "--linked", "a", "b"]), - ).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "--local", "a", "b"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "-j", "--linked", "a", "b"])).toEqual([]); - }); - - it("still detects a selector after a value-consuming flag's value", () => { - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "5", "--local", "a", "b"])).toEqual([ - "local", - ]); - }); - - it("stops scanning at the -- terminator", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--", "--local", "--linked"])).toEqual([]); - }); - - it("detects selectors given after positional arguments", () => { - expect(changedLinkedLocalFlags(["storage", "rm", "ss:///b/x", "--local"])).toEqual(["local"]); - }); -}); - -describe("rejectPasswordWithDirectTarget", () => { - const run = (connType: "local" | "db-url", password: string) => - Effect.runSyncExit(rejectPasswordWithDirectTarget(connType, Option.some(password))); - - it.each(["local", "db-url"] as const)( - "treats an empty --password as absent with %s", - (connType) => { - expect(Exit.isSuccess(run(connType, ""))).toBe(true); - }, - ); - - it.each(["local", "db-url"] as const)( - "still rejects a non-empty --password with %s", - (connType) => { - expect(Exit.isFailure(run(connType, "x"))).toBe(true); - }, - ); -}); diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index d737d077d1..45b1681713 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -2,8 +2,16 @@ import { Effect, Option } from "effect"; import { Flag, GlobalFlag } from "effect/cli"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS } from "./db-target-flags.ts"; -import { supabaseEnvBool, supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../shared/cli/value-consuming-flags.ts"; +import { envValue } from "../shared/config/env-option.ts"; +import { + parseEnvBool, + supabaseEnvBool, + supabaseEnvBoolWithProjectFallback, +} from "./supabase-env.ts"; // The CLI's global-flag registry is tree-wide, so `-o/--output` can't be redeclared per command to // vary its allowed values; this models it as the union of every command's accepted values, and @@ -87,7 +95,9 @@ export const NetworkIdFlag = GlobalFlag.Setting("network-id")({ export const YesFlag = GlobalFlag.Setting("yes")({ flag: Flag.Boolean("yes").pipe( - Flag.withDescription("answer yes to all prompts"), + Flag.withDescription( + "answer yes to all prompts; hosted changes only accept this flag or SUPABASE_YES exported in your shell, never supabase/.env or another .env file", + ), Flag.withDefault(false), ), }); @@ -217,20 +227,30 @@ const yesFlagExplicitlyFalse = (args: ReadonlyArray): boolean => (arg) => arg.startsWith("--yes=") && FALSE_VALUES.has(arg.slice("--yes=".length)), ); -/** - * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An - * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading - * {@link YesFlag} directly anywhere a command auto-confirms a prompt. - */ -export const resolveYes = Effect.gen(function* () { +/** `raw` is the `SUPABASE_YES` value the process sees, which may come from a dotenv file Bun autoloaded. */ +export type YesSource = { readonly via: "flag" } | { readonly via: "env"; readonly raw: string }; + +/** The origin of a granted `--yes`, or `None`; an explicit `--yes=false` wins over the env. */ +export const resolveYesSource = Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; if (yesFlagExplicitlyFalse(cliArgs.args)) { - return false; + return Option.none(); } - return flag || (yield* supabaseEnvBool("SUPABASE_YES")); + if (flag) return Option.some({ via: "flag" }); + const raw = yield* envValue("SUPABASE_YES"); + return raw !== undefined && parseEnvBool(raw) + ? Option.some({ via: "env", raw }) + : Option.none(); }); +/** + * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An + * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading + * {@link YesFlag} directly anywhere a command auto-confirms a prompt. + */ +export const resolveYes = Effect.map(resolveYesSource, Option.isSome); + /** * `--yes` resolved with the project `.env` consulted too, for commands that load the nested * project env before prompting (`migration down`, `migration repair --all`). Shell env diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts new file mode 100644 index 0000000000..c00ec4d0b9 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts @@ -0,0 +1,93 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readTargetPolicy } from "../target/target-policy.ts"; + +const srcDir = fileURLToPath(new URL("../..", import.meta.url)); + +const VARIANTS: ReadonlyArray = [ + undefined, + { stackBackend: "stack", computeEnabled: true }, +]; + +const consentLeafPaths: ReadonlySet = new Set( + VARIANTS.flatMap((variant) => + [...commandTreeFor(variant)] + .filter(([, node]) => node.isLeaf) + .filter(([, node]) => { + const policy = readTargetPolicy(node.command); + return Option.isSome(policy) && policy.value.consent !== "none"; + }) + .map(([path]) => path), + ), +); + +const LITERAL_CALL = /\bdescribeHostedConsent\(\s*"([^"]+)"/g; +const ANY_CALL = /\bdescribeHostedConsent\(/g; + +const testFiles = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".test.ts")) + .map((file) => file.split(sep).join("/")); + +const callsByLeaf = new Map>(); +const nonLiteralCallSites: Array = []; +const lowercaseActions: Array = []; + +const sourceFiles = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".ts") && !file.includes(".test.")) + .map((file) => file.split(sep).join("/")); + +const LOWERCASE_ACTION = /\baction:\s*[`"'][a-z]/; +for (const file of sourceFiles) { + const source = readFileSync(join(srcDir, file), "utf8"); + if (source.includes("hosted-consent.ts") && LOWERCASE_ACTION.test(source)) { + lowercaseActions.push(file); + } +} + +for (const file of testFiles) { + const source = readFileSync(join(srcDir, file), "utf8"); + const literalCalls = [...source.matchAll(LITERAL_CALL)]; + for (const [, leaf] of literalCalls) { + callsByLeaf.set(leaf!, [...(callsByLeaf.get(leaf!) ?? []), file]); + } + const anyCalls = [...source.matchAll(ANY_CALL)].length; + if (anyCalls !== literalCalls.length) { + nonLiteralCallSites.push(`${file} (${anyCalls - literalCalls.length})`); + } +} + +describe("hosted consent coverage", () => { + it("scans real test files and finds consenting leaves in the command tree", () => { + expect(testFiles.length).toBeGreaterThan(0); + expect(consentLeafPaths.size).toBeGreaterThan(0); + }); + + it("has a consent matrix for every leaf whose policy asks for consent", () => { + const missing = [...consentLeafPaths].filter((path) => !callsByLeaf.has(path)).sort(); + expect(missing, "leaves with consent but no hosted-consent matrix in src/**/*.test.ts").toEqual( + [], + ); + }); + + it("has no consent matrix for a path that is not a leaf with consent", () => { + const extra = [...callsByLeaf] + .filter(([path]) => !consentLeafPaths.has(path)) + .map(([path, files]) => `${path} (${files.join(", ")})`) + .sort(); + expect(extra, "hosted-consent matrices for paths that are not consenting leaves").toEqual([]); + }); + + it("names the leaf as a string literal at every call site so the scan can see it", () => { + expect(nonLiteralCallSites).toEqual([]); + }); + + it("starts every consent action literal with a capital letter", () => { + expect(lowercaseActions).toEqual([]); + }); +}); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts new file mode 100644 index 0000000000..9b0ad58403 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** A hosted mutation needs confirmation and this run can neither prompt nor see `--yes`. */ +export class HostedMutationConsentRequiredError extends Data.TaggedError( + "HostedMutationConsentRequiredError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.consentRequired; + } +} + +/** The user answered no to a hosted-mutation confirmation. */ +export class HostedMutationDeclinedError extends Data.TaggedError("HostedMutationDeclinedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts new file mode 100644 index 0000000000..91cbaf3097 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts @@ -0,0 +1,47 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import { isMachineResourceFormat } from "../../shared/output/resource-format.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { resolveAgentMode } from "../agent-mode.ts"; +import { AgentFlag, OutputFlag } from "../global-flags.ts"; + +export interface PromptBlocker { + readonly reason: string; + readonly agent: boolean; +} + +const agentDetected = Effect.gen(function* () { + const flag = Option.getOrElse(yield* Effect.serviceOption(AgentFlag), () => "auto" as const); + const tool = yield* Effect.serviceOption(AiTool); + return resolveAgentMode( + flag, + Option.flatMap(tool, (detected) => detected.name), + ); +}); + +/** + * Why this run cannot ask a question, or `None` when it can: text output on an interactive + * stdout with a TTY stdin, no machine `-o` format, and no agent driving it. + */ +export const promptBlocker = Effect.gen(function* () { + const output = yield* Output; + const tty = yield* Tty; + const outputFlag = yield* OutputFlag; + if (yield* agentDetected) { + return Option.some({ reason: "it is running in agent mode", agent: true }); + } + const blocked = (reason: string) => Option.some({ reason, agent: false }); + if (output.format !== "text") return blocked(`its output format is ${output.format}`); + if (isMachineResourceFormat(outputFlag)) { + return blocked( + `it writes machine-readable -o ${Option.getOrElse(outputFlag, () => "")} output`, + ); + } + if (!output.interactive) return blocked("stdout is not a terminal"); + if (!tty.stdinIsTty) return blocked("stdin is not a terminal"); + return Option.none(); +}); + +export const canPrompt = Effect.map(promptBlocker, Option.isNone); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts new file mode 100644 index 0000000000..3d4577c6b7 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts @@ -0,0 +1,8 @@ +declare const Brand: unique symbol; + +/** Proof that the user approved a hosted mutation; only the consent module constructs one. */ +export interface HostedMutationConsent { + readonly [Brand]: true; + readonly action: string; + readonly via: "prompt" | "yes"; +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts new file mode 100644 index 0000000000..ffc62f0863 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -0,0 +1,140 @@ +import { Effect, Option } from "effect"; + +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import { promptBlocker } from "./hosted-consent.prompt.ts"; +import type { PromptBlocker } from "./hosted-consent.prompt.ts"; +import type { HostedMutationConsent } from "./hosted-consent.token.ts"; +import { ignoredYesFiles, resolveHostedYes } from "./hosted-consent.yes.ts"; + +export interface HostedMutationRequest { + /** An imperative, sentence-case phrase with no trailing period, such as "Apply 3 migrations". */ + readonly action: string; + /** Plan lines printed before the question. */ + readonly details?: ReadonlyArray; + /** Asks for `expected` to be typed instead of a yes/no answer. */ + readonly confirm?: { readonly kind: "typed"; readonly expected: string }; + /** The command has a `--read-only` mode that needs no confirmation. */ + readonly readOnlyAlternative?: boolean; +} + +// The brand is type-only, so minting a token needs this one assertion. +const grant = (action: string, via: HostedMutationConsent["via"]): HostedMutationConsent => + ({ action, via }) as HostedMutationConsent; + +const announcedTarget = Effect.serviceOption(CommandTarget).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (cell) => cell.text, + }), + ), +); + +const consentRequired = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + target: Option.Option, + blocker: PromptBlocker, +) { + const ignored = yield* ignoredYesFiles; + const suggestions = [ + blocker.agent + ? "This is an agent run: ask the user before re-running with --yes." + : "Re-run with --yes to confirm.", + ...(request.readOnlyAlternative === true + ? ["For a read-only statement, pass --read-only instead."] + : []), + ...(ignored.length > 0 + ? [`SUPABASE_YES in ${ignored.join(", ")} does not confirm hosted changes; pass --yes.`] + : []), + ]; + const where = Option.match(target, { onNone: () => "", onSome: (text) => ` (target: ${text})` }); + return yield* new HostedMutationConsentRequiredError({ + message: `Confirmation required: ${request.action}${where}. This run can't ask because ${blocker.reason}. Nothing was changed.`, + suggestion: suggestions.join(" "), + }); +}); + +const ask = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + defaultValue: boolean, + onTarget: string, +) { + const output = yield* Output; + if (request.details !== undefined && request.details.length > 0) { + yield* output.raw(`${request.details.join("\n")}\n`, "stderr"); + } + if (request.confirm === undefined) { + return yield* output.promptConfirm(`${request.action}${onTarget}?`, { defaultValue }); + } + yield* output.raw(`${request.action}${onTarget}\n`, "stderr"); + const typed = yield* output.promptText(`Type ${request.confirm.expected} to confirm`); + return typed.trim() === request.confirm.expected; +}); + +/** + * Grants consent for a hosted mutation or fails. `--yes` or a shell `SUPABASE_YES` (never a dotenv + * file) grants it; otherwise only an interactive text-mode terminal with no agent driving it is + * asked. Stdin is never read, so a piped answer does not count. + */ +export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(function* ( + request: HostedMutationRequest, +) { + const policy = yield* TargetPolicy; + if (policy.consent === "none") { + return yield* Effect.die( + new Error( + `requireHostedMutationConsent needs a target policy with consent "yes" or "no"; "${policy.group}" declares "none"`, + ), + ); + } + const output = yield* Output; + const target = yield* announcedTarget; + const onTarget = Option.match(target, { onNone: () => "", onSome: (text) => ` on ${text}` }); + + const approvedBy = yield* resolveHostedYes; + if (Option.isSome(approvedBy)) { + yield* output.raw(`Confirmed by ${approvedBy.value}: ${request.action}${onTarget}\n`, "stderr"); + yield* Effect.annotateCurrentSpan("consent.via", "yes"); + return grant(request.action, "yes"); + } + + const blocker = yield* promptBlocker; + if (Option.isSome(blocker)) return yield* consentRequired(request, target, blocker.value); + + const confirmed = yield* ask(request, policy.consent === "yes", onTarget).pipe( + Effect.catchTag("NonInteractiveError", () => + consentRequired(request, target, { reason: "no prompt is available", agent: false }), + ), + ); + if (!confirmed) { + return yield* new HostedMutationDeclinedError({ message: CONTEXT_CANCELED_MESSAGE }); + } + yield* Effect.annotateCurrentSpan("consent.via", "prompt"); + return grant(request.action, "prompt"); +}); + +/** `None` for a local target (a local `ResolvedTarget` or a connection with `isLocal`); otherwise {@link requireHostedMutationConsent}. */ +export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForTarget")(function* ( + target: ResolvedTarget | ResolvedDbConfig, + request: HostedMutationRequest, +) { + const isLocal = "isLocal" in target ? target.isLocal : target.kind === "local"; + if (isLocal) return Option.none(); + return Option.some(yield* requireHostedMutationConsent(request)); +}); + +/** `Some` only under `--yes` or a shell `SUPABASE_YES`; never prompts, so a read can hand it to a connection that may clear a network ban. */ +export const hostedMutationPreapproval = Effect.map(resolveHostedYes, (approvedBy) => + Option.isSome(approvedBy) + ? Option.some(grant("Remove network bans", "yes")) + : Option.none(), +); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts new file mode 100644 index 0000000000..c64b2bdea1 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -0,0 +1,660 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; + +import { + mockCommandSettings, + withConfigEnv, + withEmptyConfigEnv, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { + announceTarget, + type CommandTargetJson, +} from "../../shared/output/command-target.service.ts"; +import type { OutputFormat } from "../../shared/output/types.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import { AgentFlag, OutputFlag, YesFlag } from "../global-flags.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import type { TargetPolicyShape } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import { + hostedMutationPreapproval, + requireHostedMutationConsent, + requireTargetMutationConsent, +} from "./hosted-consent.ts"; +import type { HostedMutationRequest } from "./hosted-consent.ts"; + +type OutputFlagValue = "pretty" | "json" | "yaml" | "toml" | "env" | "table" | "csv"; + +interface Scenario { + readonly policy?: TargetPolicyShape; + readonly format?: OutputFormat; + readonly interactive?: boolean; + readonly stdinIsTty?: boolean; + readonly yes?: boolean; + readonly args?: ReadonlyArray; + readonly outputFlag?: OutputFlagValue; + readonly confirmResponses?: ReadonlyArray; + readonly textResponses?: ReadonlyArray; + readonly promptFails?: boolean; + readonly shellYes?: string; + readonly env?: Readonly>; + readonly announced?: string; + readonly agent?: "auto" | "yes" | "no"; + readonly aiTool?: string; + readonly cwd?: string; + readonly workdir?: string; + readonly withoutFilesystem?: boolean; +} + +const REQUEST: HostedMutationRequest = { + action: "Apply 3 migrations", + details: [" 20240101_a.sql", " 20240102_b.sql"], +}; + +const UNVERIFIED_JSON: CommandTargetJson = { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + environment: "unverified", +}; + +const interactiveTerminal: Scenario = { format: "text", interactive: true, stdinIsTty: true }; + +const harness = (scenario: Scenario) => { + const out = mockOutput({ + format: scenario.format ?? "text", + interactive: scenario.interactive, + promptConfirmResponses: scenario.confirmResponses, + promptTextResponses: scenario.textResponses, + promptConfirmFail: scenario.promptFails, + }); + const layer = Layer.mergeAll( + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + mockTty({ + stdinIsTty: scenario.stdinIsTty ?? false, + stdoutIsTty: scenario.stdinIsTty ?? false, + }), + Layer.succeed(YesFlag, scenario.yes ?? false), + Layer.succeed(CliArgs, { args: scenario.args ?? [] }), + Layer.succeed( + OutputFlag, + scenario.outputFlag === undefined ? Option.none() : Option.some(scenario.outputFlag), + ), + Layer.succeed(TargetPolicy, scenario.policy ?? { group: "hosted", consent: "no" }), + scenario.agent === undefined ? Layer.empty : Layer.succeed(AgentFlag, scenario.agent), + scenario.aiTool === undefined + ? Layer.empty + : Layer.succeed(AiTool, { name: Option.some(scenario.aiTool) }), + scenario.withoutFilesystem === true + ? Layer.empty + : Layer.merge(BunServices.layer, mockRuntimeInfo({ cwd: scenario.cwd ?? "/test/project" })), + scenario.workdir === undefined + ? Layer.empty + : mockCommandSettings({ workdir: scenario.workdir }), + ); + const shellEnv = { + NODE_ENV: "", + ...(scenario.shellYes === undefined ? {} : { SUPABASE_YES: scenario.shellYes }), + ...scenario.env, + }; + const withShellEnv = (body: Effect.Effect) => + withEmptyConfigEnv(withConfigEnv(shellEnv, body)); + const prepare = (body: Effect.Effect) => + withShellEnv( + (scenario.announced === undefined + ? body + : Effect.flatMap( + announceTarget({ text: scenario.announced, json: UNVERIFIED_JSON }), + () => body, + ) + ).pipe(Effect.provide(layer)), + ); + return { out, prepare }; +}; + +const consent = (scenario: Scenario, request: HostedMutationRequest = REQUEST) => { + const { out, prepare } = harness(scenario); + const exit = prepare(requireHostedMutationConsent(request)).pipe(Effect.exit); + return { out, exit }; +}; + +const successOf = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); + return exit.value; +}; + +const failureOf = (exit: Exit.Exit): E => { + if (!Exit.isFailure(exit)) throw new Error("expected a failure"); + const failure = Exit.findErrorOption(exit); + if (Option.isNone(failure)) + throw new Error(`expected a typed failure, got ${Cause.pretty(exit.cause)}`); + return failure.value; +}; + +describe("requireHostedMutationConsent", () => { + describe("default answer", () => { + it.effect.each([ + { consent: "no", expected: false }, + { consent: "yes", expected: true }, + ] as const)( + "offers $expected as the default when the policy says $consent", + ({ consent: policyConsent, expected }) => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: policyConsent }, + confirmResponses: [true], + }); + yield* run.exit; + expect(run.out.promptConfirmCalls).toEqual([ + { message: "Apply 3 migrations?", opts: { defaultValue: expected } }, + ]); + }), + ); + + it.effect("dies when the command declares no consent", () => + Effect.gen(function* () { + const exit = yield* consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: "none" }, + }).exit; + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + }), + ); + }); + + describe("--yes", () => { + it.effect("grants without asking and echoes the action and target on stderr", () => + Effect.gen(function* () { + const run = consent({ + yes: true, + format: "json", + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "yes" }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toContain( + "Confirmed by --yes: Apply 3 migrations on project abcdefghijklmnopqrst\n", + ); + }), + ); + + it.effect("omits the target when nothing was announced", () => + Effect.gen(function* () { + const run = consent({ yes: true }); + yield* run.exit; + expect(run.out.stderrText).toBe("Confirmed by --yes: Apply 3 migrations\n"); + }), + ); + + it.effect("skips a typed confirmation", () => + Effect.gen(function* () { + const run = consent( + { ...interactiveTerminal, yes: true }, + { action: "delete compute api", confirm: { kind: "typed", expected: "api" } }, + ); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("is overridden by an explicit --yes=false even when SUPABASE_YES is set", () => + Effect.gen(function* () { + const exit = yield* consent({ args: ["--yes=false"], shellYes: "1" }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + }); + + describe("SUPABASE_YES", () => { + const withDotenvDir = ( + files: Readonly>, + body: (dir: string) => Effect.Effect, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + for (const [name, contents] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(dir, name)), { recursive: true }); + yield* fs.writeFileString(path.join(dir, name), contents); + } + return yield* body(dir); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + + it.effect( + "grants when set in the shell environment and echoes SUPABASE_YES as the source", + () => + Effect.gen(function* () { + const run = consent({ shellYes: "1", format: "json" }); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by SUPABASE_YES: Apply 3 migrations"); + }), + ); + + it.effect("does not grant when it is only in the project .env", () => + withDotenvDir({ "supabase/.env": "SUPABASE_YES=1\n" }, (dir) => + Effect.gen(function* () { + const exit = yield* consent({ cwd: dir, workdir: dir }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect.each([".env", ".env.local", ".env.development", ".env.development.local"])( + "does not grant a shell value that matches %s in the working directory", + (name) => + withDotenvDir({ [name]: "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("checks the dotenv files NODE_ENV selects", () => + withDotenvDir({ ".env.production": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const production = yield* consent({ + shellYes: "1", + cwd, + env: { NODE_ENV: "production" }, + }).exit; + expect(failureOf(production)).toBeInstanceOf(HostedMutationConsentRequiredError); + const development = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(development).via).toBe("yes"); + }), + ), + ); + + it.effect("grants a shell value that differs from the working directory's file", () => + withDotenvDir({ ".env": "SUPABASE_YES=true\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(exit).via).toBe("yes"); + }), + ), + ); + + it.effect("grants when the working directory's dotenv files do not mention it", () => + withDotenvDir({ ".env": "OTHER=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(exit).via).toBe("yes"); + }), + ), + ); + + it.effect("does not grant when a dotenv file that does not parse mentions it", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n!!!\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("does not grant when the filesystem cannot be checked", () => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", withoutFilesystem: true }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + + it.effect("still grants --yes when a working-directory .env sets the variable", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const run = consent({ yes: true, cwd }); + expect(successOf(yield* run.exit).via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by --yes: Apply 3 migrations"); + }), + ), + ); + }); + + describe("when the run cannot prompt", () => { + it.effect.each([ + { + name: "json output", + scenario: { format: "json", stdinIsTty: true }, + reason: "its output format is json", + }, + { + name: "stream-json output", + scenario: { format: "stream-json", stdinIsTty: true }, + reason: "its output format is stream-json", + }, + { + name: "-o yaml in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "yaml" }, + reason: "it writes machine-readable -o yaml output", + }, + { + name: "-o json in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "json" }, + reason: "it writes machine-readable -o json output", + }, + { + name: "piped stdin carrying an affirmative answer", + scenario: { + format: "text", + interactive: true, + stdinIsTty: false, + confirmResponses: [true], + }, + reason: "stdin is not a terminal", + }, + { + name: "a non-interactive stdout", + scenario: { format: "text", interactive: false, stdinIsTty: true }, + reason: "stdout is not a terminal", + }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario; reason: string }>)( + "fails with ConsentRequiredError for $name and never asks", + ({ scenario, reason }) => + Effect.gen(function* () { + const run = consent({ ...scenario, announced: "project abcdefghijklmnopqrst" }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: `Confirmation required: Apply 3 migrations (target: project abcdefghijklmnopqrst). This run can't ask because ${reason}. Nothing was changed.`, + suggestion: "Re-run with --yes to confirm.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("leaves the target out of the message when nothing was announced", () => + Effect.gen(function* () { + const error = failureOf(yield* consent({ format: "json" }).exit); + expect(error).toMatchObject({ + message: + "Confirmation required: Apply 3 migrations. This run can't ask because its output format is json. Nothing was changed.", + }); + }), + ); + + it.effect("fails with ConsentRequiredError when the prompt turns out to be unavailable", () => + Effect.gen(function* () { + const error = failureOf(yield* consent({ ...interactiveTerminal, promptFails: true }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because no prompt is available"), + }); + }), + ); + + it.effect("suggests --read-only when the command has a read-only mode", () => + Effect.gen(function* () { + const error = failureOf( + yield* consent({ format: "json" }, { ...REQUEST, readOnlyAlternative: true }).exit, + ); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. For a read-only statement, pass --read-only instead.", + }); + }), + ); + + it.effect("says a project .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", cwd: dir, workdir: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in supabase/.env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("says a working-directory .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(dir, ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", shellYes: "1", cwd: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in .env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); + + describe("agents", () => { + it.effect.each([ + { name: "--agent yes", scenario: { agent: "yes" } }, + { name: "a detected agent", scenario: { agent: "auto", aiTool: "claude_code" } }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario }>)( + "refuses to prompt for $name in an interactive terminal", + ({ scenario }) => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, ...scenario, confirmResponses: [true] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because it is running in agent mode"), + suggestion: "This is an agent run: ask the user before re-running with --yes.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("prompts when --agent no overrides a detected agent", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + agent: "no", + aiTool: "claude_code", + confirmResponses: [true], + }); + expect(successOf(yield* run.exit).via).toBe("prompt"); + }), + ); + + it.effect("grants --yes to an agent", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, yes: true, agent: "yes" }); + expect(successOf(yield* run.exit).via).toBe("yes"); + }), + ); + }); + + describe("in an interactive terminal", () => { + it.effect("grants an accepted prompt and prints the details first", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + confirmResponses: [true], + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "prompt" }); + expect(run.out.stderrText).toBe( + "Target: project abcdefghijklmnopqrst\n 20240101_a.sql\n 20240102_b.sql\n", + ); + expect(run.out.promptConfirmCalls.map((call) => call.message)).toEqual([ + "Apply 3 migrations on project abcdefghijklmnopqrst?", + ]); + }), + ); + + it.effect("fails with DeclinedError when the answer is no", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, confirmResponses: [false] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(error).toMatchObject({ message: "context canceled" }); + }), + ); + + describe("typed confirmation", () => { + const typed: HostedMutationRequest = { + action: "delete compute api", + details: ["This permanently deletes the compute."], + confirm: { kind: "typed", expected: "api" }, + }; + + it.effect("grants when the typed answer matches", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: [" api "] }, typed); + const token = successOf(yield* run.exit); + expect(token.via).toBe("prompt"); + expect(run.out.promptTextCalls.map((call) => call.message)).toEqual([ + "Type api to confirm", + ]); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe( + "This permanently deletes the compute.\ndelete compute api\n", + ); + }), + ); + + it.effect("fails with DeclinedError when the typed answer differs", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: ["apis"] }, typed); + expect(failureOf(yield* run.exit)).toBeInstanceOf(HostedMutationDeclinedError); + }), + ); + }); + }); + + it("classifies the errors for telemetry", () => { + expect( + classifyCliErrorActionability( + new HostedMutationConsentRequiredError({ message: "m", suggestion: "s" }), + ), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "consent_required", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect( + classifyCliErrorActionability(new HostedMutationDeclinedError({ message: "m" })), + ).toMatchObject({ error_kind: "user_cancelled", error_category: "cancelled" }); + }); +}); + +describe("hostedMutationPreapproval", () => { + const preapproval = (scenario: Scenario) => harness(scenario).prepare(hostedMutationPreapproval); + + it.effect("is Some under --yes", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ yes: true }))).toBe(true); + }), + ); + + it.effect("is Some under a shell SUPABASE_YES", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ shellYes: "1" }))).toBe(true); + }), + ); + + it.effect("is None in a terminal that could prompt", () => + Effect.gen(function* () { + const { out, prepare } = harness({ ...interactiveTerminal, confirmResponses: [true] }); + expect(Option.isNone(yield* prepare(hostedMutationPreapproval))).toBe(true); + expect(out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("is None under --yes=false", () => + Effect.gen(function* () { + expect(Option.isNone(yield* preapproval({ args: ["--yes=false"], shellYes: "1" }))).toBe( + true, + ); + }), + ); + + it.effect("is None for a shell value that a working-directory .env also sets", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(cwd, ".env.local"), "SUPABASE_YES=1\n"); + expect(Option.isNone(yield* preapproval({ shellYes: "1", cwd }))).toBe(true); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); + +describe("requireTargetMutationConsent", () => { + const dbConfig = (isLocal: boolean): ResolvedDbConfig => ({ + conn: { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "", + database: "postgres", + }, + isLocal, + }); + const linked: ResolvedTarget = { + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "file", + matchesLinkedRef: true, + }; + + const forTarget = (target: ResolvedTarget | ResolvedDbConfig, scenario: Scenario = {}) => { + const { out, prepare } = harness(scenario); + return { out, exit: prepare(requireTargetMutationConsent(target, REQUEST)).pipe(Effect.exit) }; + }; + + it.effect.each([ + { name: "a local target", target: { kind: "local" } satisfies ResolvedTarget }, + { name: "a connection to the local stack", target: dbConfig(true) }, + ])("is None for $name without asking or echoing", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, yes: true }); + const result = successOf(yield* run.exit); + expect(Option.isNone(result)).toBe(true); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe(""); + }), + ); + + it.effect.each([ + { name: "a linked target", target: linked }, + { + name: "an unclassified --db-url target", + target: { + kind: "db-url", + url: Redacted.make("postgresql://postgres:secret@db.example.com:5432/postgres"), + } satisfies ResolvedTarget, + }, + { name: "a connection to a hosted database", target: dbConfig(false) }, + ])("asks for $name", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, confirmResponses: [true] }); + const result = successOf(yield* run.exit); + expect(Option.map(result, (token) => token.via)).toEqual(Option.some("prompt")); + }), + ); + + it.effect("fails for a hosted target in a run that cannot prompt", () => + Effect.gen(function* () { + const error = failureOf(yield* forTarget(linked, { format: "json" }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); +}); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts new file mode 100644 index 0000000000..5999b2972d --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -0,0 +1,100 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { candidateDotenvFilenames } from "../../shared/config/cli-config-env.ts"; +import { parseDotEnv } from "../../shared/config/dotenv.ts"; +import { envValue } from "../../shared/config/env-option.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { resolveYesSource } from "../global-flags.ts"; +import { parseEnvBool } from "../supabase-env.ts"; + +export type HostedYesSource = "--yes" | "SUPABASE_YES"; + +const YES_VARIABLE = "SUPABASE_YES"; + +interface DotenvDefinition { + readonly file: string; + /** `None` when the file does not parse but mentions the variable, which counts as any value. */ + readonly value: Option.Option; +} + +interface Platform { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly cwd: string; +} + +// Without a filesystem the origin of `SUPABASE_YES` cannot be checked, so callers treat it as unverified. +const platform = Effect.gen(function* () { + const fs = yield* Effect.serviceOption(FileSystem.FileSystem); + const path = yield* Effect.serviceOption(Path.Path); + const runtime = yield* Effect.serviceOption(RuntimeInfo); + return Option.all({ fs, path, runtime }).pipe( + Option.map(({ fs, path, runtime }): Platform => ({ fs, path, cwd: runtime.cwd })), + ); +}); + +const envNameFrom = (variable: string) => + Effect.map(envValue(variable), (value) => + value === undefined || value === "" ? "development" : value, + ); + +const definitionsIn = Effect.fnUntraced(function* (host: Platform, dir: string, env: string) { + const found: Array = []; + for (const name of candidateDotenvFilenames(env)) { + const file = host.path.join(dir, name); + const content = yield* host.fs.readFileString(file).pipe(Effect.option); + if (Option.isNone(content)) continue; + const parsed = yield* Effect.try(() => parseDotEnv(content.value)).pipe(Effect.option); + if (Option.isNone(parsed)) { + if (content.value.includes(YES_VARIABLE)) found.push({ file, value: Option.none() }); + } else if (Object.hasOwn(parsed.value, YES_VARIABLE)) { + found.push({ file, value: Option.some(parsed.value[YES_VARIABLE]!) }); + } + } + return found; +}); + +/** The files Bun loads into `process.env` from the working directory before any CLI code runs. */ +const autoloadedDefinitions = (host: Platform) => + Effect.flatMap(envNameFrom("NODE_ENV"), (env) => definitionsIn(host, host.cwd, env)); + +/** + * Confirms a hosted change only for `--yes` or a `SUPABASE_YES` the shell exported. Bun copies + * working-directory dotenv files into the environment, and a shell variable overrides them, so a + * value that matches one of those files is treated as the file's and not as consent. + */ +export const resolveHostedYes = Effect.gen(function* () { + const source = yield* resolveYesSource; + if (Option.isNone(source)) return Option.none(); + if (source.value.via === "flag") return Option.some("--yes"); + const { raw } = source.value; + const host = yield* platform; + if (Option.isNone(host)) return Option.none(); + const fromFile = (yield* autoloadedDefinitions(host.value)).some( + ({ value }) => Option.isNone(value) || value.value === raw, + ); + return fromFile ? Option.none() : Option.some("SUPABASE_YES"); +}); + +/** Dotenv files, relative to the working directory, whose `SUPABASE_YES` does not confirm a hosted change. */ +export const ignoredYesFiles = Effect.gen(function* () { + const host = yield* platform; + if (Option.isNone(host)) return []; + const { path, cwd } = host.value; + const workdir = Option.match(yield* Effect.serviceOption(CommandSettings), { + onNone: () => cwd, + onSome: (settings) => settings.workdir, + }); + const projectEnv = yield* envNameFrom("SUPABASE_ENV"); + + const definitions = [ + ...(yield* autoloadedDefinitions(host.value)), + ...(yield* definitionsIn(host.value, path.join(workdir, "supabase"), projectEnv)), + ...(yield* definitionsIn(host.value, workdir, projectEnv)), + ]; + const files = definitions + .filter(({ value }) => Option.isNone(value) || parseEnvBool(value.value)) + .map(({ file }) => path.relative(cwd, file)); + return [...new Set(files)]; +}); diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index b93c51b7da..9c69929a0e 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -19,7 +19,7 @@ import { } from "./migration-history.ts"; import { parseMigrationContent } from "./migration-file.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; -import { splitSqlTokens } from "./sql-split.ts"; +import { splitSqlTokens, trimLeadingSqlComments } from "./sql-split.ts"; /** * A migration file failed to apply. Used by `migration up`/`down`'s migrate-and-seed step; the @@ -40,9 +40,6 @@ export class MigrationApplyError extends Data.TaggedError("MigrationApplyError") } } -// Byte order mark (U+FEFF), stripped from the head of a statement. -const BOM_CODE_POINT = 0xfeff; - // Statements that PostgreSQL refuses to run inside a transaction block / extended-query // pipeline (SQLSTATE 25001). Matched against the upper-cased, comment-stripped statement. // @@ -58,31 +55,6 @@ const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; -/** - * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the - * front of a statement so the keyword check below sees the real first token. - */ -const trimLeadingSqlComments = (sql: string): string => { - // Stripped via code point comparison so it isn't relied on to match a whitespace regex class. - let trimmed = sql.replace(/^[ \t\n\r]+/u, ""); - while (trimmed.charCodeAt(0) === BOM_CODE_POINT) { - trimmed = trimmed.slice(1).replace(/^[ \t\n\r]+/u, ""); - } - for (;;) { - if (trimmed.startsWith("--")) { - const idx = trimmed.indexOf("\n"); - if (idx < 0) return ""; - trimmed = trimmed.slice(idx + 1).replace(/^[ \t\n\r]+/u, ""); - } else if (trimmed.startsWith("/*")) { - const idx = trimmed.indexOf("*/"); - if (idx < 0) return trimmed; - trimmed = trimmed.slice(idx + 2).replace(/^[ \t\n\r]+/u, ""); - } else { - return trimmed.trim(); - } - } -}; - /** * Whether a migration statement cannot run inside a transaction block — `CREATE [UNIQUE] INDEX * CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 3ee569443e..6d948b0c7f 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -4,30 +4,21 @@ import { DnsResolverFlag, resolveYesWithProjectEnv } from "./global-flags.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; import { Output } from "../shared/output/output.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { bold } from "./colors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { DbConnection } from "./db-connection.service.ts"; import { loadProjectEnvValues } from "./db-config.toml-read.ts"; -import { type DbTargetSelection } from "./db-target-flags.ts"; +import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import { readMigrationTable } from "./migration-history.ts"; +import { promptYesNo } from "./prompt-yes-no.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { LinkedProjectCache } from "../telemetry/linked-project-cache.service.ts"; -import { - MigrationTargetFlagsError, - OperationCanceledError, -} from "../commands/migration/migration.errors.ts"; -import { migrationConfirm } from "../commands/migration/migration.prompt.ts"; -import type { MigrationFetchFlags } from "../commands/migration/fetch/fetch.command.ts"; +import { OperationCanceledError } from "../commands/migration/migration.errors.ts"; import { MigrationFetchWriteError } from "../commands/migration/fetch/fetch.errors.ts"; -// Re-exported so an in-process caller (`pull`) can build a `MigrationFetchInput` without -// reaching into `commands/migration/**` directly. -export type { MigrationFetchFlags }; - export interface MigrationFetchInput { - readonly flags: MigrationFetchFlags; - readonly target: DbTargetSelection; - /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution. */ + readonly target: ResolvedTarget; + /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for the overwrite prompt. */ readonly assumeYes?: boolean; } @@ -39,7 +30,7 @@ export interface MigrationFetchOutcome { export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( input: MigrationFetchInput, ) { - const { flags, target, assumeYes } = input; + const { target, assumeYes } = input; const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -48,61 +39,30 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Validated first, before any other work — same ordering as `migration down`/`repair`. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }); - } - - const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. - yield* Effect.annotateCurrentSpan("db.conn_type", connType); + yield* Effect.annotateCurrentSpan("db.conn_type", target.kind); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Resolve the DB config BEFORE any filesystem/prompt side effects — an invalid - // `--db-url`/`config.toml` then fails immediately, instead of first creating - // `supabase/migrations` or letting a declined overwrite prompt mask the real error - // with `context canceled`. Same fix as `migration repair`. - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + // Resolved before any filesystem or prompt side effect so an invalid `--db-url` or + // `config.toml` fails immediately instead of behind a declined overwrite prompt. + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); - // The project .env loads after the parse-time flag-group validation above — so a - // SUPABASE_YES set only in supabase/.env auto-confirms, but a flag conflict still - // surfaces before any .env read. Resolve --yes against the project env here, not - // just process.env. Same ordering as `migration down`/`repair`. const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Linked fetch caches the project ref on success. The ref is - // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, - // so a declined prompt returns before it runs. + // The cache write is attached to the body so a declined prompt returns before it runs. let cacheLinkedRef: Effect.Effect | undefined; - if (connType === "linked") { - const projectRef = yield* ProjectRefResolver; + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - cacheLinkedRef = linkedProjectCache.cache(ref); + cacheLinkedRef = linkedProjectCache.cache(target.ref); } const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - yield* fs - .makeDirectory(migrationsDir, { recursive: true }) - .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); // Only a missing directory counts as "empty"; any other read error must propagate — // collapsing it to empty would skip the confirmation and risk clobbering existing migrations. const existing = yield* fs.readDirectory(migrationsDir).pipe( @@ -121,7 +81,7 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const overwrite = assumeYes !== undefined ? assumeYes - : yield* migrationConfirm(title, { defaultValue: true, yes }); + : yield* promptYesNo(output, yes, title, true, true, { readMachineStdin: true }); if (!overwrite) { return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } @@ -129,12 +89,6 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const migrations = yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -143,6 +97,10 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( }), ); + yield* fs + .makeDirectory(migrationsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); + const written: Array = []; for (const file of migrations) { // The version/name come from the remote `schema_migrations` table. A hostile remote could diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 3ff20c7f21..8b4d55c1ce 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -171,7 +171,7 @@ export type MigrationSync = export function reconcileMigrations( remote: ReadonlyArray, local: ReadonlyArray, - isLocal = false, + targetFlags: string, ): MigrationSync { // `MIGRATION_VERSION_MAX` pins the exhausted side of the walk; `parseMigrationVersion` is // shared with `migration list` so both surfaces skip the same edge-case versions. @@ -216,7 +216,7 @@ export function reconcileMigrations( if (extraRemote.length + extraLocal.length > 0) { return { kind: "conflict", - suggestion: suggestMigrationRepair(extraRemote, extraLocal, isLocal), + suggestion: suggestMigrationRepair(extraRemote, extraLocal, targetFlags), }; } if (local.length === 0) { @@ -225,31 +225,30 @@ export function reconcileMigrations( return { kind: "in-sync" }; } +/** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`). */ export function suggestMigrationRepair( extraRemote: ReadonlyArray, extraLocal: ReadonlyArray, - isLocal = false, + targetFlags: string, ): string { - const localFlag = isLocal ? " --local" : ""; let result = "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n"; for (const version of extraRemote) { - result += `${bold(`supabase migration repair${localFlag} --status reverted ${version}`)}\n`; + result += `${bold(`supabase migration repair ${targetFlags} --status reverted ${version}`)}\n`; } for (const version of extraLocal) { - result += `${bold(`supabase migration repair${localFlag} --status applied ${version}`)}\n`; + result += `${bold(`supabase migration repair ${targetFlags} --status applied ${version}`)}\n`; } return result; } /** Each generated line ends with a trailing newline, including the last one. */ -export function suggestRevertHistory(versions: ReadonlyArray, isLocal = false): string { - const localFlag = isLocal ? " --local" : ""; +export function suggestRevertHistory(versions: ReadonlyArray, targetFlags: string): string { return ( "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + - `${bold(`supabase migration repair${localFlag} --status reverted ${versions.join(" ")}`)}\n` + + `${bold(`supabase migration repair ${targetFlags} --status reverted ${versions.join(" ")}`)}\n` + "\nAnd update local migrations to match remote database:\n" + - `${bold(`supabase db pull${localFlag}`)}\n` + `${bold(`supabase db pull ${targetFlags}`)}\n` ); } diff --git a/apps/cli/src/command-internal/migration-history.unit.test.ts b/apps/cli/src/command-internal/migration-history.unit.test.ts index 0073012b0f..81935bf7d2 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -29,59 +29,71 @@ const failingSession = (error: DbExecError): DbSession => ({ describe("reconcileMigrations", () => { it("reports in-sync when remote and local match", () => { - expect(reconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ + expect(reconcileMigrations(["20240101000000"], ["20240101000000"], "--linked")).toEqual({ kind: "in-sync", }); }); it("reports missing only when both histories are empty", () => { - expect(reconcileMigrations([], [])).toEqual({ kind: "missing" }); - expect(reconcileMigrations(["20240101000000"], []).kind).toBe("conflict"); + expect(reconcileMigrations([], [], "--linked")).toEqual({ kind: "missing" }); + expect(reconcileMigrations(["20240101000000"], [], "--linked").kind).toBe("conflict"); }); it("reports a conflict with an extra remote migration", () => { - const result = reconcileMigrations(["20240101000000"], ["20240102000000"]); + const result = reconcileMigrations(["20240101000000"], ["20240102000000"], "--linked"); expect(result.kind).toBe("conflict"); if (result.kind === "conflict") { expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status reverted 20240101000000", + "supabase migration repair --linked --status reverted 20240101000000", ); expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status applied 20240102000000", + "supabase migration repair --linked --status applied 20240102000000", ); } }); it("reports a conflict with an extra local migration", () => { - const result = reconcileMigrations([], ["20240102000000"]); + const result = reconcileMigrations([], ["20240102000000"], "--linked"); expect(result.kind).toBe("conflict"); }); it("is in sync when an 8-digit and a 14-digit version share a prefix (#6036)", () => { expect( - reconcileMigrations(["20260420", "20260420010000"], ["20260420010000", "20260420"]), + reconcileMigrations( + ["20260420", "20260420010000"], + ["20260420010000", "20260420"], + "--linked", + ), ).toEqual({ kind: "in-sync" }); }); it("skips versions that do not parse as integers", () => { - expect(reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"])).toEqual({ + expect( + reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"], "--linked"), + ).toEqual({ kind: "in-sync", }); }); it("skips empty / whitespace versions (not Number() coercion)", () => { - expect(reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ + expect( + reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"], "--linked"), + ).toEqual({ kind: "in-sync", }); }); it("treats a version within the int64 range as a real conflict", () => { - expect(reconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); + expect(reconcileMigrations(["9999999999999999"], [], "--linked").kind).toBe("conflict"); }); it("skips a version beyond the int64 range instead of hanging the scan", () => { expect( - reconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), + reconcileMigrations( + ["20240101000000", "9999999999999999999"], + ["20240101000000"], + "--linked", + ), ).toEqual({ kind: "in-sync" }); }); }); @@ -174,21 +186,29 @@ describe("findPendingMigrations", () => { }); describe("suggestMigrationRepair", () => { - it("lists reverted (remote) then applied (local) repair commands", () => { - const out = stripAnsi(suggestMigrationRepair(["111"], ["222"])); + it("lists reverted (remote) then applied (local) repair commands for the target", () => { + const out = stripAnsi(suggestMigrationRepair(["111"], ["222"], "--linked")); expect(out).toContain("try repairing the migration history table:"); - expect(out).toContain("supabase migration repair --status reverted 111"); - expect(out).toContain("supabase migration repair --status applied 222"); + expect(out).toContain("supabase migration repair --linked --status reverted 111"); + expect(out).toContain("supabase migration repair --linked --status applied 222"); + }); + + it("carries a local or db-url target into every repair command", () => { + expect(stripAnsi(suggestMigrationRepair(["111"], [], "--local"))).toContain( + "supabase migration repair --local --status reverted 111", + ); + expect(stripAnsi(suggestMigrationRepair([], ["222"], "--db-url "))).toContain( + "supabase migration repair --db-url --status applied 222", + ); }); }); describe("suggestRevertHistory", () => { it("builds the revert-history suggestion with a trailing newline per line", () => { - expect(suggestRevertHistory(["0002", "0003"])).toContain( - "supabase migration repair --status reverted 0002 0003", - ); - expect(suggestRevertHistory(["0002"])).toMatch(/\n$/u); - expect(suggestRevertHistory(["0002"])).toContain("supabase db pull"); + const out = stripAnsi(suggestRevertHistory(["0002", "0003"], "--linked")); + expect(out).toContain("supabase migration repair --linked --status reverted 0002 0003"); + expect(out).toContain("supabase db pull --linked"); + expect(out).toMatch(/\n$/u); }); }); diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index e87050ea85..d576845933 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -3,6 +3,8 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { readProjectRefFile, tempPaths } from "../shared/config/temp-paths.ts"; +import { announceTarget } from "../shared/output/command-target.service.ts"; +import { describeLinkedTarget } from "./target/target-identity.ts"; export type ParentRefResolution = | { readonly kind: "resolved"; readonly ref: string } @@ -58,6 +60,17 @@ export function parseCachedLinkedProject(content: string): Option.Option/supabase/.temp/linked-project.json`; any failure is `None`. */ +export const readCachedLinkedProject = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): Effect.Effect> => + fs.readFileString(tempPaths(path, workdir).linkedProjectCache).pipe( + Effect.map(parseCachedLinkedProject), + Effect.orElseSucceed(() => Option.none()), + ); + function parseCachedParentRef(content: string): Option.Option { return Option.map(parseCachedLinkedProject(content), (project) => project.ref); } @@ -140,6 +153,13 @@ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve yield* Effect.annotateCurrentSpan("parent_project_ref.linked_parent", parent.kind); if (parent.kind === "resolved") { yield* Effect.annotateCurrentSpan("parent_project_ref.source", "linked_parent"); + const cliSettings = yield* CommandSettings; + const cache = yield* readCachedLinkedProject( + yield* FileSystem.FileSystem, + yield* Path.Path, + cliSettings.workdir, + ); + yield* announceTarget(describeLinkedTarget({ ref: parent.ref, cache })); return parent.ref; } diff --git a/apps/cli/src/command-internal/pooler-fallback.ts b/apps/cli/src/command-internal/pooler-fallback.ts index 7ecdc66e31..ecc9a0b4d4 100644 --- a/apps/cli/src/command-internal/pooler-fallback.ts +++ b/apps/cli/src/command-internal/pooler-fallback.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Predicate } from "effect"; import { Output } from "../shared/output/output.service.ts"; import { yellow } from "./colors.ts"; @@ -8,6 +8,21 @@ export function isDirectDbHost(host: string, projectHost: string): boolean { return host.startsWith("db.") && host.endsWith(`.${projectHost}`); } +/** + * Treats a failed pooler resolution as "no fallback" so the caller keeps its original error, except + * an unapproved network-ban removal, whose "re-run with --yes" guidance would otherwise be lost. + */ +export const orNoPoolerFallback = ( + resolve: Effect.Effect, E, R>, +) => + resolve.pipe( + Effect.catch((error) => + Predicate.isTagged(error, "LinkedDbNetworkBannedError") + ? Effect.fail(error) + : Effect.succeed(Option.none()), + ), + ); + export interface PoolerFallbackOptions { readonly run: Effect.Effect; readonly retry: (pooler: PgConnInput) => Effect.Effect; @@ -32,10 +47,12 @@ export const emitPoolerFallbackWarning = (host: string): Effect.Effect( options: PoolerFallbackOptions, -): Effect.Effect { - const resolveFallback = options.resolveFallback.pipe( - Effect.orElseSucceed(() => Option.none()), - ); +): Effect.Effect< + A, + E | (EF & { readonly _tag: "LinkedDbNetworkBannedError" }), + R | R2 | RF | Output +> { + const resolveFallback = orNoPoolerFallback(options.resolveFallback); const retryOrReturn = (result: A) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts index b32a0f8444..08e43655ca 100644 --- a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts +++ b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { Output } from "../shared/output/output.service.ts"; +import { DbConfigPoolerLoginError, LinkedDbNetworkBannedError } from "./db-config.errors.ts"; import type { PgConnInput } from "./db-connection.service.ts"; import { runWithPoolerFallback } from "./pooler-fallback.ts"; @@ -175,4 +176,46 @@ describe("runWithPoolerFallback", () => { expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); }).pipe(Effect.provide(out.layer)); }); + + it.live( + "surfaces an unapproved network-ban removal instead of keeping the original result", + () => { + const out = captureOutput(); + const banned = new LinkedDbNetworkBannedError({ + message: "Network bans (203.0.113.7) may be blocking the connection.", + suggestion: "Re-run with --yes to remove them.", + }); + + return Effect.gen(function* () { + const exit = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(banned), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }).pipe(Effect.exit); + + expect(exit).toEqual(Exit.fail(banned)); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(out.layer)); + }, + ); + + it.live("keeps the original result when the pooler cannot be resolved for another reason", () => { + const out = captureOutput(); + + return Effect.gen(function* () { + const result = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(new DbConfigPoolerLoginError({ message: "tenant mismatch" })), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }); + + expect(result).toEqual({ exitCode: 1, stderr: "network is unreachable" }); + }).pipe(Effect.provide(out.layer)); + }); }); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 2c79ff59c6..29108d2809 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -4,6 +4,8 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { OutputFlag } from "./global-flags.ts"; +import { requireHostedMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { Output } from "../shared/output/output.service.ts"; import { encodeEnv, encodeSortedJson } from "./output.encoders.ts"; import { @@ -58,6 +60,8 @@ export interface ProjectCreateInput { * `bootstrap` suppresses it since it emits its own top-level result instead. */ readonly emitStructuredResult: boolean; + /** Proof that the caller already confirmed the create; without it the core asks before the POST. */ + readonly consent?: HostedMutationConsent; } /** Formats `key: value`, padding `key:` to width 20. */ @@ -125,6 +129,12 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( ...(input.templateUrl !== undefined ? { template_url: input.templateUrl } : {}), }; + if (input.consent === undefined) { + yield* requireHostedMutationConsent({ + action: `Create project "${name}" in organization ${orgId}`, + }); + } + const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; // `executeRaw` skips output decoding so the created project is emitted as the API returned it, diff --git a/apps/cli/src/command-internal/project-target.integration.test.ts b/apps/cli/src/command-internal/project-target.integration.test.ts new file mode 100644 index 0000000000..2ebd4e2de3 --- /dev/null +++ b/apps/cli/src/command-internal/project-target.integration.test.ts @@ -0,0 +1,111 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { + mockCommandPlatformApiService, + mockCommandSettings, +} from "../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import type { ProjectRefSource } from "../config/project-ref.service.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { + configTargetErrorsFor, + mintConfigTargetErrors, + resolveConfigTarget, +} from "./project-target.ts"; + +const PARENT_REF = "parentparentparentpa"; +const BRANCH_REF = "branchbranchbranchbr"; +const BRANCH_UUID = "11111111-2222-4333-8444-555555555555"; + +const minted = mintConfigTargetErrors("ProjectTargetTest"); +const errors = configTargetErrorsFor({ + notLinked: minted.BranchNotLinkedError, + parentRefInvalid: minted.ParentRefInvalidError, + branchNotFound: minted.BranchNotFoundError, + branchNotReady: minted.BranchNotReadyError, +}); + +const resolverFor = (source: ProjectRefSource, matchesLinkedRef: boolean) => + Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + Effect.succeed({ + ref: Option.getOrElse(flagValue, () => PARENT_REF), + source, + matchesLinkedRef, + }), + resolve: () => Effect.die("resolve is not used by resolveConfigTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveConfigTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveConfigTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveConfigTarget"), + }); + +const run = ( + requested: Option.Option, + options: { readonly savedRef?: string; readonly source?: ProjectRefSource } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "project-target-" }); + if (options.savedRef !== undefined) { + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); + } + const out = mockOutput(); + const api = mockCommandPlatformApiService({ + v1: { getABranchConfig: () => Effect.succeed({ ref: BRANCH_REF }) }, + }); + return yield* resolveConfigTarget(requested, errors, () => Effect.die("unexpected")).pipe( + Effect.provide( + Layer.mergeAll( + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + resolverFor(options.source ?? "flag", options.savedRef === PARENT_REF), + api.layer, + mockCommandSettings({ workdir: dir, projectId: Option.none() }), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +describe("resolveConfigTarget linked target", () => { + it.effect("carries the source and linked-ref match the ref chain resolved", () => + Effect.gen(function* () { + const flag = yield* run(Option.some(PARENT_REF), { savedRef: PARENT_REF }); + expect(flag.linked).toEqual({ + kind: "linked", + ref: PARENT_REF, + source: "flag", + matchesLinkedRef: true, + }); + const file = yield* run(Option.none(), { source: "file", savedRef: PARENT_REF }); + expect(file.linked).toMatchObject({ source: "file", matchesLinkedRef: true }); + }), + ); + + it.effect("marks a branch resolved from a UUID as a flag source", () => + Effect.gen(function* () { + const target = yield* run(Option.some(BRANCH_UUID)); + expect(target).toMatchObject({ ref: BRANCH_REF, branch: BRANCH_UUID }); + expect(target.linked).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + }); + }), + ); + + it.effect("matches the linked ref when the branch's own ref is the saved one", () => + Effect.gen(function* () { + const target = yield* run(Option.some(BRANCH_UUID), { savedRef: BRANCH_REF }); + expect(target.linked.matchesLinkedRef).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 9505cc40b3..2152727bb9 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -1,7 +1,9 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Data, Effect, Option } from "effect"; +import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import { readProjectRefFile } from "../shared/config/temp-paths.ts"; import { parentNotLinkedMessage, parentRefInvalidMessage, @@ -9,12 +11,15 @@ import { resolveLinkedParentRef, resolveParentScopedProjectRef, } from "./parent-project-ref.ts"; +import { announceTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { projectRefPicker } from "../config/project-ref.layer.ts"; import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; import { compose, withCondition } from "./command-permissions/model.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; +import { type LinkedTarget, linkedTarget } from "./target/resolve-target.ts"; +import { renderBranchTarget } from "./target/target-identity.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -31,6 +36,11 @@ export interface ConfigTarget { readonly branch: string | undefined; } +/** A {@link ConfigTarget} with the same ref as the linked target connection resolution takes. */ +interface ResolvedConfigTarget extends ConfigTarget { + readonly linked: LinkedTarget; +} + /** * Builds the four target-resolution failures {@link resolveConfigTarget} can raise. * A single type parameter suffices because {@link configTargetErrorsFor} already unions @@ -177,6 +187,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* let ref: string; let branch: string | undefined; + let linked: LinkedTarget; if (Option.isSome(requested) && !BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { const target = requested.value; branch = target; @@ -184,6 +195,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); let parentRef: ReturnType; + let parentProjectRef = Option.none(); if (byId) { parentRef = resolveParentScopedProjectRef(Option.none()); } else { @@ -195,6 +207,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* return yield* Effect.fail(errors.parentRefInvalid(target)); } parentRef = Effect.succeed(parent.ref); + parentProjectRef = Option.some(parent.ref); } const resolving = @@ -213,11 +226,31 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { return yield* Effect.fail(errors.branchNotReady(target)); } + yield* announceTarget( + renderBranchTarget({ + branch: byId ? Option.none() : Option.some(target), + ref, + parentRef: parentProjectRef, + }), + ); + const { workdir } = yield* CommandSettings; + const linkedRef = yield* readProjectRefFile( + yield* FileSystem.FileSystem, + yield* Path.Path, + workdir, + ).pipe(Effect.orElseSucceed(() => Option.none())); + linked = linkedTarget({ + ref, + source: "flag", + matchesLinkedRef: Option.contains(linkedRef, ref), + }); } else { yield* Effect.annotateCurrentSpan("project_target.kind", "project"); - ref = yield* resolver.resolve(requested); + const resolved = yield* resolver.resolveRef(requested); + ref = resolved.ref; + linked = linkedTarget(resolved); } yield* Effect.annotateCurrentSpan("project.ref", ref); - return { ref, branch }; + return { ref, branch, linked } satisfies ResolvedConfigTarget; }); diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 957a41dad7..8e0ced02b7 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -32,6 +32,7 @@ import { isVectorBucketsFeatureNotEnabled, } from "../commands/seed/buckets/buckets.classify.ts"; import { SeedConfigLoadError } from "../commands/seed/buckets/buckets.errors.ts"; +import { describeConfigParseError } from "./workdir-project.ts"; import { bucketObjectKey } from "../commands/seed/buckets/buckets.upload.ts"; // Re-exported so `stack start` can report a project-context config-load failure with the @@ -110,7 +111,12 @@ const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); * `emitSummary` gates the stdout summary; `interactive` (default `true`) gates overwrite/prune * prompts. The caller owns project-ref resolution, cache writes, and telemetry. */ -export const seedBucketsRun = Effect.fnUntraced(function* (opts: { +export const seedBucketsRun = Effect.fnUntraced(function* < + EA = never, + RA = never, + EC = never, + RC = never, +>(opts: { readonly projectRef: string; readonly emitSummary: boolean; readonly interactive?: boolean; @@ -151,6 +157,13 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { * would swallow the next line of a `curl … | bash` script. Declined prunes say so instead. */ readonly promptless?: boolean; + /** Announces the local Storage endpoint as the run's target once its URL is known. */ + readonly announceLocalEndpoint?: (baseUrl: string) => Effect.Effect; + /** + * Asks for consent to the hosted plan's writes before the first one. One answer covers every + * bucket overwrite and prune, so no per-bucket prompt follows it. + */ + readonly confirmHostedWrites?: (details: ReadonlyArray) => Effect.Effect; }) { const output = yield* Output; const cliSettings = yield* CommandSettings; @@ -177,7 +190,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { "CliConfigParseError", (cause) => new SeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + message: describeConfigParseError(workdir, cause), }), ), Effect.mapError((cause) => @@ -247,6 +260,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // Build the Storage service-gateway client (local or remote). const credentials = opts.credentials ?? (yield* resolveStorageCredentials({ projectRef })); + if (projectRef === "" && opts.announceLocalEndpoint !== undefined) { + yield* opts.announceLocalEndpoint(credentials.baseUrl); + } // Gateway operations use plain `globalThis.fetch` instead of the DoH fetch, with the Kong CA in // `FetchHttpClient.RequestInit` for local + https; the api-keys lookup in @@ -260,16 +276,36 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const summary = emptySummary(); - yield* upsertBuckets(output, yes, interactive, promptless, gateway, bucketPropsByName, summary); + const existingBuckets = yield* gateway.listBuckets; + + // A hosted run never prompts per item: one consent covers every overwrite and prune. + const confirmHostedWrites = projectRef === "" ? undefined : opts.confirmHostedWrites; + if (confirmHostedWrites !== undefined) { + const details = yield* planHostedWrites(gateway, config, bucketPropsByName, existingBuckets); + if (details.length > 0) yield* confirmHostedWrites(details); + } + const approved = confirmHostedWrites !== undefined || yes; + const skipPrompts = confirmHostedWrites !== undefined || promptless; + + yield* upsertBuckets( + output, + approved, + interactive, + skipPrompts, + gateway, + existingBuckets, + bucketPropsByName, + summary, + ); // Upsert analytics buckets (remote --linked only). if (config.storage.analytics.enabled && projectRef !== "") { yield* output.raw("Updating analytics buckets...\n", "stderr"); yield* upsertAnalyticsBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, Object.keys(config.storage.analytics.buckets), summary, @@ -281,9 +317,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { yield* output.raw("Updating vector buckets...\n", "stderr"); yield* upsertVectorBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, vectorBucketNames, summary, @@ -340,6 +376,8 @@ const computeBucketProps = ( }), }); +type ExistingBucket = { readonly id: string; readonly name: string }; + // `propsByName` is precomputed and size-validated before this runs. const upsertBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -347,10 +385,10 @@ const upsertBuckets = Effect.fnUntraced(function* ( interactive: boolean, promptless: boolean, gateway: StorageGateway, + existing: ReadonlyArray, propsByName: ReadonlyMap, summary: SeedSummary, ) { - const existing = yield* gateway.listBuckets; const byName = new Map(existing.map((b) => [b.name, b.id])); for (const [name, props] of propsByName) { @@ -478,6 +516,71 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( } }); +const joinNames = (names: ReadonlyArray): string => names.join(", "); + +/** + * The hosted writes `seedBucketsRun` is about to make, as consent detail lines; empty when it + * would write nothing. Reads the remote bucket lists but changes nothing. + */ +const planHostedWrites = Effect.fnUntraced(function* ( + gateway: StorageGateway, + config: CliConfig, + propsByName: ReadonlyMap, + existingBuckets: ReadonlyArray, +) { + const details: Array = []; + const existing = new Set(existingBuckets.map((bucket) => bucket.name)); + const configured = [...propsByName.keys()]; + const create = configured.filter((name) => !existing.has(name)); + const overwrite = configured.filter((name) => existing.has(name)); + if (create.length > 0) details.push(`Create buckets: ${joinNames(create)}`); + if (overwrite.length > 0) + details.push(`Overwrite the properties of buckets: ${joinNames(overwrite)}`); + + const withObjects = Object.entries(config.storage.buckets ?? {}) + .filter(([, bucket]) => bucket.objects_path.length > 0) + .map(([name]) => name); + if (withObjects.length > 0) { + details.push( + `Upload objects, overwriting existing ones, to buckets: ${joinNames(withObjects)}`, + ); + } + + if (config.storage.analytics.enabled) { + const declared = Object.keys(config.storage.analytics.buckets); + const present = new Set(yield* gateway.listAnalyticsBuckets); + const created = declared.filter((name) => !present.has(name)); + const pruned = [...present].filter((name) => !declared.includes(name)); + if (created.length > 0) details.push(`Create analytics buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete analytics buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + + const declaredVector = Object.keys(config.storage.vector.buckets); + if (config.storage.vector.enabled && declaredVector.length > 0) { + const listed = yield* gateway.listVectorBuckets.pipe( + Effect.map(Option.some), + Effect.catch((error) => + isVectorBucketsFeatureNotEnabled(error.message) || + isLocalVectorBucketsUnavailable(error.message) + ? Effect.succeed(Option.none>()) + : Effect.fail(error), + ), + ); + if (Option.isSome(listed)) { + const present = new Set(listed.value); + const created = declaredVector.filter((name) => !present.has(name)); + const pruned = listed.value.filter((name) => !declaredVector.includes(name)); + if (created.length > 0) details.push(`Create vector buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete vector buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + } + return details; +}); + /** * On `FeatureNotEnabled` or local-unavailable errors, prints the matching warning and * continues (object upload still runs); any other error propagates. diff --git a/apps/cli/src/command-internal/seed-remote-consent.errors.ts b/apps/cli/src/command-internal/seed-remote-consent.errors.ts deleted file mode 100644 index 424b028b7c..0000000000 --- a/apps/cli/src/command-internal/seed-remote-consent.errors.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Data } from "effect"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; - -/** Seeding a project that matched a `[remotes.*]` block needs consent, and this run can't prompt for it. */ -export class SeedConsentRequiredError extends Data.TaggedError("SeedConsentRequiredError")<{ - readonly message: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts new file mode 100644 index 0000000000..0ef8cc2bf7 --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts @@ -0,0 +1,96 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { planDbSeed } from "./seed-remote-consent.ts"; + +const REF = "abcdefghijklmnopqrst"; + +const plan = ( + toml: string, + options: { + readonly projectRef?: Option.Option; + readonly env?: Record; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "seed-plan-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), toml); + const configValues = yield* CliConfigValues; + const resolved = yield* configValues.load({ + workdir, + projectRef: options.projectRef ?? Option.some(REF), + }); + return yield* planDbSeed(resolved, { workdir, ref: REF }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + configValuesLayer({ env: options.env }), + mockCommandSettings({ workdir: "/unused" }), + BunServices.layer, + ), + ), + ); + +const FILES = ["supabase/seed.sql", "supabase/seed_users.sql"]; + +describe("planDbSeed", () => { + it.effect("describes the files without naming a remote when none matched", () => + Effect.gen(function* () { + const { input, detail } = yield* plan("", { projectRef: Option.none() }); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files"); + expect(detail(FILES.slice(0, 1))).toBe("seed 1 file"); + }), + ); + + it.effect("names the matched remote and what turned seeding on", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`, { + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + }); + expect(input.enabled).toBe(true); + expect(input.consent).toMatchObject({ remote: "prod", ref: REF }); + expect(input.consent?.enabledBy).toContain("SUPABASE_DB_SEED_ENABLED"); + expect(detail(FILES)).toBe( + `seed 2 files into [remotes.prod] (seeding enabled by ${input.consent?.enabledBy})`, + ); + }), + ); + + it.effect("omits the origin when the remote enables seeding itself", () => + Effect.gen(function* () { + const { input, detail } = yield* plan( + `[remotes.prod]\nproject_id = "${REF}"\n[remotes.prod.db.seed]\nenabled = true\n`, + ); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files into [remotes.prod]"); + }), + ); + + it.effect("has no detail when a matched remote leaves seeding off", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`); + expect(input.enabled).toBe(false); + expect(detail(FILES)).toBeUndefined(); + }), + ); + + it.effect("has no detail when seeding is off or there is nothing to seed", () => + Effect.gen(function* () { + const off = yield* plan("[db.seed]\nenabled = false\n"); + expect(off.input.enabled).toBe(false); + expect(off.detail(FILES)).toBeUndefined(); + + const none = yield* plan(""); + expect(none.detail([])).toBeUndefined(); + }), + ); +}); diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts index 5b4582b456..3c71a2ea0b 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -3,32 +3,28 @@ import { Effect, Option, Path, Predicate } from "effect"; import { CliConfigKeys } from "../config/cli-config-keys.ts"; import { describeCliConfigOrigin } from "../config/cli-config-key.ts"; import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; -import { Output } from "../shared/output/output.service.ts"; -import { Tty } from "../shared/runtime/tty.service.ts"; -import { promptYesNoOutcome } from "./prompt-yes-no.ts"; -import { SeedConsentRequiredError } from "./seed-remote-consent.errors.ts"; -/** A seed run that needs consent because its target matched a `[remotes.*]` block. */ -export interface SeedConsentTarget { +/** A seed run whose target matched a `[remotes.*]` block that did not turn seeding on itself. */ +interface SeedConsentTarget { readonly ref: string; readonly remote: string; /** What turned seeding on, as `describeCliConfigOrigin` words it. */ readonly enabledBy: string; } -/** The effective `[db.seed]` values a command acts on, plus the consent its target needs. */ -export interface DbSeedInput { +/** The effective `[db.seed]` values a command acts on, plus the remote that did not enable them. */ +interface DbSeedInput { readonly enabled: boolean; readonly sqlPaths: ReadonlyArray; readonly consent: SeedConsentTarget | undefined; } -type SeedConsentCommand = "push" | "reset"; - -const SEED_CONSENT_SUGGESTIONS: Record = { - push: "Pass --yes to seed, or drop --include-seed to push migrations only.", - reset: "Pass --yes to seed, or --no-seed to reset without seeding.", -}; +/** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ +export interface DbSeedPlan { + readonly input: DbSeedInput; + /** `undefined` when seeding is off or `files` is empty. */ + readonly detail: (files: ReadonlyArray) => string | undefined; +} const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: string): boolean => { const block = resolvedConfig.loaded.interpolatedRemotes?.[remote]; @@ -41,11 +37,8 @@ const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: st return CliConfigKeys.db.seed.enabled.codec.fromConfig(declared) === true; }; -/** - * Reads `[db.seed]` through the resolved config. Seeding needs consent only when the target matched a - * `[remotes.*]` block that does not itself turn seeding on. - */ -export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( +/** Reads `[db.seed]` through the resolved config; the caller's single consent carries {@link DbSeedPlan.detail}. */ +export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( resolvedConfig: ResolvedCliConfig, target: { readonly workdir: string; readonly ref: string }, ) { @@ -61,65 +54,15 @@ export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( remote, enabledBy: describeCliConfigOrigin(enabled.origin, { workdir: target.workdir, path }), }; - return { enabled: enabled.value, sqlPaths: sqlPaths.value, consent } satisfies DbSeedInput; + const input: DbSeedInput = { enabled: enabled.value, sqlPaths: sqlPaths.value, consent }; + return { + input, + detail: (files) => { + if (!input.enabled || files.length === 0) return undefined; + const noun = files.length === 1 ? "file" : "files"; + const into = remote === undefined ? "" : ` into [remotes.${remote}]`; + const enabledBy = consent === undefined ? "" : ` (seeding enabled by ${consent.enabledBy})`; + return `seed ${files.length} ${noun}${into}${enabledBy}`; + }, + } satisfies DbSeedPlan; }); - -/** Whether a prompt can reach someone: a real terminal, or a piped line standing in for one. */ -const canPromptForSeed = Effect.fnUntraced(function* () { - const output = yield* Output; - const tty = yield* Tty; - return tty.stdinIsTty ? output.interactive && output.format === "text" : true; -}); - -const requiredError = (target: SeedConsentTarget, command: SeedConsentCommand) => - new SeedConsentRequiredError({ - message: `Seeding ${target.ref} ([remotes.${target.remote}]) needs confirmation and this run can't prompt. Nothing was changed.`, - suggestion: SEED_CONSENT_SUGGESTIONS[command], - }); - -/** The note `--dry-run` prints so a real run's consent step isn't a surprise. */ -export const seedConsentDryRunNote = Effect.fnUntraced(function* ( - target: SeedConsentTarget, - yes: boolean, -) { - if (yes) return; - const output = yield* Output; - const action = (yield* canPromptForSeed()) - ? "will ask before seeding" - : "will need --yes to seed"; - yield* output.raw(`A real run ${action} ${target.ref} ([remotes.${target.remote}]).\n`, "stderr"); -}); - -/** - * Asks before seeding a project whose target matched a `[remotes.*]` block, defaulting to no. - * Returns `false` when the answer is no; fails with `SeedConsentRequiredError` when nothing can ask - * or piped stdin ends without an answer line. - */ -export const confirmSeedIntoMatchedRemote = Effect.fnUntraced(function* (input: { - readonly command: SeedConsentCommand; - readonly target: SeedConsentTarget; - readonly files: ReadonlyArray; - readonly yes: boolean; -}) { - const { command, target, files, yes } = input; - const output = yield* Output; - if (!yes && !(yield* canPromptForSeed())) return yield* requiredError(target, command); - const noun = files.length === 1 ? "seed file" : "seed files"; - const outcome = yield* promptYesNoOutcome( - output, - yes, - `Project ${target.ref} matches [remotes.${target.remote}]. Run ${files.length} ${noun} (${files.join(", ")}) against it?`, - false, - true, - { readMachineStdin: true }, - ); - if (!outcome.answered) return yield* requiredError(target, command); - if (outcome.value && yes) yield* output.raw(`Seeding enabled by ${target.enabledBy}\n`, "stderr"); - return outcome.value; -}); - -/** The cancelled message both commands use when the seed prompt is answered no. */ -export const SEED_CANCELLED_MESSAGE = "Seeding cancelled; nothing was changed."; - -export const seedCancelledSuggestion = (command: SeedConsentCommand): string => - SEED_CONSENT_SUGGESTIONS[command]; diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 85b1b799db..fa2cc45463 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -10,6 +10,8 @@ interface State { /** Returns the next state, or `null` to emit a token (statement boundary). */ next(char: string, data: string): State | null; + /** `true` while inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly open?: boolean; } const BEGIN_ATOMIC = "ATOMIC"; @@ -50,33 +52,55 @@ function isBeginAtomic(data: string): boolean { return endsWithKeyword(data.slice(0, end), "BEGIN"); } -function isCommentsAndWhitespace(text: string): boolean { +// A character that ends a delimiter can't start the next one: `/*/` opens a comment without +// closing it, and `*/*` closes one before a lone `*`. +const blockDelimiterDelta = (window: string): -1 | 0 | 1 => + window === "/*" ? 1 : window === "*/" ? -1 : 0; + +// Index of the first character past leading blanks and comments, or `text.length` when only +// trivia (or an unterminated comment) remains. +function skipLeadingTrivia(text: string, isBlank: (char: string) => boolean): number { let i = 0; while (i < text.length) { - if (isSqlWhitespace(text[i]!)) { + if (isBlank(text[i]!)) { i += 1; } else if (text.startsWith("--", i)) { const newline = text.slice(i + 2).search(/[\n\r]/u); - if (newline === -1) return true; + if (newline === -1) return text.length; i += newline + 3; } else if (text.startsWith("/*", i)) { - // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. let depth = 1; + let afterDelimiter = true; i += 2; while (i < text.length && depth > 0) { - const window = text.slice(i - 1, i + 1); - if (window === "/*") depth += 1; - else if (window === "*/") depth -= 1; + const delta: number = afterDelimiter ? 0 : blockDelimiterDelta(text.slice(i - 1, i + 1)); + afterDelimiter = delta !== 0; + depth += delta; i += 1; } - if (depth > 0) return true; + if (depth > 0) return text.length; } else { - return false; + return i; } } - return true; + return text.length; } +const isCommentsAndWhitespace = (text: string): boolean => + skipLeadingTrivia(text, isSqlWhitespace) === text.length; + +const BYTE_ORDER_MARK = "\uFEFF"; + +/** + * Strips leading whitespace, byte order marks and line/block comments (block comments nest, as + * PostgreSQL scans them) so a keyword check sees the statement's first token. Empty when only + * trivia or an unterminated comment remains. + */ +export const trimLeadingSqlComments = (sql: string): string => + sql + .slice(skipLeadingTrivia(sql, (char) => isSqlWhitespace(char) || char === BYTE_ORDER_MARK)) + .trim(); + class ReadyState implements State { next(char: string, data: string): State | null { switch (char) { @@ -129,18 +153,16 @@ class LineCommentState implements State { class BlockState implements State { private depth = 0; + private afterDelimiter = false; + get open(): boolean { + return this.depth > 0; + } next(char: string, data: string): State | null { - const window = data.slice(-2); - if (window === "/*") { - this.depth += 1; - return this; - } - if (this.depth === 0) return new ReadyState().next(char, data); - if (window === "*/") { - this.depth -= 1; - if (this.depth === 0) return new ReadyState(); - } - return this; + const delta = this.afterDelimiter ? 0 : blockDelimiterDelta(data.slice(-2)); + this.afterDelimiter = delta !== 0; + if (this.depth === 0 && delta === 0) return new ReadyState().next(char, data); + this.depth += delta; + return this.depth === 0 ? new ReadyState() : this; } } @@ -151,6 +173,9 @@ class QuoteState implements State { private readonly delimiter: string, private readonly backslashEscapes: boolean, ) {} + get open(): boolean { + return !this.escape; + } next(char: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). @@ -204,6 +229,7 @@ class QuoteContinueState implements State { } class DollarState implements State { + readonly open = true; constructor(private readonly delimiter: string) {} next(_char: string, data: string): State | null { if (data.slice(-this.delimiter.length) === this.delimiter) return new ReadyState(); @@ -228,6 +254,9 @@ class EscapeState implements State { class ParenState implements State { constructor(private prev: State) {} + get open(): boolean { + return this.prev.open === true; + } next(char: string, data: string): State | null { const curr = this.prev.next(char, data); if (curr === null) { @@ -250,6 +279,9 @@ class AtomicState implements State { ) { this.statementStart = start; } + get open(): boolean { + return this.prev.open === true; + } next(char: string, data: string): State | null { const pendingEnd = this.pendingEnd; this.pendingEnd = false; @@ -289,8 +321,14 @@ interface RawToken { readonly terminated: boolean; } +interface RawSplit { + readonly tokens: RawToken[]; + /** The input ends inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly unterminated: boolean; +} + /** The FSM traversal shared by every `splitSql*` entry point below. */ -function splitRaw(sql: string): RawToken[] { +function splitRaw(sql: string): RawSplit { let state: State = new ReadyState(); const tokens: RawToken[] = []; // Slice each token from `sql` instead of growing it with `+=`: states read the token's tail every @@ -312,7 +350,7 @@ function splitRaw(sql: string): RawToken[] { } // Trailing non-terminated statement at EOF. if (end > start) tokens.push({ text: sql.slice(start), terminated: false }); - return tokens; + return { tokens, unterminated: state.open === true }; } /** @@ -324,7 +362,7 @@ export function splitSql( ...transform: ReadonlyArray<(s: string) => string> ): string[] { const statements: string[] = []; - for (const { text: raw } of splitRaw(sql)) { + for (const { text: raw } of splitRaw(sql).tokens) { let token = raw; for (const apply of transform) token = apply(token); if (token.length > 0) statements.push(token); @@ -340,6 +378,16 @@ export function splitAndTrim(sql: string): string[] { return splitSql(sql, trimStatement); } +/** `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote. */ +export function splitAndTrimChecked(sql: string): { + readonly statements: string[]; + readonly unterminated: boolean; +} { + const { tokens, unterminated } = splitRaw(sql); + const statements = tokens.map(({ text }) => trimStatement(text)).filter((s) => s.length > 0); + return { statements, unterminated }; +} + /** One statement, paired with both its raw and trimmed forms. */ export interface SplitSqlToken { /** The exact text `splitSql(sql)` (no transforms) would emit for this statement. */ @@ -364,7 +412,7 @@ export interface SplitSqlToken { * empty, so callers counting only non-empty trimmed statements still see every raw token. */ export function splitSqlTokens(sql: string): ReadonlyArray { - return splitRaw(sql).map(({ text: raw, terminated }) => ({ + return splitRaw(sql).tokens.map(({ text: raw, terminated }) => ({ raw, trimmed: trimStatement(raw), terminated, diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index 374275125c..a3437b2b2a 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { findDropStatements, splitAndTrim, splitSql, splitSqlTokens } from "./sql-split.ts"; +import { + findDropStatements, + splitAndTrim, + splitAndTrimChecked, + splitSql, + splitSqlTokens, + trimLeadingSqlComments, +} from "./sql-split.ts"; describe("splitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -142,6 +149,35 @@ describe("splitAndTrim", () => { ]); }); + it.each([ + ["a closing */ directly followed by *", "/* /* */* x */ COMMIT; DELETE FROM t; SELECT 1"], + ["a closing */ directly followed by /*", "/* /* *//* */ */ COMMIT; DELETE FROM t; SELECT 1"], + ["a doubled star before the closing slash", "/* /* **/ */ COMMIT; DELETE FROM t; SELECT 1"], + ])("closes a nested block comment with %s", (_name, sql) => { + const statements = splitAndTrim(sql); + expect(statements).toHaveLength(3); + expect(statements.slice(1)).toEqual(["DELETE FROM t", "SELECT 1"]); + expect(statements[0]!.endsWith("COMMIT")).toBe(true); + }); + + it("does not let /*/ close the comment it opens", () => { + expect(splitAndTrim("SELECT 1 /*/ ; */ ; SELECT 2")).toEqual(["SELECT 1 /*/ ; */", "SELECT 2"]); + }); + + it("reads /*/ inside a comment as a nested opener only", () => { + expect(splitAndTrim("/* a /*/ b */ ; */ SELECT 1; SELECT 2")).toEqual([ + "/* a /*/ b */ ; */ SELECT 1", + "SELECT 2", + ]); + }); + + it("closes a comment written as /**/ and /***/", () => { + expect(splitAndTrim("SELECT /**/ 1; SELECT /***/ 2")).toEqual([ + "SELECT /**/ 1", + "SELECT /***/ 2", + ]); + }); + it("does not split inside a BEGIN ATOMIC body", () => { const sql = "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; SELECT 2; END; SELECT 3;"; @@ -247,9 +283,9 @@ describe("splitAndTrim", () => { ]); }); - it("does not close a BEGIN ATOMIC body at an END after an overlapping block comment", () => { + it("does not close a BEGIN ATOMIC body at an END after a block comment nesting /*/", () => { const body = - "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ SELECT 2 END; SELECT 3; END"; + "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ */ SELECT 2 END; SELECT 3; END"; expect(splitAndTrim(`${body}; SELECT 4;`)).toEqual([body, "SELECT 4"]); }); @@ -262,6 +298,44 @@ describe("splitAndTrim", () => { }); }); +describe("splitAndTrimChecked", () => { + it("returns the statements splitAndTrim returns", () => { + const sql = "SELECT 1; /* a */ SELECT 'b;';\nSELECT $$c; d$$"; + expect(splitAndTrimChecked(sql).statements).toEqual(splitAndTrim(sql)); + }); + + it.each([ + ["an open block comment", "SELECT 1 /* a"], + ["an open nested block comment", "SELECT 1 /* a /* b */"], + ["an open string", "SELECT 'a"], + ["a string ending in a doubled quote", "SELECT 'a''"], + ["an open quoted identifier", 'SELECT "a'], + ["an open escape string", String.raw`SELECT E'a\'`], + ["an open dollar quote", "SELECT $$a"], + ["an open tagged dollar quote", "SELECT $t$a$$"], + ["an open string inside parentheses", "SELECT ('a"], + ["an open comment after a statement", "SELECT 1; /*"], + ])("flags %s as unterminated", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(true); + }); + + it.each([ + ["a plain statement", "SELECT 1"], + ["a trailing line comment", "SELECT 1 -- a"], + ["a closed block comment", "SELECT 1 /* a */"], + ["a closed nested block comment", "SELECT 1 /* a /* b */ */"], + ["a closed string", "SELECT 'a'"], + ["a string with a doubled quote", "SELECT 'a''b'"], + ["a closed quoted identifier", 'SELECT "a"'], + ["a closed escape string", String.raw`SELECT E'a\''`], + ["a closed dollar quote", "SELECT $$a;b$$"], + ["a closed string inside parentheses", "SELECT ('a')"], + ["blank input", " \n"], + ])("does not flag %s", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(false); + }); +}); + describe("splitSql", () => { it("preserves raw statements (no transforms) including the trailing ;-less token", () => { expect(splitSql("SELECT 1; SELECT 2")).toEqual(["SELECT 1;", " SELECT 2"]); @@ -280,3 +354,34 @@ describe("findDropStatements", () => { expect(findDropStatements(sql)).toEqual(["DROP TABLE real"]); }); }); + +describe("trimLeadingSqlComments", () => { + it.each([ + ["whitespace", " \t\n\r\f\vSELECT 1"], + ["a byte order mark", "\uFEFF SELECT 1"], + ["a line comment", "-- a\nSELECT 1"], + ["a carriage-return line comment", "-- a\rSELECT 1"], + ["a block comment", "/* a */SELECT 1"], + ["a nested block comment", "/* a /* b */ c */ SELECT 1"], + ["a nested comment closed by */ then *", "/* /* */* x */ SELECT 1"], + ["a nested comment closed by */ then /*", "/* /* *//* */ */ SELECT 1"], + ["a comment nesting /*/", "/* a /*/ b */ */ SELECT 1"], + ["stacked comments and blanks", "\uFEFF-- a\n /* b */ \n-- c\nSELECT 1"], + ])("strips %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe("SELECT 1"); + }); + + it("keeps comments that are not at the start", () => { + expect(trimLeadingSqlComments("SELECT 1 -- a\n")).toBe("SELECT 1 -- a"); + }); + + it.each([ + ["a line comment without a newline", "-- a"], + ["an unterminated block comment", "/* a SELECT 1"], + ["a comment opened by /*/ and closed later", "/*/ SELECT 1 */"], + ["a nested comment closed only once", "/* /* */* x SELECT 1"], + ["only whitespace", " \n"], + ])("returns empty for %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe(""); + }); +}); diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 8705c4c3d4..6190f17106 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -4,9 +4,11 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { announceTarget } from "../shared/output/command-target.service.ts"; import { kongAuthHeaders } from "./kong-auth.ts"; import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage-gateway.errors.ts"; import { splitDirAndFile } from "./storage-url.ts"; +import { renderLocalTarget } from "./target/target-identity.ts"; /** * Client for the Supabase Storage service gateway (Kong). See @@ -149,6 +151,15 @@ function localGatewayHint(port: string): string { ); } +/** Announces the local Storage API endpoint as the run's target. */ +export const announceLocalStorageTarget = (baseUrl: string) => { + const { hostname, port, protocol } = new URL(baseUrl); + const defaultPort = protocol === "https:" ? 443 : 80; + return announceTarget( + renderLocalTarget(hostname.replace(/^\[|\]$/g, ""), port === "" ? defaultPort : Number(port)), + ); +}; + /** * Whether a transport failure is a plain connection-refused. The * port-conflict hint is suppressed for these, since a refused connection diff --git a/apps/cli/src/command-internal/supabase-env.ts b/apps/cli/src/command-internal/supabase-env.ts index b4016143b6..477c35de5d 100644 --- a/apps/cli/src/command-internal/supabase-env.ts +++ b/apps/cli/src/command-internal/supabase-env.ts @@ -13,7 +13,7 @@ import { envValue } from "../shared/config/env-option.ts"; const TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** Truthiness for an already-resolved env value (see module doc). */ -function parseEnvBool(raw: string | undefined): boolean { +export function parseEnvBool(raw: string | undefined): boolean { return raw !== undefined && TRUE_VALUES.has(raw); } diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts new file mode 100644 index 0000000000..1b7c0889b9 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -0,0 +1,360 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Tracer } from "effect"; + +import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; +import { VALID_REF, mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import type { ProjectRefSource } from "../../config/project-ref.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; +import { resolveTarget } from "./resolve-target.ts"; +import { renderLocalTarget } from "./target-identity.ts"; +import { TargetPolicy } from "./target-policy.ts"; +import type { TargetGroup } from "./target-policy.ts"; +import type { TargetSelectorInput } from "./target-selector.ts"; + +const none: TargetSelectorInput = { + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const FILE_REF = "filefilefilefilefile"; +const FLAG_REF = "flagflagflagflagflag"; + +const projectRefResolver = (options: { readonly linked: boolean }) => { + const flags: Array> = []; + const layer = Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + options.linked + ? Effect.sync(() => { + flags.push(flagValue); + const source: ProjectRefSource = Option.isSome(flagValue) ? "flag" : "file"; + return { + ref: Option.getOrElse(flagValue, () => FILE_REF), + source, + matchesLinkedRef: !Option.isSome(flagValue), + }; + }) + : Effect.fail(new ProjectRefNotLinkedError({ message: "Cannot find project ref." })), + resolve: () => Effect.die("resolve is not used by resolveTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveTarget"), + }); + return { layer, flags }; +}; + +const run = ( + group: TargetGroup, + input: TargetSelectorInput, + options: { + readonly toml?: string; + readonly dotenv?: string; + readonly env?: Readonly>; + readonly linked?: boolean; + readonly projectId?: Option.Option; + readonly savedRef?: string; + readonly cache?: { readonly ref: string; readonly name?: string }; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "resolve-target-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + if (options.toml !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", "config.toml"), options.toml); + } + if (options.dotenv !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), options.dotenv); + } + if (options.savedRef !== undefined || options.cache !== undefined) { + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + } + if (options.savedRef !== undefined) { + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); + } + if (options.cache !== undefined) { + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "linked-project.json"), + JSON.stringify(options.cache), + ); + } + const resolver = projectRefResolver({ linked: options.linked ?? true }); + const out = mockOutput(); + const spans: Array = []; + const tracer = Tracer.make({ + span(spanOptions) { + const span = new Tracer.NativeSpan(spanOptions); + spans.push(span); + return span; + }, + }); + const exit = yield* resolveTarget(input).pipe( + Effect.tap(() => announceTarget(renderLocalTarget("127.0.0.1", 54322))), + Effect.provide( + Layer.mergeAll( + Layer.succeed(TargetPolicy, { group, consent: "none" }), + commandTargetLayer.pipe(Layer.provide(out.layer)), + resolver.layer, + mockCommandSettings({ + workdir: dir, + projectId: options.projectId ?? Option.none(), + }), + configValuesLayer({ env: options.env }), + ), + ), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + Effect.exit, + ); + const attributes = spans.find((span) => span.name === "Target.resolve")?.attributes; + return { + exit, + stderr: out.stderrText, + refFlags: resolver.flags, + kind: attributes?.get("target.kind"), + selection: attributes?.get("target.selection"), + }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const succeeded = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${JSON.stringify(exit)}`); + return exit.value; +}; + +describe("resolveTarget (selectable)", () => { + it.effect("is local when nothing selects a target", () => + Effect.gen(function* () { + const result = yield* run("selectable", none); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect([result.kind, result.selection]).toEqual(["local", "default"]); + }), + ); + + it.effect("follows a workspace target of remote to the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { toml: 'target = "remote"\n' }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, + }); + expect(result.refFlags).toEqual([Option.none()]); + expect([result.kind, result.selection]).toEqual(["linked", "config"]); + }), + ); + + it.effect("lets SUPABASE_TARGET override the config file in both directions", () => + Effect.gen(function* () { + const toRemote = yield* run("selectable", none, { + toml: 'target = "local"\n', + env: { SUPABASE_TARGET: "remote" }, + }); + expect(succeeded(toRemote.exit)).toMatchObject({ kind: "linked" }); + expect(toRemote.selection).toBe("env"); + + const toLocal = yield* run("selectable", none, { + toml: 'target = "remote"\n', + env: { SUPABASE_TARGET: "local" }, + }); + expect(succeeded(toLocal.exit)).toEqual({ kind: "local" }); + expect(toLocal.selection).toBe("env"); + }), + ); + + it.effect("reads the workspace target from the project .env", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { dotenv: "SUPABASE_TARGET=remote\n" }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.selection).toBe("env"); + }), + ); + + it.effect("stays local when only SUPABASE_PROJECT_ID is set", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some(VALID_REF) }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect( + "fails with not-linked instead of falling back to local when remote cannot resolve", + () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + toml: 'target = "remote"\n', + linked: false, + }); + expect(Exit.isFailure(result.exit)).toBe(true); + expect(JSON.stringify(result.exit)).toContain("ProjectRefNotLinkedError"); + }), + ); + + it.effect("lets --linked beat a local workspace target", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, linked: true }, + { toml: 'target = "local"\n' }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("lets --local beat a remote workspace target without resolving a ref", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, local: true }, + { toml: 'target = "remote"\n' }, + ); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("passes --project-ref to the ref chain and marks it a flag source", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, projectRef: Option.some(FLAG_REF) }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + matchesLinkedRef: false, + }); + expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + }), + ); + + it.effect("keeps the --db-url connection string redacted", () => + Effect.gen(function* () { + const url = "postgres://user:secret@db.example.com:5432/postgres"; + const result = yield* run("selectable", { ...none, dbUrl: Option.some(url) }); + const target = succeeded(result.exit); + expect(target.kind).toBe("db-url"); + if (target.kind === "db-url") expect(Redacted.value(target.url)).toBe(url); + expect(JSON.stringify(target)).not.toContain("secret"); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("rejects conflicting selectors", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, local: true, linked: true }); + expect(JSON.stringify(result.exit)).toContain("TargetSelectorConflictError"); + }), + ); + + it.effect("hints at --linked on a default-local banner when the checkout is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --linked for alpha (${FILE_REF})\n`, + ); + }), + ); + + it.effect("hints with the bare ref when the cache does not name the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { savedRef: FILE_REF }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --linked for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("adds no hint when the checkout is not linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { cache: { ref: FILE_REF, name: "alpha" } }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("adds no hint when a selector chose local", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, local: true }, { savedRef: FILE_REF }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("adds no hint when the workspace config chose local", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + toml: 'target = "local"\n', + savedRef: FILE_REF, + }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("names the file when the workspace config cannot be parsed", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { toml: "[db\nport = " }); + const failure = Exit.isFailure(result.exit) ? Cause.squash(result.exit.cause) : undefined; + expect(failure).toMatchObject({ + _tag: "CliConfigLoadError", + message: expect.stringMatching(/^failed to parse supabase[/\\]config\.toml: ./), + }); + }), + ); +}); + +describe("resolveTarget (sync)", () => { + it.effect("defaults to the linked project and ignores a local workspace target", () => + Effect.gen(function* () { + const result = yield* run("sync", none, { toml: 'target = "local"\n' }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.refFlags).toEqual([Option.none()]); + expect([result.kind, result.selection]).toEqual(["linked", "default"]); + }), + ); + + it.effect("targets local only with --local", () => + Effect.gen(function* () { + const result = yield* run("sync", { ...none, local: true }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("fails with not-linked when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("sync", none, { linked: false }); + expect(JSON.stringify(result.exit)).toContain("ProjectRefNotLinkedError"); + }), + ); +}); + +describe("resolveTarget (other groups)", () => { + it.effect.each(["local-only", "account", "hosted", "removed"] satisfies Array)( + "dies for a %s command", + (group) => + Effect.gen(function* () { + const result = yield* run(group, none); + expect(Exit.isFailure(result.exit)).toBe(true); + if (Exit.isFailure(result.exit)) { + expect(Cause.pretty(result.exit.cause)).toContain( + "resolveTarget needs a sync or selectable", + ); + } + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts new file mode 100644 index 0000000000..c5017d7bb2 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -0,0 +1,119 @@ +import { Effect, FileSystem, Option, Path, Redacted, Result } from "effect"; + +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { + PROJECT_REF_PATTERN, + ProjectRefResolver, + type ProjectRefSource, + type ResolvedProjectRef, +} from "../../config/project-ref.service.ts"; +import { CliConfigLoadError } from "../../shared/config/cli-config-load.errors.ts"; +import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { readCachedLinkedProject } from "../parent-project-ref.ts"; +import { describeConfigParseError } from "../workdir-project.ts"; +import { linkedAlternativeHint } from "./target-identity.ts"; +import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; +import { TargetPolicy } from "./target-policy.ts"; + +export type ResolvedTarget = + | { readonly kind: "local" } + | { + readonly kind: "linked"; + readonly ref: string; + readonly source: ProjectRefSource; + readonly matchesLinkedRef: boolean; + } + | { readonly kind: "db-url"; readonly url: Redacted.Redacted }; + +export type LinkedTarget = Extract; + +type TargetSelection = "flag" | "env" | "config" | "default"; + +/** The linked target for a ref the ref chain has already resolved and announced. */ +export const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ + kind: "linked", + ref: resolved.ref, + source: resolved.source, + matchesLinkedRef: resolved.matchesLinkedRef, +}); + +/** + * Resolves the target a `sync` or `selectable` command acts on: a selector flag wins, then + * `sync` means the linked project and `selectable` follows the workspace `target`. Linked goes + * through {@link ProjectRefResolver.resolveRef}, which announces it. + */ +export const resolveTarget = Effect.fn("Target.resolve")(function* (input: TargetSelectorInput) { + const policy = yield* TargetPolicy; + if (policy.group !== "sync" && policy.group !== "selectable") { + return yield* Effect.die( + new Error(`resolveTarget needs a sync or selectable command, not "${policy.group}"`), + ); + } + const selection = yield* Result.match(parseTargetSelector(input), { + onFailure: Effect.fail, + onSuccess: Effect.succeed, + }); + const projectRef = yield* ProjectRefResolver; + + const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: TargetSelection) { + yield* Effect.annotateCurrentSpan({ + "target.kind": target.kind, + "target.selection": selectedBy, + }); + return target; + }); + const linked = Effect.fnUntraced(function* (flag: Option.Option) { + return linkedTarget(yield* projectRef.resolveRef(flag)); + }); + const hintLinkedAlternative = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( + Effect.map(Option.filter((ref) => PROJECT_REF_PATTERN.test(ref))), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(linkedRef)) return; + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isNone(cell)) return; + const cache = yield* readCachedLinkedProject(fs, path, workdir); + yield* cell.value.hintLocal(linkedAlternativeHint({ ref: linkedRef.value, cache })); + }); + + switch (selection.explicit) { + case "local": + return yield* finish({ kind: "local" }, "flag"); + case "db-url": + return yield* finish({ kind: "db-url", url: Redacted.make(selection.url) }, "flag"); + case "linked": + return yield* finish(yield* linked(selection.projectRef), "flag"); + case undefined: + } + + if (policy.group === "sync") { + return yield* finish(yield* linked(Option.none()), "default"); + } + const { workdir } = yield* CommandSettings; + const resolvedConfig = yield* (yield* CliConfigValues) + .load({ workdir, projectRef: Option.none() }) + .pipe( + Effect.catchTag("CliConfigParseError", (error) => + Effect.fail(new CliConfigLoadError({ message: describeConfigParseError(workdir, error) })), + ), + ); + const workspaceTarget = yield* resolvedConfig.get(CliConfigKeys.target); + const selectedBy: TargetSelection = + workspaceTarget.origin.tier === "shell" || workspaceTarget.origin.tier === "projectEnv" + ? "env" + : workspaceTarget.origin.tier === "config" + ? "config" + : "default"; + if (workspaceTarget.value === "remote") { + return yield* finish(yield* linked(Option.none()), selectedBy); + } + if (selectedBy === "default") yield* hintLinkedAlternative(); + return yield* finish({ kind: "local" }, selectedBy); +}); diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts new file mode 100644 index 0000000000..f016ea8dea --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -0,0 +1,105 @@ +import type { Option } from "effect"; +import { Flag, GlobalFlag } from "effect/cli"; + +import type { ResolvedTarget } from "./resolve-target.ts"; + +type TargetFlagGroup = "sync" | "selectable"; +type TargetSubject = "database" | "Storage API"; + +interface TargetFlagOptions { + readonly group: TargetFlagGroup; + readonly subject: TargetSubject; +} + +const localFlag = ({ group, subject }: TargetFlagOptions) => + Flag.Boolean("local").pipe( + Flag.withDescription( + group === "sync" + ? `Target the local ${subject}.` + : `Target the local ${subject} (default unless target = "remote" is set in config.toml or SUPABASE_TARGET).`, + ), + Flag.withDefault(false), + ); + +const linkedFlag = ({ group }: TargetFlagOptions) => + Flag.Boolean("linked").pipe( + Flag.withDescription( + group === "sync" + ? "Target the linked project (default)." + : 'Target the linked project (default when target = "remote" is set in config.toml or SUPABASE_TARGET).', + ), + Flag.withDefault(false), + ); + +const dbUrlFlag = () => + Flag.String("db-url").pipe( + Flag.withDescription( + "Target the database specified by the connection string (must be percent-encoded).", + ), + Flag.optional, + ); + +const projectRefFlag = () => + Flag.String("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project. Implies --linked."), + Flag.optional, + ); + +export type TargetFlagsWithDbUrl = { + readonly local: Flag.Flag; + readonly linked: Flag.Flag; + readonly dbUrl: Flag.Flag>; + readonly projectRef: Flag.Flag>; +}; + +export type TargetFlagsWithoutDbUrl = Omit; + +/** The only declaration of `--local`, `--linked`, `--db-url` and `--project-ref` for sync and selectable commands. */ +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: true }, +): TargetFlagsWithDbUrl; +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: false }, +): TargetFlagsWithoutDbUrl; +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: boolean }, +): TargetFlagsWithDbUrl | TargetFlagsWithoutDbUrl { + const base = { + local: localFlag(options), + linked: linkedFlag(options), + projectRef: projectRefFlag(), + }; + return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; +} + +/** The flags that point a suggested follow-up command at `target`; a `--db-url` value is never echoed. */ +export const flagsForTarget = (target: ResolvedTarget): string => { + switch (target.kind) { + case "local": + return "--local"; + case "db-url": + return "--db-url "; + case "linked": + return target.matchesLinkedRef ? "--linked" : `--linked --project-ref ${target.ref}`; + } +}; + +/** + * `seed` accepts the selectors before or after its subcommand, which needs scoped globals; they + * share their definitions with {@link targetFlags}. + */ +export const seedTargetGlobalFlags = { + linked: GlobalFlag.Setting("linked")({ + flag: linkedFlag({ group: "selectable", subject: "Storage API" }), + }), + local: GlobalFlag.Setting("local")({ + flag: localFlag({ group: "selectable", subject: "Storage API" }), + }), +}; + +/** Hidden alias of `--project-ref`, accepted only by `gen types`. */ +export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( + Flag.withDescription("Deprecated, removed in v4: use --project-ref."), + Flag.optional, + Flag.withHidden, +); diff --git a/apps/cli/src/command-internal/target/target-flags.unit.test.ts b/apps/cli/src/command-internal/target/target-flags.unit.test.ts new file mode 100644 index 0000000000..7919ec64dd --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.unit.test.ts @@ -0,0 +1,148 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { CliOutput, Command, type Flag } from "effect/cli"; +import type * as CliCommand from "effect/cli/Command"; + +import { unwrapParam } from "../param-introspection.ts"; +import { + deprecatedProjectIdFlag, + seedTargetGlobalFlags, + flagsForTarget, + targetFlags, +} from "./target-flags.ts"; + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const parseWith = ( + config: Config, + args: ReadonlyArray, +) => { + const parsed: Array = []; + const command = Command.make("target-test", config).pipe( + Command.withHandler((flags) => Effect.sync(() => parsed.push(flags))), + ); + return Command.runWith(command, { version: "0.0.0-test" })(args).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter))), + Effect.map(() => parsed[0]), + ); +}; + +const describeFlag = (flag: Flag.Flag) => { + const single = unwrapParam(flag)?.single; + return { + name: single?.name, + description: Option.getOrUndefined(single?.description ?? Option.none()), + }; +}; + +describe("targetFlags", () => { + const withDbUrl = targetFlags({ group: "selectable", subject: "database", dbUrl: true }); + + it.effect("defaults every selector to absent", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, [])).toEqual({ + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), + }); + }), + ); + + it.effect("parses each selector", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); + expect(yield* parseWith(withDbUrl, ["--linked"])).toMatchObject({ linked: true }); + expect( + yield* parseWith(withDbUrl, ["--db-url", "postgresql://h/db", "--project-ref", "abc"]), + ).toMatchObject({ + dbUrl: Option.some("postgresql://h/db"), + projectRef: Option.some("abc"), + }); + }), + ); + + it.effect("accepts an explicit false switch", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, ["--linked=false"])).toMatchObject({ linked: false }); + }), + ); + + it("omits --db-url for commands without a database connection", () => { + const storage = targetFlags({ group: "selectable", subject: "Storage API", dbUrl: false }); + expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef"]); + expect(Object.keys(withDbUrl).sort()).toEqual(["dbUrl", "linked", "local", "projectRef"]); + }); + + it("describes the linked default by group", () => { + const sync = targetFlags({ group: "sync", subject: "database", dbUrl: true }); + expect(describeFlag(sync.linked).description).toBe("Target the linked project (default)."); + expect(describeFlag(withDbUrl.linked).description).toBe( + 'Target the linked project (default when target = "remote" is set in config.toml or SUPABASE_TARGET).', + ); + }); + + it("names the subject in the local help", () => { + const storage = targetFlags({ group: "sync", subject: "Storage API", dbUrl: false }); + expect(describeFlag(storage.local).description).toBe("Target the local Storage API."); + }); +}); + +describe("deprecatedProjectIdFlag", () => { + it("is hidden from help", () => { + expect(unwrapParam(deprecatedProjectIdFlag)?.single.hidden).toBe(true); + }); + + it.effect("parses an optional value", () => + Effect.gen(function* () { + expect(yield* parseWith({ projectId: deprecatedProjectIdFlag }, [])).toEqual({ + projectId: Option.none(), + }); + expect( + yield* parseWith({ projectId: deprecatedProjectIdFlag }, ["--project-id", "abc"]), + ).toEqual({ projectId: Option.some("abc") }); + }), + ); +}); + +describe("seedTargetGlobalFlags", () => { + it("shares the selector flag names with targetFlags", () => { + expect(describeFlag(seedTargetGlobalFlags.linked.flag).name).toBe("linked"); + expect(describeFlag(seedTargetGlobalFlags.local.flag).name).toBe("local"); + }); +}); + +describe("flagsForTarget", () => { + it("names the flags that re-select each kind of target without echoing a db-url", () => { + expect(flagsForTarget({ kind: "local" })).toBe("--local"); + expect(flagsForTarget({ kind: "db-url", url: Redacted.make("postgresql://u:pw@h/db") })).toBe( + "--db-url ", + ); + expect( + flagsForTarget({ + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "file", + matchesLinkedRef: true, + }), + ).toBe("--linked"); + }); + + it("keeps an explicit ref that is not the linked project", () => { + expect( + flagsForTarget({ + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "flag", + matchesLinkedRef: false, + }), + ).toBe("--linked --project-ref abcdefghijklmnopqrst"); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts new file mode 100644 index 0000000000..7767c2d8db --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -0,0 +1,100 @@ +import { Option } from "effect"; + +import type { RenderedTarget } from "../../shared/output/command-target.service.ts"; +import { sanitizeInlineName } from "../http-errors.ts"; +import type { CachedLinkedProject } from "../parent-project-ref.ts"; + +const hostWithPort = (host: string, port: number): string => + `${host.includes(":") ? `[${host}]` : host}:${port}`; + +export const renderLocalTarget = (host: string, port: number): RenderedTarget => ({ + text: `local stack (${hostWithPort(sanitizeInlineName(host), port)})`, + json: { kind: "local", host, port }, +}); + +/** Names the host and port only; the connection string's credentials never reach the banner. */ +export const renderDbUrlTarget = (host: string, port: number): RenderedTarget => ({ + text: `custom Postgres (${hostWithPort(sanitizeInlineName(host), port)})`, + json: { kind: "db-url", host, port }, +}); + +const named = (name: string | undefined, ref: string): string => { + const safeName = name === undefined ? "" : sanitizeInlineName(name); + return safeName.length === 0 ? ref : `${safeName} (${ref})`; +}; + +/** + * A linked project's identity from the `linked-project.json` cache alone, with no API call. Only a + * cache naming the resolved ref itself is evidence (a production project); a project that is + * merely linked, or one the cache cannot vouch for, is shown as its ref and reported `unverified`, + * because the cache can be stale or missing and never proves a branch. A ref that differs from + * the linked project's own says so. + */ +export const describeLinkedTarget = (input: { + readonly ref: string; + readonly cache: Option.Option; + /** The name the project picker showed for `ref`. */ + readonly name?: string; + /** The ref in `supabase/.temp/project-ref`, when the caller compares against the linked project. */ + readonly linkedRef?: Option.Option; +}): RenderedTarget => { + const { ref } = input; + const cached = Option.getOrUndefined(input.cache); + if (cached?.ref === ref) { + return { + text: `project ${named(cached.name, ref)} · production`, + json: { + kind: "linked", + project_ref: ref, + ...(cached.name === undefined ? {} : { project_name: cached.name }), + environment: "production", + }, + }; + } + const differsFromLinked = Option.exists( + input.linkedRef ?? Option.none(), + (linked) => linked !== ref, + ); + return { + text: `project ${named(input.name, ref)}${differsFromLinked ? " (not the linked project)" : ""}`, + json: { + kind: "linked", + project_ref: ref, + ...(input.name === undefined ? {} : { project_name: input.name }), + environment: "unverified", + }, + }; +}; + +/** The suffix a default-local banner carries when the checkout is linked, naming the project `--linked` would use. */ +export const linkedAlternativeHint = (input: { + readonly ref: string; + readonly cache: Option.Option; +}): string => { + const cached = Option.getOrUndefined(input.cache); + const name = cached?.ref === input.ref ? cached.name : undefined; + return `default; pass --linked for ${named(name, input.ref)}`; +}; + +/** A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` for anything but a name lookup. */ +export const renderBranchTarget = (input: { + readonly branch: Option.Option; + readonly ref: string; + readonly parentRef: Option.Option; +}): RenderedTarget => { + const branch = Option.map(input.branch, sanitizeInlineName); + const parentRef = Option.getOrUndefined(input.parentRef); + const label = Option.match(branch, { + onNone: () => `branch ${input.ref}`, + onSome: (name) => `branch ${name} (${input.ref})`, + }); + return { + text: parentRef === undefined ? label : `project ${parentRef} · ${label}`, + json: { + kind: "linked", + project_ref: input.ref, + environment: "branch", + ...(parentRef === undefined ? {} : { parent_project_ref: parentRef }), + }, + }; +}; diff --git a/apps/cli/src/command-internal/target/target-identity.unit.test.ts b/apps/cli/src/command-internal/target/target-identity.unit.test.ts new file mode 100644 index 0000000000..d5c1599bd7 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.unit.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { + describeLinkedTarget, + linkedAlternativeHint, + renderBranchTarget, + renderDbUrlTarget, + renderLocalTarget, +} from "./target-identity.ts"; + +const REF = "abcdefghijklmnopqrst"; +const OTHER_REF = "qrstuvwxyzabcdefghij"; + +describe("renderLocalTarget", () => { + it("renders the host and port", () => { + expect(renderLocalTarget("127.0.0.1", 54322)).toEqual({ + text: "local stack (127.0.0.1:54322)", + json: { kind: "local", host: "127.0.0.1", port: 54322 }, + }); + }); + + it("brackets an IPv6 host", () => { + expect(renderLocalTarget("::1", 54322).text).toBe("local stack ([::1]:54322)"); + }); +}); + +describe("renderDbUrlTarget", () => { + it("names the host without credentials", () => { + expect(renderDbUrlTarget("db.example.com", 5432)).toEqual({ + text: "custom Postgres (db.example.com:5432)", + json: { kind: "db-url", host: "db.example.com", port: 5432 }, + }); + }); +}); + +describe("describeLinkedTarget", () => { + it("is production when the cache names the resolved ref", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · production`, + json: { kind: "linked", project_ref: REF, project_name: "alpha", environment: "production" }, + }); + }); + + it("omits the name when the cache has none", () => { + const target = describeLinkedTarget({ ref: REF, cache: Option.some({ ref: REF }) }); + expect(target.text).toBe(`project ${REF} · production`); + expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); + }); + + it("never claims a branch for a ref the cache does not name", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "unverified" }, + }); + }); + + it("is just the ref without a cache", () => { + const target = describeLinkedTarget({ ref: REF, cache: Option.none() }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("says a ref that differs from the linked project is not the linked project", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.some({ ref: REF, name: "alpha" }), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${OTHER_REF} (not the linked project)`); + expect(target.json).toEqual({ + kind: "linked", + project_ref: OTHER_REF, + environment: "unverified", + }); + }); + + it("does not flag the linked ref itself", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("does not flag a ref when nothing is linked", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.none(), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("shows the project name the picker gave", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.none(), + name: "beta", + linkedRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project beta (${OTHER_REF}) (not the linked project)`, + json: { + kind: "linked", + project_ref: OTHER_REF, + project_name: "beta", + environment: "unverified", + }, + }); + }); + + it("strips control characters and newlines from a cached project name", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.some({ ref: REF, name: "alpha\nTarget: forged\u001b[31m" }), + }); + expect(target.text).not.toContain("\n"); + expect(target.text).not.toContain("\u001b"); + }); +}); + +describe("linkedAlternativeHint", () => { + it("names the cached project when the cache matches the linked ref", () => { + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: REF, name: "alpha" }) }), + ).toBe(`default; pass --linked for alpha (${REF})`); + }); + + it("falls back to the ref when the cache is absent or names another project", () => { + const expected = `default; pass --linked for ${REF}`; + expect(linkedAlternativeHint({ ref: REF, cache: Option.none() })).toBe(expected); + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: OTHER_REF, name: "beta" }) }), + ).toBe(expected); + }); +}); + +describe("renderBranchTarget", () => { + it("names the parent, branch and resolved ref for a name lookup", () => { + const target = renderBranchTarget({ + branch: Option.some("feature-x"), + ref: OTHER_REF, + parentRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project ${REF} · branch feature-x (${OTHER_REF})`, + json: { + kind: "linked", + project_ref: OTHER_REF, + environment: "branch", + parent_project_ref: REF, + }, + }); + }); + + it("shows only the resolved ref for a UUID lookup", () => { + const target = renderBranchTarget({ + branch: Option.none(), + ref: OTHER_REF, + parentRef: Option.none(), + }); + expect(target).toEqual({ + text: `branch ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "branch" }, + }); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-password.ts b/apps/cli/src/command-internal/target/target-password.ts new file mode 100644 index 0000000000..1b181ec622 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-password.ts @@ -0,0 +1,22 @@ +import { Effect, Option } from "effect"; + +import { SUGGEST_LINKED_PROJECT } from "../connect-errors.ts"; +import { TargetPasswordConflictError } from "./target.errors.ts"; + +/** Fails when a non-empty `--password` accompanies a target that is not a linked project. */ +export const rejectTargetPassword = ( + target: "local" | "db-url", + password: Option.Option, + linkedCheckout = false, +): Effect.Effect => + Option.isNone(Option.filter(password, (value) => value.length > 0)) + ? Effect.void + : Effect.fail( + new TargetPasswordConflictError({ + target, + message: + target === "db-url" + ? "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" + : `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.${linkedCheckout ? ` ${SUGGEST_LINKED_PROJECT}` : ""}`, + }), + ); diff --git a/apps/cli/src/command-internal/target/target-policy.ts b/apps/cli/src/command-internal/target/target-policy.ts new file mode 100644 index 0000000000..93cd1ad1fc --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.ts @@ -0,0 +1,59 @@ +import { Context, Layer, Option } from "effect"; +import { Command } from "effect/cli"; + +export type TargetGroup = "local-only" | "account" | "hosted" | "sync" | "selectable" | "removed"; + +export interface TargetPolicyShape { + readonly group: TargetGroup; + readonly consent: "none" | "no" | "yes"; + readonly consentExemptReason?: string | undefined; +} + +/** + * What a leaf declares. `consentExemptReason` explains why a leaf never asks for hosted-mutation + * consent; a `sync` or `selectable` leaf must give one because it can reach a hosted database. + */ +export type TargetPolicyDeclaration = + | { + readonly group: "sync" | "selectable"; + readonly consent: "none"; + readonly consentExemptReason: string; + } + | { + readonly group: Exclude; + readonly consent: "none"; + readonly consentExemptReason?: string; + } + | { + readonly group: TargetGroup; + readonly consent: "no" | "yes"; + readonly consentExemptReason?: never; + }; + +/** The command's target group and hosted-mutation consent default, provided to its handler. */ +export class TargetPolicy extends Context.Service()( + "supabase/TargetPolicy", +) {} + +/** Context key under which a command's policy is stored in `Command.annotations`. */ +export class TargetPolicyAnnotation extends Context.Service< + TargetPolicyAnnotation, + TargetPolicyShape +>()("supabase/TargetPolicyAnnotation") {} + +/** + * Declares a leaf's target group and consent default; apply it after the leaf's handler and its + * other `Command.provide` layers so every one of them can read `TargetPolicy`. + */ +export const withTargetPolicy = + (policy: TargetPolicyDeclaration) => + ( + self: Command.Command, + ) => + self.pipe( + Command.annotate(TargetPolicyAnnotation, policy), + Command.provide(Layer.succeed(TargetPolicy, policy)), + ); + +export const readTargetPolicy = (command: Command.Command.Any): Option.Option => + Context.getOption(command.annotations, TargetPolicyAnnotation); diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts new file mode 100644 index 0000000000..63062014ad --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -0,0 +1,282 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { TreeNode } from "../../../tests/helpers/command-flags.ts"; +import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readPermissions } from "../command-permissions/command-permissions.annotation.ts"; +import { TargetPolicy, readTargetPolicy, withTargetPolicy } from "./target-policy.ts"; +import type { TargetGroup, TargetPolicyShape } from "./target-policy.ts"; + +const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ + { name: "default backend", variant: undefined }, + { + name: "stack backend", + variant: { stackBackend: "stack", computeEnabled: true } satisfies Required< + NonNullable + >, + }, +]; + +/** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ +const CONSENT_FREE_OPERATIONS: ReadonlyMap = new Map([ + [ + "v1-create-login-role", + "mints a short-lived login role for the connection; revisit when connection setup asks for consent", + ], + ["v1-delete-network-bans", "removes the caller's own ban only when the token permits it"], + ["v1-list-all-network-bans", "a POST that only reads the ban list"], + ["v1-verify-dns-config", "re-runs verification and changes no configuration"], + ["v1-check-vanity-subdomain-availability", "a POST that only reads availability"], +]); + +/** Non-GET operations one leaf may call without consent; the leaf must also declare `consentExemptReason`. */ +const LEAF_CONSENT_FREE_OPERATIONS: ReadonlyMap> = new Map([ + [ + "notebooks pull", + new Map([["v2-create-notebook", "creates remote notebooks only after an interactive choice"]]), + ], +]); + +const isConsentFree = (path: string, operationId: string) => + CONSENT_FREE_OPERATIONS.has(operationId) || + LEAF_CONSENT_FREE_OPERATIONS.get(path)?.has(operationId) === true; + +const MUTATING_VERBS: ReadonlySet = new Set([ + "create", + "delete", + "remove", + "update", + "set", + "unset", + "deploy", + "push", + "restore", + "activate", + "disable", + "pause", + "unpause", + "add", + "up", + "down", + "squash", + "repair", + "reset", + "query", + "cp", + "mv", + "rm", + "buckets", +]); + +const TARGET_SELECTOR_FLAGS = ["local", "linked", "db-url"] as const; + +interface Leaf { + readonly path: string; + readonly node: TreeNode; + readonly policy: TargetPolicyShape; +} + +const leavesByVariant = VARIANTS.map(({ name, variant }) => { + const tree = commandTreeFor(variant); + const leaves = [...tree].filter(([, node]) => node.isLeaf); + return { + name, + withPolicy: leaves.flatMap(([path, node]): ReadonlyArray => { + const policy = readTargetPolicy(node.command); + return Option.isSome(policy) ? [{ path, node, policy: policy.value }] : []; + }), + missingPolicy: leaves + .filter(([, node]) => Option.isNone(readTargetPolicy(node.command))) + .map(([path]) => path), + }; +}); + +const lastWord = (path: string) => path.split(" ").at(-1) ?? path; + +describe.each(leavesByVariant)( + "target policy tree guards ($name)", + ({ withPolicy, missingPolicy }) => { + it("declares a target policy on every leaf", () => { + expect(missingPolicy).toEqual([]); + }); + + it("gives local-only, account and hosted leaves no local, linked or db-url flag", () => { + const offenders = withPolicy + .filter(({ policy }) => ["local-only", "account", "hosted"].includes(policy.group)) + .flatMap(({ path, node }) => + TARGET_SELECTOR_FLAGS.filter((flag) => node.flags.has(flag)).map( + (flag) => `${path} --${flag}`, + ), + ); + expect(offenders).toEqual([]); + }); + + it("gives sync and selectable leaves local, linked and project-ref flags", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .flatMap(({ path, node }) => + (["local", "linked", "project-ref"] as const) + .filter((flag) => !node.flags.has(flag)) + .map((flag) => `${path} lacks --${flag}`), + ); + expect(offenders).toEqual([]); + }); + + it("matches a declared database-target default to the group", () => { + const expected: Partial> = { + sync: "linked", + selectable: "local", + }; + const offenders = withPolicy.flatMap(({ path, node, policy }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped" || permissions.dbTargetDefault === undefined) + return []; + const want = expected[policy.group]; + return want === undefined || permissions.dbTargetDefault === want + ? [] + : [`${path}: ${policy.group} defaults to ${permissions.dbTargetDefault}`]; + }); + expect(offenders).toEqual([]); + }); + + it("requires consent for every declared non-GET operation outside the consent-free map", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.consent === "none") + .flatMap(({ path, node }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped") return []; + return permissions.operations + .filter( + ({ operationId }) => + OPERATIONS.get(operationId)?.method !== "GET" && !isConsentFree(path, operationId), + ) + .map(({ operationId }) => `${path}: ${operationId}`); + }); + expect([...new Set(offenders)]).toEqual([]); + }); + + it("requires an exemption reason on a sync or selectable leaf that never asks for consent", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + + it("gives every leaf-scoped consent-free operation a leaf that declares its reason", () => { + const reasonByPath = new Map(withPolicy.map(({ path, policy }) => [path, policy])); + const offenders = [...LEAF_CONSENT_FREE_OPERATIONS.keys()].filter((path) => { + const policy = reasonByPath.get(path); + return ( + policy === undefined || + policy.consent !== "none" || + (policy.consentExemptReason ?? "").trim() === "" + ); + }); + expect(offenders).toEqual([]); + }); + + it("requires consent or an exemption reason on a leaf named for a mutating verb", () => { + const offenders = withPolicy + .filter(({ path }) => MUTATING_VERBS.has(lastWord(path))) + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + }, +); + +describe("target policy allowlists", () => { + it("name only operations in the API spec", () => { + const unknown = [ + ...CONSENT_FREE_OPERATIONS.keys(), + ...[...LEAF_CONSENT_FREE_OPERATIONS.values()].flatMap((operations) => [...operations.keys()]), + ].filter((operationId) => !OPERATIONS.has(operationId)); + expect(unknown).toEqual([]); + }); +}); + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const runtimeLayer = Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter)); + +class GroupProbe extends Context.Service()("test/GroupProbe") {} + +describe("withTargetPolicy", () => { + it("annotates the command with the declared policy", () => { + const leaf = Command.make("leaf").pipe(withTargetPolicy({ group: "hosted", consent: "no" })); + expect(readTargetPolicy(leaf)).toEqual(Option.some({ group: "hosted", consent: "no" })); + }); + + it("requires a reason from a sync or selectable declaration that never asks for consent", () => { + // @ts-expect-error a leaf that can reach a hosted database must say why it never asks + withTargetPolicy({ group: "sync", consent: "none" }); + // @ts-expect-error a leaf that can reach a hosted database must say why it never asks + withTargetPolicy({ group: "selectable", consent: "none" }); + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }); + withTargetPolicy({ group: "hosted", consent: "none" }); + }); + + it("reads no policy from an unannotated command", () => { + expect(readTargetPolicy(Command.make("leaf"))).toEqual(Option.none()); + }); + + it.effect("provides the policy to the handler", () => + Effect.gen(function* () { + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* TargetPolicy); + }), + ), + withTargetPolicy({ group: "selectable", consent: "yes" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual([{ group: "selectable", consent: "yes" }]); + }), + ); + + it.effect("provides the policy to layers applied before it", () => + Effect.gen(function* () { + const probeLayer = Layer.effect( + GroupProbe, + Effect.gen(function* () { + return (yield* TargetPolicy).group; + }), + ); + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* GroupProbe); + }), + ), + Command.provide(probeLayer), + withTargetPolicy({ group: "account", consent: "none" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual(["account"]); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts new file mode 100644 index 0000000000..558e799bc2 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -0,0 +1,85 @@ +import { Option, Result } from "effect"; + +import { + TargetEmptyValueError, + TargetProjectRefConflictError, + TargetSelectorConflictError, + type TargetSelectorError, +} from "./target.errors.ts"; + +export interface TargetSelectorInput { + readonly local: boolean; + readonly linked: boolean; + readonly dbUrl: Option.Option; + readonly projectRef: Option.Option; +} + +export type TargetSelection = + | { readonly explicit: "local" } + | { readonly explicit: "db-url"; readonly url: string } + | { readonly explicit: "linked"; readonly projectRef: Option.Option } + | { readonly explicit: undefined }; + +const joinFlags = (flags: ReadonlyArray): string => + flags.length <= 2 + ? flags.join(" and ") + : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; + +/** A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. */ +export const parseTargetSelector = ( + input: TargetSelectorInput, +): Result.Result => { + const dbUrl = Option.getOrUndefined(input.dbUrl); + const projectRef = Option.getOrUndefined(input.projectRef); + + if (dbUrl === "") { + return Result.fail( + new TargetEmptyValueError({ + flag: "--db-url", + message: "--db-url cannot be empty.", + suggestion: + "Pass a connection string, for example --db-url postgresql://postgres:postgres@127.0.0.1:54322/postgres", + }), + ); + } + if (projectRef === "") { + return Result.fail( + new TargetEmptyValueError({ + flag: "--project-ref", + message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", + }), + ); + } + + const selected = [ + ...(input.local ? ["--local"] : []), + ...(input.linked ? ["--linked"] : []), + ...(dbUrl === undefined ? [] : ["--db-url"]), + ]; + if (selected.length > 1) { + return Result.fail( + new TargetSelectorConflictError({ + flags: selected, + message: `Only one of --local, --linked and --db-url can be set; got ${joinFlags(selected)}.`, + }), + ); + } + + if (projectRef !== undefined && (input.local || dbUrl !== undefined)) { + const conflictsWith = input.local ? "--local" : "--db-url"; + return Result.fail( + new TargetProjectRefConflictError({ + conflictsWith, + message: `--project-ref selects a linked project and cannot be combined with ${conflictsWith}.`, + }), + ); + } + + if (input.local) return Result.succeed({ explicit: "local" }); + if (dbUrl !== undefined) return Result.succeed({ explicit: "db-url", url: dbUrl }); + if (input.linked || projectRef !== undefined) { + return Result.succeed({ explicit: "linked", projectRef: input.projectRef }); + } + return Result.succeed({ explicit: undefined }); +}; diff --git a/apps/cli/src/command-internal/target/target-selector.unit.test.ts b/apps/cli/src/command-internal/target/target-selector.unit.test.ts new file mode 100644 index 0000000000..8feffab3bb --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.unit.test.ts @@ -0,0 +1,159 @@ +import { Option, Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; + +const none: TargetSelectorInput = { + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const parse = (overrides: Partial) => + parseTargetSelector({ ...none, ...overrides }); + +const failure = (overrides: Partial) => { + const result = parse(overrides); + if (Result.isSuccess(result)) throw new Error("expected the selector to be rejected"); + return result.failure; +}; + +describe("parseTargetSelector", () => { + it("reports no explicit target when nothing is set", () => { + expect(parse({})).toEqual(Result.succeed({ explicit: undefined })); + }); + + it.each([ + ["--local", { local: true }, { explicit: "local" }], + ["--linked", { linked: true }, { explicit: "linked", projectRef: Option.none() }], + [ + "--db-url", + { dbUrl: Option.some("postgresql://u:p@db.example.com:5432/postgres") }, + { explicit: "db-url", url: "postgresql://u:p@db.example.com:5432/postgres" }, + ], + ] as const)("selects the target named by %s", (_flag, overrides, expected) => { + expect(parse(overrides)).toEqual(Result.succeed(expected)); + }); + + it("treats false switches as absent, so --local=false and --linked=false select nothing", () => { + expect(parse({ local: false, linked: false })).toEqual(Result.succeed({ explicit: undefined })); + }); + + it("lets a false switch coexist with another selector", () => { + expect(parse({ linked: false, dbUrl: Option.some("postgresql://h/db") })).toEqual( + Result.succeed({ explicit: "db-url", url: "postgresql://h/db" }), + ); + }); + + describe("--project-ref", () => { + it("implies the linked target and carries the ref", () => { + expect(parse({ projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + + it("agrees with an explicit --linked", () => { + expect(parse({ linked: true, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + + it("is rejected with --local", () => { + expect( + failure({ local: true, projectRef: Option.some("abcdefghijklmnopqrst") }), + ).toMatchObject({ + _tag: "TargetProjectRefConflictError", + conflictsWith: "--local", + message: "--project-ref selects a linked project and cannot be combined with --local.", + }); + }); + + it("is rejected with --db-url", () => { + expect( + failure({ + dbUrl: Option.some("postgresql://h/db"), + projectRef: Option.some("abcdefghijklmnopqrst"), + }), + ).toMatchObject({ + _tag: "TargetProjectRefConflictError", + conflictsWith: "--db-url", + }); + }); + + it("is not rejected with --local=false", () => { + expect(parse({ local: false, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + }); + + describe("mutual exclusion", () => { + it("rejects --local with --linked and names both flags", () => { + expect(failure({ local: true, linked: true })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--linked"], + message: "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + }); + }); + + it("rejects --local with --db-url", () => { + expect(failure({ local: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--db-url"], + }); + }); + + it("rejects --linked with --db-url", () => { + expect(failure({ linked: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--linked", "--db-url"], + }); + }); + + it("lists all three flags when every selector is set", () => { + expect( + failure({ local: true, linked: true, dbUrl: Option.some("postgresql://h/db") }), + ).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--linked", "--db-url"], + message: + "Only one of --local, --linked and --db-url can be set; got --local, --linked and --db-url.", + }); + }); + + it("reports the selector conflict before the --project-ref conflict", () => { + expect( + failure({ local: true, linked: true, projectRef: Option.some("abcdefghijklmnopqrst") }), + ).toMatchObject({ _tag: "TargetSelectorConflictError" }); + }); + }); + + describe("empty values", () => { + it("rejects an empty --db-url", () => { + expect(failure({ dbUrl: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--db-url", + message: "--db-url cannot be empty.", + suggestion: + "Pass a connection string, for example --db-url postgresql://postgres:postgres@127.0.0.1:54322/postgres", + }); + }); + + it("rejects an empty --project-ref instead of treating it as a branch name", () => { + expect(failure({ projectRef: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--project-ref", + message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", + }); + }); + + it("reports an empty value before any conflict", () => { + expect(failure({ local: true, dbUrl: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--db-url", + }); + }); + }); +}); diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts new file mode 100644 index 0000000000..a48146643a --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -0,0 +1,55 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** More than one of `--local`, `--linked` and `--db-url` was set. */ +export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ + readonly flags: ReadonlyArray; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--project-ref` selects a linked project, so it cannot be combined with `--local` or `--db-url`. */ +export class TargetProjectRefConflictError extends Data.TaggedError( + "TargetProjectRefConflictError", +)<{ + readonly conflictsWith: "--local" | "--db-url"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--db-url` or `--project-ref` was passed an empty value. */ +export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueError")<{ + readonly flag: "--db-url" | "--project-ref"; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--password` was given while the resolved target is not a linked project. */ +export class TargetPasswordConflictError extends Data.TaggedError("TargetPasswordConflictError")<{ + readonly target: "local" | "db-url"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export type TargetSelectorError = + | TargetSelectorConflictError + | TargetProjectRefConflictError + | TargetEmptyValueError; diff --git a/apps/cli/src/command-internal/target/target.errors.unit.test.ts b/apps/cli/src/command-internal/target/target.errors.unit.test.ts new file mode 100644 index 0000000000..365b66fed2 --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + TargetEmptyValueError, + TargetPasswordConflictError, + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "./target.errors.ts"; + +describe("target errors", () => { + it.each([ + new TargetSelectorConflictError({ flags: ["--local", "--linked"], message: "m" }), + new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m" }), + new TargetEmptyValueError({ flag: "--db-url", message: "m", suggestion: "s" }), + new TargetPasswordConflictError({ target: "local", message: "m" }), + ])("classifies $_tag as invalid input the user can fix", (error) => { + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + }); + }); +}); diff --git a/apps/cli/src/command-internal/test-db.command-handler.ts b/apps/cli/src/command-internal/test-db.command-handler.ts index f923bd1132..765d12b249 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -1,5 +1,5 @@ import { Effect, Option } from "effect"; -import { Argument, Flag } from "effect/cli"; +import { Argument } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../shared/output/json-error-handling.ts"; @@ -7,6 +7,7 @@ import { Output } from "../shared/output/output.service.ts"; import { ProcessControl } from "../shared/runtime/process-control.service.ts"; import { withCommandTelemetry } from "../telemetry/command-telemetry.ts"; import type { TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; +import { targetFlags } from "./target/target-flags.ts"; import { testDb } from "./test-db.handler.ts"; /** Shared verbatim by both `test db` and its `db test` alias. */ @@ -42,25 +43,7 @@ export const testDbConfig = { Argument.withDescription("Paths to test files or directories."), Argument.variadic(), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Tests the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Runs pgTAP tests on the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Runs pgTAP tests on the local database."), - Flag.withDefault(false), - ), - // TS-only override of the linked project ref — see push.command.ts (db push). - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), } as const; export interface TestDbFlags { diff --git a/apps/cli/src/command-internal/test-db.errors.ts b/apps/cli/src/command-internal/test-db.errors.ts index 62b81c4820..557f2b07d7 100644 --- a/apps/cli/src/command-internal/test-db.errors.ts +++ b/apps/cli/src/command-internal/test-db.errors.ts @@ -41,14 +41,3 @@ export class TestDbNoTestsError extends Data.TaggedError("TestDbNoTestsError")<{ return actionability.invalidInput; } } - -/** More than one of `--db-url` / `--linked` / `--local` was set. */ -export class TestDbMutuallyExclusiveFlagsError extends Data.TaggedError( - "TestDbMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 557eda1c63..b5d3ce9f6b 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -3,7 +3,6 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; import { postgres } from "@supabase/stack/commands"; -import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { TelemetryState } from "../telemetry/telemetry-state.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; @@ -11,17 +10,16 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { resolveDbTargetFlags } from "./db-target-flags.ts"; +import { + requireTargetMutationConsent, + hostedMutationPreapproval, +} from "./hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "./target/resolve-target.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { TestDbFlags } from "./test-db.command-handler.ts"; -import { - TestDbEnablePgtapError, - TestDbMutuallyExclusiveFlagsError, - TestDbNoTestsError, - TestDbRunError, -} from "./test-db.errors.ts"; +import { TestDbEnablePgtapError, TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; import { buildPgProveArgs, pgProveMajor } from "./test-db.pg-prove-args.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { @@ -117,40 +115,22 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const dnsResolver = yield* DnsResolverFlag; - const cliArgs = yield* CliArgs; yield* Effect.gen(function* () { - // Selection is keyed off flag presence, not its boolean value: `--linked=false` - // and `--no-linked` both count as explicitly setting `linked`. - const target = resolveDbTargetFlags(cliArgs.args); - const { setFlags } = target; - if (setFlags.length > 1) { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); - } - - const connType = target.connType ?? "local"; - - // `--project-ref` never implies `--linked`; see push.handler.ts's - // identical guard (db push) for the rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); - } - - const { conn, isLocal } = yield* resolver.resolve({ + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, dbUrl: flags.dbUrl, - connType, + projectRef: flags.projectRef, + }); + + const resolved = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); + const { conn, isLocal } = resolved; const args = buildPgProveArgs({ paths: flags.paths, @@ -161,7 +141,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const backend = yield* currentStackBackend; const managedStack = - backend.kind === "stack" && connType === "local" ? yield* managedStackFor() : undefined; + backend.kind === "stack" && target.kind === "local" ? yield* managedStackFor() : undefined; const proveRuntime = backend.kind === "stack" && managedStack === undefined ? yield* resolveBundledPostgresRuntime(undefined, runtimeInfo.platform, runtimeInfo.arch) @@ -177,7 +157,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { managedStack !== undefined ? proveConnection.host : useNativeProve - ? connType === "local" + ? target.kind === "local" ? "127.0.0.1" : conn.host : stackPublishedProve @@ -217,13 +197,17 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { let lastVerdict = ""; let lastSummary = ""; + yield* requireTargetMutationConsent(resolved, { + action: "Run pgTAP tests", + details: [ + "enable the pgtap extension", + `run pg_prove against ${args.hostPaths.join(", ")}`, + "drop the pgtap extension afterwards unless it was already installed", + ], + }); + const { exitCode } = yield* Effect.scoped( Effect.gen(function* () { - // stdout is reserved for the pg_prove TAP stream (forwarded byte-exact - // below), so connection diagnostics go to stderr. An `Output.task` - // spinner would also corrupt the stream: it writes ANSI to stdout in - // text mode, and JSON log events to stdout in stream-json mode. - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); // Detect pre-existence before enabling so the drop is skipped when pgTAP diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 9572227a20..e5464c9414 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Option } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -12,11 +12,12 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); -import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; import { VALID_REF, VALID_TOKEN, mockCommandSettings, + withConfigEnv, mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; @@ -30,10 +31,19 @@ import { type StubbedRequest, } from "../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../tests/helpers/hosted-consent.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; +import { HostedMutationDeclinedError } from "./hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "./target/target.errors.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; @@ -67,28 +77,40 @@ const stackApiStub = Layer.succeed(StackApi, { }); function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { - const calls: Array<{ - readonly connType: string; - readonly linkedProjectRef: Option.Option; - }> = []; + const inputs: Array = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { - calls.push({ - connType: flags.connType ?? "", - linkedProjectRef: flags.linkedProjectRef ?? Option.none(), - }); - return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }); + resolveConnection: (input: DbConnectionInput) => { + inputs.push(input); + return Effect.succeed({ + conn: opts.conn ?? LOCAL_CONN, + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get calls() { - return calls; + get inputs() { + return inputs; }, }; } +const projectRefLayer = Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("resolve is not used by this test"), + resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 + ? { ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false } + : { ref: VALID_REF, source: "file" as const, matchesLinkedRef: true }, + ), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + promptProjectRef: () => Effect.succeed(VALID_REF), +}); + function mockDbConnection(opts: { existed?: boolean; connectFails?: boolean; @@ -266,14 +288,20 @@ interface SetupOpts { workdir?: string; dnsResolver?: "native" | "https"; dockerInstalled?: boolean; - /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ - args?: ReadonlyArray; /** Runs the real `DbConfigResolver` against `workdir`. */ realLayers?: boolean; + /** `--yes`; on by default so hosted runs reach pg_prove. */ + yes?: boolean; + interactive?: boolean; + confirm?: ReadonlyArray; } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ + format: opts.format ?? "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirm, + }); const telemetry = mockTelemetryStateTracked(); const resolver = mockResolver({ conn: opts.conn, isLocal: opts.isLocal }); const connection = mockDbConnection(opts); @@ -302,7 +330,12 @@ function setup(opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(DnsResolverFlag, opts.dnsResolver ?? "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), + projectRefLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: opts.interactive ?? false }), BunServices.layer, ); return { layer, out, telemetry, connection, docker, bundled, resolver }; @@ -312,7 +345,7 @@ const flags = (over: Partial[0]> = {}) => ({ paths: over.paths ?? [], dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), }); @@ -418,7 +451,6 @@ describe("test db integration", () => { const { layer, docker, connection } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); @@ -435,7 +467,6 @@ describe("test db integration", () => { const { layer, docker, bundled } = setup({ conn: LOCAL_CONN, isLocal: true, - args: ["--db-url=postgresql://postgres:postgres@127.0.0.1:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( @@ -458,7 +489,6 @@ describe("test db integration", () => { conn: { ...LOCAL_CONN, host: "db.internal" }, isLocal: true, dockerInstalled: false, - args: ["--db-url=postgresql://postgres:postgres@db.internal:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( @@ -478,7 +508,6 @@ describe("test db integration", () => { conn: REMOTE_CONN, isLocal: false, dockerInstalled: false, - args: ["--linked"], }); return Effect.gen(function* () { yield* testDb(flags({ local: false, linked: true })); @@ -493,7 +522,6 @@ describe("test db integration", () => { conn: REMOTE_CONN, isLocal: false, dnsResolver: "https", - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); @@ -669,66 +697,56 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects mutually exclusive connection flags (--linked + --local via args)", () => { - const { layer } = setup({ args: ["--linked", "--local"] }); + it.live("rejects mutually exclusive target flags (--linked + --local)", () => { + const { layer, resolver, connection } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); + const exit = yield* Effect.exit(testDb(flags({ linked: true, local: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); } + expect(resolver.inputs).toEqual([]); + expect(connection.connectCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); + it.live("treats --linked=false as absent and runs against the default local target", () => { + const { layer, resolver } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } + yield* testDb(flags({ linked: false, local: false })); + expect(resolver.inputs[0]?.target.kind).toBe("local"); }).pipe(Effect.provide(layer)); }); - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - const { layer } = setup({ args: ["--linked=false"] }); + it.live("follows a remote workspace target when no selector flag is given", () => { + const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - // No explicit assertion: success means routing reached resolver.resolve - // with connType "linked" instead of failing on mutual exclusion or the - // local-target guard. - yield* testDb(flags()); + yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, testDb(flags())); + expect(resolver.inputs[0]?.target.kind).toBe("linked"); }).pipe(Effect.provide(layer)); }); - it.live("tests the project given via --project-ref --linked", () => { - // Only reaches the resolver as `linkedProjectRef` when --linked is set. + it.live("selects the project given via --project-ref alone", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false, args: ["--linked"] }); + const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* testDb(flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) })); - expect(resolver.calls[0]?.connType).toBe("linked"); - expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + yield* testDb(flags({ projectRef: Option.some(FLAG_REF) })); + expect(resolver.inputs[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref on the default local target", () => { - // No explicit --local/--db-url flag; the guard must fire from the default alone. + it.live("rejects --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, docker, resolver } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags({ projectRef: Option.some(FLAG_REF) }))); + const exit = yield* Effect.exit( + testDb(flags({ local: true, projectRef: Option.some(FLAG_REF) })), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); } - expect(resolver.calls).toEqual([]); + expect(resolver.inputs).toEqual([]); expect(connection.execCalls).toEqual([]); expect(docker.lastOpts).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -750,25 +768,42 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the connection diagnostic to stderr, keeping stdout for TAP", () => { + it.live("keeps stdout for the TAP stream only", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* testDb(flags()); - expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stdoutText).toBe(""); expect(out.messages).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("labels the connection diagnostic 'remote' for non-local connections", () => { - const { layer, out } = setup({ + it.live("asks before enabling pgTAP on a hosted target and stops when declined", () => { + const { layer, out, connection, docker } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], + yes: false, + interactive: true, + confirm: [false], }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); - expect(out.stderrText).toContain("Connecting to remote database..."); + const exit = yield* Effect.exit( + testDb(flags({ dbUrl: Option.some("postgres://x"), paths: ["/abs/a_test.sql"] })), + ); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + HostedMutationDeclinedError, + ); + expect(out.stderrText).toContain("enable the pgtap extension"); + expect(out.stderrText).toContain("run pg_prove against /abs/a_test.sql"); + expect(connection.connectCalls).toEqual([]); + expect(docker.lastOpts).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs against a local target without asking for consent", () => { + const { layer, out } = setup({ yes: false }); + return Effect.gen(function* () { + yield* testDb(flags()); + expect(out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -789,7 +824,6 @@ describe("test db integration", () => { it.effect("mints a login role through the real resolver for --linked --project-ref", () => { const { layer, connection } = setup({ - args: ["test", "db", "--linked", "--project-ref", VALID_REF], realLayers: true, workdir: tempWorkdir.current, }); @@ -817,7 +851,6 @@ describe("test db integration", () => { it.effect("makes no Management API request for the default local target", () => { const { layer } = setup({ - args: ["test", "db"], realLayers: true, workdir: tempWorkdir.current, }); @@ -849,3 +882,32 @@ describe("test db integration", () => { ); }); }); + +describeHostedConsent("test db", { + invoke: () => { + const connection = mockDbConnection({}); + return { + run: testDb( + flags({ linked: true, projectRef: Option.some(VALID_REF), paths: ["supabase/tests"] }), + ).pipe( + Effect.provide( + Layer.mergeAll( + mockResolver({ conn: REMOTE_CONN, isLocal: false }).layer, + connection.layer, + mockDockerRun({}).layer, + mockBundledPostgresClient({}).layer, + stackApiStub, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + runtimeInfoLayer("linux"), + Layer.succeed(DebugFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + Effect.scoped, + ), + mutations: () => connection.execCalls.length, + }; + }, +}); diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 2ba9df6c0f..90f826b13e 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; +import { commandCredentialsLayer } from "../auth/command-credentials.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; +import { projectRefLayer } from "../config/project-ref.layer.ts"; import { dbConfigLayer } from "./db-config.layer.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { dockerRunLayer } from "./docker-run.layer.ts"; @@ -27,6 +30,23 @@ import { bundledPostgresClientLayer } from "./bundled-postgres-client.ts"; */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +const projectRef = projectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliSettings), +); + const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), @@ -41,6 +61,7 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray) => dbConnectionLayer, dockerRunLayer, cliSettings, + projectRef, // Exposed at top level so `withCommandTelemetry` can read // `stitchedDistinctId()`. The same reference is provided to `dbConfig` // above, so the lazy linked stack shares a single stitch attempt. diff --git a/apps/cli/src/command-internal/test-db.layers.unit.test.ts b/apps/cli/src/command-internal/test-db.layers.unit.test.ts index db2062ca89..8a171bb7c5 100644 --- a/apps/cli/src/command-internal/test-db.layers.unit.test.ts +++ b/apps/cli/src/command-internal/test-db.layers.unit.test.ts @@ -39,6 +39,7 @@ import { DbConnection } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; import { testDbRuntimeLayer } from "./test-db.layers.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-test-db-layers-"); @@ -68,7 +69,7 @@ function ambientStubs() { connect: () => Effect.die("db-connection not needed for layer-exposure test"), }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), }), @@ -82,6 +83,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockTelemetryStateLayer, cliConfigValuesTestLayer, diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 01cd33a2ed..19025b11c6 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -4,19 +4,21 @@ import { FetchHttpClient } from "effect/http"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted } from "effect"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { CommandRuntime } from "../shared/runtime/command-runtime.service.ts"; -import { mockOutput, mockProcessControl } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; import { parseConnectionString } from "./db-config.parse.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; import { testDb } from "./test-db.handler.ts"; @@ -102,7 +104,13 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { output.layer, mockTelemetryStateTracked().layer, Layer.succeed(DbConfigResolver, { - resolve: () => Effect.succeed({ conn: connection, isLocal: true }), + resolveConnection: (input) => + Effect.succeed({ + conn: connection, + isLocal: true, + ref: Option.none(), + target: input.target, + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(RuntimeInfo, { @@ -117,6 +125,17 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { Layer.succeed(DnsResolverFlag, "native" as const), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, false), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: false }), + Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("unused"), + resolveForLink: () => Effect.die("unused"), + resolveRef: () => Effect.die("unused"), + resolveOptional: () => Effect.die("unused"), + promptProjectRef: () => Effect.die("unused"), + }), Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), Layer.succeed(DockerRun, { run: () => Effect.die("unused"), @@ -156,7 +175,6 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { } } expect(failedOutput.stdoutText).toContain("not ok"); - expect(failedOutput.stderrText).toContain("Connecting to local database"); yield* assertPgtapDropped.pipe(Effect.provide(services)); if (runtime !== "native") return; diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json index 14e5c5dc26..4da32d2256 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json @@ -707,6 +707,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json index 034299bb78..15ec88281a 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json @@ -676,6 +676,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json index ca7de557e0..4354055636 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json @@ -1121,6 +1121,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json index 405b3322ba..91b6e9789b 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json @@ -668,6 +668,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json index 788f1a6061..7c5f677b02 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json @@ -692,6 +692,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json index b2fdf27b39..1c691d0814 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json @@ -674,6 +674,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json index 7e20a8e533..60435ac8ca 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json @@ -668,6 +668,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index d301665c97..c32b16b2ba 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -13,6 +13,14 @@ export function relativeConfigPath(workdir: string, path: string): string { return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; } +/** The one message every loader reports for a config file that fails to parse. */ +export function describeConfigParseError( + workdir: string, + error: { readonly path: string; readonly cause: unknown }, +): string { + return `failed to parse ${relativeConfigPath(workdir, error.path)}: ${String(error.cause)}`; +} + /** * Shared "no project here" message for JSON-capable config loads (`config diff/push/pull`, * `gen types`, `storage ls|mv|rm|cp`, `seed buckets`). diff --git a/apps/cli/src/command-internal/workdir-project.unit.test.ts b/apps/cli/src/command-internal/workdir-project.unit.test.ts index e9beef0fd0..2aa3b94b7f 100644 --- a/apps/cli/src/command-internal/workdir-project.unit.test.ts +++ b/apps/cli/src/command-internal/workdir-project.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { missingProjectConfigMessage } from "./workdir-project.ts"; +import { describeConfigParseError, missingProjectConfigMessage } from "./workdir-project.ts"; describe("missingProjectConfigMessage", () => { it("points at `supabase init` for a defaulted workdir", () => { @@ -29,3 +29,20 @@ describe("missingProjectConfigMessage", () => { expect(message).not.toContain(""); }); }); + +describe("describeConfigParseError", () => { + it("names the failed file relative to the workdir", () => { + expect( + describeConfigParseError("/repo", { + path: "/repo/supabase/config.toml", + cause: new Error("unexpected end of input"), + }), + ).toBe("failed to parse supabase/config.toml: Error: unexpected end of input"); + }); + + it("keeps a path outside the workdir as given", () => { + expect( + describeConfigParseError("/repo", { path: "/elsewhere/config.json", cause: "bad json" }), + ).toBe("failed to parse /elsewhere/config.json: bad json"); + }); +}); diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index b26a98fb83..d1814e5135 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -94,3 +94,5 @@ One `result` NDJSON event on success containing the backup response object. - Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. + +- Prints `Target: ` once on stderr in every output format, for example `Target: project () · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 942b76f10c..067bc7f067 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -9,6 +9,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -43,4 +44,5 @@ export const backupsListCommand = Command.make("list", config).pipe( backupsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 5f1c15cba3..3148ffa702 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -33,18 +33,21 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — restore initiated | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | -| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — restore initiated | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | +| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -81,3 +84,6 @@ One `result` NDJSON event on success with the project ref payload. - Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. + +- Prints `Target: ` once on stderr in every output format, for example `Target: project () · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the project is resolved and before the POST, with default No: `Restore the database to Unix timestamp (PITR)`. With no `--timestamp` the action reads `Start a PITR restore without a recovery time target`. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: on ` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index 5a51d25c68..7049722198 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -9,6 +9,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -47,4 +48,5 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( backupsRestore(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "restore"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 389351aac1..9206771d74 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -5,6 +5,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -33,6 +34,12 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: Option.isSome(flags.timestamp) + ? `Restore the database to Unix timestamp ${flags.timestamp.value} (PITR)` + : "Start a PITR restore without a recovery time target", + }); + const restoring = output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; yield* api.v1 diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index adae0d4ce6..cb758bcde8 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,6 +1,6 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; +import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { @@ -10,10 +10,14 @@ import { mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -43,6 +47,15 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-backups-restore-int-"); +const buildRuntime = (opts: Parameters[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Start a PITR restore without a recovery time target on project ${VALID_REF}\n`; + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -51,7 +64,7 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -98,7 +111,9 @@ describe("backups restore integration", () => { projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -150,7 +165,9 @@ describe("backups restore integration", () => { projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }, @@ -211,7 +228,7 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -251,7 +268,7 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -293,3 +310,10 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("backups restore", { + api: { response: { status: 201, body: null } }, + invoke: () => ({ + run: backupsRestore({ projectRef: Option.none(), timestamp: Option.some(1_707_407_047) }), + }), +}); diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 8116b7f543..1bc416c946 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -1,7 +1,7 @@ # `supabase bootstrap [template]` -`bootstrap` is a meta-orchestrator: it chains a workdir prompt → template fetch/download → -blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → +`bootstrap` is a meta-orchestrator: it chains a workdir prompt → hosted-mutation consent → +template fetch/download → blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → health poll → write `.env` → `db push` → start suggestion. Every step is native TypeScript, including the migration push (`dbPushCore`, shared with the standalone `supabase db push` command — see Notes). @@ -69,15 +69,15 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle | `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | | `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | | `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_YES` | shell value confirms the hosted-mutation consent and the overwrite prompt; a `SUPABASE_YES` in the project `supabase/.env` does not confirm the consent | no | | `SUPABASE_EXPERIMENTAL_STACK` | blank/`scratch` path only; when `1`, persists `[experimental] stack = true` and omits Docker-era default ports. Empty is unset; non-boolean values fail closed | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; native push failure (missing local/remote migrations, cancelled confirmation, connect/apply failure); invalid `SUPABASE_EXPERIMENTAL_STACK` on the blank path; any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: ` and continues). | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; hosted-mutation consent declined, or required but unavailable (JSON, piped stdin or agent run without `--yes`/shell `SUPABASE_YES`); native push failure (missing local/remote migrations, connect/apply failure); invalid `SUPABASE_EXPERIMENTAL_STACK` on the blank path; any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: ` and continues). | ## Telemetry @@ -98,9 +98,9 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ### `--output-format text` stderr progress: `Using workdir …`, `Created a new project at …`, `Linking project…`, -`Checking project health…`, the native push step's own progress (`Connecting to remote -database…`, `Applying migration …`, `Seeding…`, skip/up-to-date notices, confirmation prompts — -byte-identical to the standalone `db push`, see its own `SIDE_EFFECTS.md`), and the final +`Checking project health…`, the native push step's own progress (`Applying migration …`, `Seeding…`, +skip/up-to-date notices, as in the standalone `db push`, see its own `SIDE_EFFECTS.md`; it asks +no confirmation of its own), and the final `To start your app:` suggestion (Aqua command lines). `Downloading: ` goes to stdout (text mode only). The `create` sub-step also echoes the new project per `-o` (`pretty|json|yaml|toml|env`); bootstrap adds no `-o` output of its own. The push step's own @@ -125,9 +125,18 @@ suppressed; a single structured result is emitted for the whole command: ## Notes +- **Hosted-mutation consent.** Bootstrap declares the `account` target group with consent + default Yes. After the template is chosen and before anything is written, it asks once: + `Create a new Supabase project from the "