@carllee1983/dbcli 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -310,6 +310,19 @@ dbcli init --rename staging:stg # rename
310
310
  dbcli init --remove stg # remove
311
311
  ```
312
312
 
313
+ Rotating one connection's password — nothing else in the config moves:
314
+
315
+ ```bash
316
+ dbcli password prod # masked prompt
317
+ rotate-secret | dbcli password prod --stdin # for scheduled rotation scripts
318
+ ```
319
+
320
+ The value goes to the env var the config actually references (a literal password
321
+ is converted to `{ "$env": ... }` on first use, and a connection with no
322
+ `envFile` gets one recorded so the reader loads it), is verified by connecting
323
+ before it is saved (`--skip-test` to opt out), and the env file is written
324
+ `0600` on POSIX.
325
+
313
326
  For a connection shared across projects, use the explicit root-level `--global` scope. It stores a v2 registry at `~/.config/dbcli/config.json`; it does not create or modify a project binding:
314
327
 
315
328
  ```bash
@@ -436,8 +449,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
436
449
  (no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
437
450
  (`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
438
451
  - `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
439
- and `rows_affected: 0`; proceed once `status:"success"` and the SQL shape matches the
440
- intended `--where` / `--set`. MongoDB prints a shell-style preview.
452
+ and `status:"dry_run"` with `rows_affected: 0` never `success`, which now means the
453
+ write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
454
+ Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
455
+ MongoDB prints a shell-style preview.
456
+ - `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
457
+ first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
458
+ unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
441
459
  - `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
442
460
  after a failure); optional for one-off manual writes.
443
461
 
@@ -22,6 +22,7 @@ never the right move.
22
22
  **Commands** —
23
23
  [init](#init) ·
24
24
  [use](#use) ·
25
+ [password](#password) ·
25
26
  [list](#list) ·
26
27
  [schema](#schema) ·
27
28
  [query](#query) ·
@@ -196,6 +197,46 @@ dbcli list --use prod
196
197
 
197
198
  **Options:** `--list`, `--format <text|json>`, `--confirm-production <name>` (required when explicitly selecting a production connection as the default).
198
199
 
200
+ ### password
201
+
202
+ Change one connection's password without touching any other setting — built for
203
+ environments where credentials rotate on a schedule.
204
+
205
+ ```bash
206
+ dbcli password # Masked prompt, rotates the default connection
207
+ dbcli password prod # Masked prompt, rotates 'prod'
208
+ rotate-secret | dbcli password prod --stdin # Non-interactive, nothing lands in shell history
209
+ dbcli password prod --password "$NEW" --skip-test --format json
210
+ ```
211
+
212
+ Where the value lands is read from the config, never guessed: a connection whose
213
+ `password` is `{ "$env": "NAME" }` gets `NAME` rewritten in its `envFile`. A
214
+ connection that declares no `envFile` has one recorded (`.env.local`) as part of
215
+ the rotation — without it the reader would never load the file. A connection
216
+ still holding a literal password is converted to
217
+ `{ "$env": "DBCLI_<CONN>_PASSWORD" }` once, so later rotations only touch the env
218
+ file. Values are written quoted (`NAME="..."`), so leading and trailing
219
+ whitespace survives the round trip.
220
+
221
+ v1 configs rewrite `DBCLI_PASSWORD` in `.env.local`, matching the v1 reader. A v1
222
+ config whose password comes from some other environment variable is refused with
223
+ an explanation: v1 has no per-connection env file, so no file dbcli writes could
224
+ make that variable resolve — set it in the environment, or migrate to v2.
225
+
226
+ The new password is verified by connecting with it before anything is written,
227
+ so a bad rotation fails without leaving broken credentials behind. Pass
228
+ `--skip-test` when the database is unreachable from where the command runs. The
229
+ env file is written with `0600` permissions on POSIX systems (Windows has no
230
+ equivalent mode bit — the file inherits the directory's ACL), and the value is
231
+ never echoed or
232
+ logged.
233
+
234
+ **Options:** `[connection]`, `--stdin`, `--password <value>` (visible in shell
235
+ history and the process list — prefer `--stdin`), `--skip-test`,
236
+ `--format <text|json>`.
237
+
238
+ Blocked under `DBCLI_AGENT_MODE=1` like every other credential mutation.
239
+
199
240
  ### Agent configuration trust boundary
200
241
 
201
242
  When `DBCLI_AGENT_MODE=1`, configuration, permission, and credential mutations
@@ -296,6 +337,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
296
337
  **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
297
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
298
339
 
340
+ > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
+ > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
+ > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
343
+ > reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
344
+ > templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
345
+ > scope cannot be established is treated as admin rather than guessed downward.
346
+
299
347
  > **Server-side scripts are rejected on every path.** MongoDB `$where`,
300
348
  > `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
301
349
  > execute code on the database server. The adapters reject them anywhere in a
@@ -884,7 +932,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
884
932
  dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
885
933
  ```
886
934
 
887
- **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
935
+ **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
888
936
  **Permission:** read-write+
889
937
 
890
938
  ### update
@@ -897,7 +945,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
897
945
  dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
898
946
  ```
899
947
 
900
- **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
948
+ **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
901
949
  **Permission:** read-write+
902
950
 
903
951
  > **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
@@ -918,7 +966,7 @@ dbcli delete users --where "id=1" --force
918
966
  dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
919
967
  ```
920
968
 
921
- **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
969
+ **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
922
970
  **Permission:** data-admin+
923
971
 
924
972
  ### export
@@ -2499,6 +2547,8 @@ dbcli migrate add-enum status active inactive suspended
2499
2547
  dbcli migrate alter-enum status --add-value archived
2500
2548
  dbcli migrate drop-enum status --execute --force
2501
2549
  ```
2550
+ A destructive `migrate` action (`drop`, `drop-column`, `drop-index`, `drop-enum`) asks for confirmation on stderr before it runs, and reports `status: "cancelled"` if you decline — not `success`, which it used to claim with the cancellation buried in `warnings`. `--force` skips the question; a non-interactive run that omits it cannot answer and therefore cancels.
2551
+
2502
2552
 
2503
2553
  **Column spec format:** `name:type[:modifier[:modifier...]]`
2504
2554
  - Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`
@@ -310,6 +310,19 @@ dbcli init --rename staging:stg # rename
310
310
  dbcli init --remove stg # remove
311
311
  ```
312
312
 
313
+ Rotating one connection's password — nothing else in the config moves:
314
+
315
+ ```bash
316
+ dbcli password prod # masked prompt
317
+ rotate-secret | dbcli password prod --stdin # for scheduled rotation scripts
318
+ ```
319
+
320
+ The value goes to the env var the config actually references (a literal password
321
+ is converted to `{ "$env": ... }` on first use, and a connection with no
322
+ `envFile` gets one recorded so the reader loads it), is verified by connecting
323
+ before it is saved (`--skip-test` to opt out), and the env file is written
324
+ `0600` on POSIX.
325
+
313
326
  For a connection shared across projects, use the explicit root-level `--global` scope. It stores a v2 registry at `~/.config/dbcli/config.json`; it does not create or modify a project binding:
314
327
 
315
328
  ```bash
@@ -436,8 +449,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
436
449
  (no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
437
450
  (`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
438
451
  - `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
439
- and `rows_affected: 0`; proceed once `status:"success"` and the SQL shape matches the
440
- intended `--where` / `--set`. MongoDB prints a shell-style preview.
452
+ and `status:"dry_run"` with `rows_affected: 0` never `success`, which now means the
453
+ write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
454
+ Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
455
+ MongoDB prints a shell-style preview.
456
+ - `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
457
+ first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
458
+ unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
441
459
  - `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
442
460
  after a failure); optional for one-off manual writes.
443
461
 
@@ -22,6 +22,7 @@ never the right move.
22
22
  **Commands** —
23
23
  [init](#init) ·
24
24
  [use](#use) ·
25
+ [password](#password) ·
25
26
  [list](#list) ·
26
27
  [schema](#schema) ·
27
28
  [query](#query) ·
@@ -196,6 +197,46 @@ dbcli list --use prod
196
197
 
197
198
  **Options:** `--list`, `--format <text|json>`, `--confirm-production <name>` (required when explicitly selecting a production connection as the default).
198
199
 
200
+ ### password
201
+
202
+ Change one connection's password without touching any other setting — built for
203
+ environments where credentials rotate on a schedule.
204
+
205
+ ```bash
206
+ dbcli password # Masked prompt, rotates the default connection
207
+ dbcli password prod # Masked prompt, rotates 'prod'
208
+ rotate-secret | dbcli password prod --stdin # Non-interactive, nothing lands in shell history
209
+ dbcli password prod --password "$NEW" --skip-test --format json
210
+ ```
211
+
212
+ Where the value lands is read from the config, never guessed: a connection whose
213
+ `password` is `{ "$env": "NAME" }` gets `NAME` rewritten in its `envFile`. A
214
+ connection that declares no `envFile` has one recorded (`.env.local`) as part of
215
+ the rotation — without it the reader would never load the file. A connection
216
+ still holding a literal password is converted to
217
+ `{ "$env": "DBCLI_<CONN>_PASSWORD" }` once, so later rotations only touch the env
218
+ file. Values are written quoted (`NAME="..."`), so leading and trailing
219
+ whitespace survives the round trip.
220
+
221
+ v1 configs rewrite `DBCLI_PASSWORD` in `.env.local`, matching the v1 reader. A v1
222
+ config whose password comes from some other environment variable is refused with
223
+ an explanation: v1 has no per-connection env file, so no file dbcli writes could
224
+ make that variable resolve — set it in the environment, or migrate to v2.
225
+
226
+ The new password is verified by connecting with it before anything is written,
227
+ so a bad rotation fails without leaving broken credentials behind. Pass
228
+ `--skip-test` when the database is unreachable from where the command runs. The
229
+ env file is written with `0600` permissions on POSIX systems (Windows has no
230
+ equivalent mode bit — the file inherits the directory's ACL), and the value is
231
+ never echoed or
232
+ logged.
233
+
234
+ **Options:** `[connection]`, `--stdin`, `--password <value>` (visible in shell
235
+ history and the process list — prefer `--stdin`), `--skip-test`,
236
+ `--format <text|json>`.
237
+
238
+ Blocked under `DBCLI_AGENT_MODE=1` like every other credential mutation.
239
+
199
240
  ### Agent configuration trust boundary
200
241
 
201
242
  When `DBCLI_AGENT_MODE=1`, configuration, permission, and credential mutations
@@ -296,6 +337,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
296
337
  **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
297
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
298
339
 
340
+ > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
+ > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
+ > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
343
+ > reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
344
+ > templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
345
+ > scope cannot be established is treated as admin rather than guessed downward.
346
+
299
347
  > **Server-side scripts are rejected on every path.** MongoDB `$where`,
300
348
  > `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
301
349
  > execute code on the database server. The adapters reject them anywhere in a
@@ -884,7 +932,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
884
932
  dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
885
933
  ```
886
934
 
887
- **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
935
+ **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
888
936
  **Permission:** read-write+
889
937
 
890
938
  ### update
@@ -897,7 +945,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
897
945
  dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
898
946
  ```
899
947
 
900
- **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
948
+ **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
901
949
  **Permission:** read-write+
902
950
 
903
951
  > **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
@@ -918,7 +966,7 @@ dbcli delete users --where "id=1" --force
918
966
  dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
919
967
  ```
920
968
 
921
- **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
969
+ **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
922
970
  **Permission:** data-admin+
923
971
 
924
972
  ### export
@@ -2499,6 +2547,8 @@ dbcli migrate add-enum status active inactive suspended
2499
2547
  dbcli migrate alter-enum status --add-value archived
2500
2548
  dbcli migrate drop-enum status --execute --force
2501
2549
  ```
2550
+ A destructive `migrate` action (`drop`, `drop-column`, `drop-index`, `drop-enum`) asks for confirmation on stderr before it runs, and reports `status: "cancelled"` if you decline — not `success`, which it used to claim with the cancellation buried in `warnings`. `--force` skips the question; a non-interactive run that omits it cannot answer and therefore cancels.
2551
+
2502
2552
 
2503
2553
  **Column spec format:** `name:type[:modifier[:modifier...]]`
2504
2554
  - Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`
package/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ All notable changes to dbcli are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.58.0] - 2026-08-14 - A write that did not happen stops reporting success
9
+
10
+ ### Changed
11
+
12
+ - **A `migrate` action somebody declined at the prompt reported `success`, and the prompt itself came from inside core.** `DDLExecutor` called `promptUser.confirm` directly — the one thing ADR 0009 removed from `DataExecutor`, still in place here because the CI gate reads writes and that was an import — and a declined `DROP TABLE` came back as `status: "success"` with the cancellation mentioned only in `warnings`, so a caller reading `status` was told the table was gone. `DDLExecutionOptions` now carries the same `confirm` callback, `src/commands/mutation-confirm.ts` supplies the CLI's implementation on stderr like every other question dbcli asks, and `DDLExecutionResult.status` gained `cancelled`. A destructive operation with no handler and no `--force` is refused rather than defaulted either way, matching `DataExecutor`. With the import gone, `scripts/check-core-no-stdout.ts` now rejects an `@/utils/prompts` import anywhere under `src/core/**` — no module had to join the ratchet for that rule to hold, which is the evidence `ddl-executor` was the last one. ADR 0009 recorded this as an open gap one commit ago and now records it as closed, with the falsification condition naming the new rule (#70).
13
+
14
+ - **MongoDB and Redis writes now ask before writing, and this breaks unattended scripts that never passed `--force`.** `DataExecutor` performs the confirmation, and only SQL goes through it: `insert`, `update` and `delete` against MongoDB or Redis were issued straight from the command to the adapter, so `dbcli delete orders --where '{}'` emptied a collection without asking anybody, in a terminal or out of it — while the same command against PostgreSQL stopped and waited. All six branches now pass through `confirmDirectMutation` before an adapter is even built, with `--force` honoured in exactly that one place, so the three engines cannot answer the question differently. The prompt shows the statement the dry run would print and omits the parameter block, because a MongoDB or Redis statement carries its values inline. The consequence to plan for: dbcli's existing rule is that a non-interactive run cannot answer the prompt and therefore ends as `status: "cancelled"` having changed nothing, which is what SQL automation has always had to pass `--force` to avoid — MongoDB and Redis automation must now do the same, and a script that does not will stop writing rather than start failing silently. `cancelled` is consequently reachable on every engine, which is what the documentation already implied. `tests/unit/commands/mongo-redis-confirmation.test.ts` covers all six branches against a mock adapter, and `tests/integration/mongo-redis-confirmation.test.ts` re-asserts the important half against the real servers in `docker-compose.test.yml` — the document is still there, the key still holds its value — because "the adapter method was not called" is a statement about a mock, not about the data. That compose file gained a `mongodb` service on the default port, which the pre-existing mongo integration tests were already assuming and silently skipping without, and `bun run test:docker` now runs all of `tests/integration` rather than only `tests/integration/adapters` — it brought up six services and then exercised three of them (#70).
15
+
16
+ - **`status` on the `insert` / `update` / `delete` result envelope gained `cancelled` and `dry_run`, and this is a breaking change to what those commands report.** Declining at the confirmation prompt, previewing with `--dry-run`, and running a write that matched no rows were all one value: `status: "success"` with `rows_affected: 0`. The three are different events and a caller had no way to tell them apart. Worse, all three commands derived the audit entry's success flag from that field, so pressing `N` at the prompt wrote an audit record saying the write had happened — an audit log that lies is worse than none. A parallel field was rejected: it would have left `status` still saying success while `outcome` said cancelled, and every consumer reading only `status` — `writeAuditEntry` included — would have stayed wrong. Exit codes are unchanged: only `error` exits `1`, because cancelling is a choice rather than a failure. The audit mapping now lives in one place (`src/commands/mutation-audit.ts`) instead of one copy per command; a dry run is recorded as successful because it did complete the preview it was asked for, a cancellation is recorded as unsuccessful, and `metadata.outcome` carries the exact ending in both cases, since a boolean cannot express "did not happen, did not fail". `tests/unit/core/data-executor-outcome-status.test.ts` pins all four endings as mutually distinguishable and asserts that neither cancellation nor dry run issues the statement. The guarantee is that no write is sent, not that the database is untouched: both paths still open a connection and read the table schema, because the SQL a dry run exists to show cannot be built without the column list, and a confirmation prompt has nothing to display until it is. `tests/unit/commands/mutation-db-contact.test.ts` states that boundary at the command level — `execute` never called, `connect` and `getTableSchema` called once each — so the wider claim cannot be assumed from the narrower test (#70).
17
+
18
+ - **The confirmation prompt for `insert` / `update` / `delete` moved to stderr, so stdout is one JSON document whether or not the write was forced.** The generated SQL, the destructive-delete warning, the parameter list, and the `y/n` question were all written to stdout ahead of the result envelope, which meant that every mutation nobody passed `--force` to produced stdout no parser could read — including with `--format json`, whose entire purpose is to be parsed. The bug survived because the JSON test forced the write and therefore skipped the confirmation altogether. All four now go to stderr, matching what `audit clear` already did for its own confirmation: a question addressed to a person is not part of the result. A terminal shows both streams, so nothing changes for the human being asked, and `tests/unit/commands/mutation-output-characterisation.test.ts` pins the block byte for byte on stderr while asserting `JSON.parse` succeeds on stdout for a non-forced run. `promptUser.confirm` writes to stderr for every caller now, not only these three, since a y/n question on the data channel is never what was wanted. Everything in the block is localised: `Generated SQL:` and `Parameters:` are new `ceremony.*` keys, and the destructive-delete warning and the question itself moved out of `DataExecutor` — `MutationConfirmationRequest` now carries `destructive: boolean` instead of a finished `warning` sentence and `prompt` string. The line is not "core cannot translate" — `permission-guard` now does, and `blacklist-validator` always did — it is that core states facts and the command layer chooses words: whether a delete can be undone is a fact, while the sentence shown about it, in what tone and on which stream, is presentation, and an embedder should be able to say it in its own product's voice. The refusal `enforcePermissionForType` throws is localised in place (`errors.permission_requires_level`), with the permission names interpolated verbatim since they are the values written in the config file. English output is unchanged in every case; `tests/unit/i18n/ceremony-messages.test.ts` checks key and placeholder parity between the two `ceremony.json` files, so an English string added without its translation now fails (#70).
19
+
20
+ - **`--recovery` now covers failures the executor reports, not only failures it throws, and the recovery envelope replaces the result envelope rather than following it.** A statement that failed inside `DataExecutor` returned a result with `status: "error"`, which the command printed as the JSON result envelope before exiting `1` — `--recovery` was consulted only in the outer catch block, so the flag silently did nothing for the most common kind of write failure. Both failure paths now emit the recovery envelope, and a caller parsing stdout gets exactly one JSON document either way. Automation that parsed the result envelope out of a failed `--recovery` run must read the recovery envelope instead; automation that does not pass `--recovery` is unaffected (#70).
21
+
22
+ ### Added
23
+
24
+ - **`insert`, `update` and `delete` say what happened in prose when a person is watching.** These commands declared `--format` but never branched on it, so a human running one by hand got a JSON envelope and nothing else. In an interactive terminal they now print the affected row count and table, the elapsed time for work that actually ran, and — for a write that changed rows — the fact that dbcli has no automatic undo for it. Redirected or piped stdout is byte-for-byte the envelope it always was, and `--format json` keeps the envelope in a terminal too; `--format text` is deliberately not treated as a request for prose, since it is the flag's default and therefore appears on invocations that asked for nothing. Ceremony strings live in `resources/lang/{en,zh-TW}/ceremony.json`, following the precedent set by the shell strings rather than being merged into the general message file (#70).
25
+
26
+ - **A write that fails in a terminal now says so in prose too, on stderr.** Prose covered the endings a person cares least about — a successful write, a cancellation, a dry run — while the two that actually stop you got a raw JSON envelope on stdout: a blacklist refusal and a validation failure (a malformed `--set`, a missing `--where`). Both now go through `printMutationFailure`, which prints the reason as a sentence when somebody is watching and the same envelope, byte for byte, when nobody is. Human-mode failures — including the executor's own `status: "error"` — write to **stderr**, joining the `PermissionError` and `ConnectionError` branches that always did, so every human-facing failure is on the error stream while routine progress stays on stdout. A blacklist refusal gets a hint naming `dbcli blacklist list`; nothing else gets the `--recovery` line, because `--recovery` writes a plan for a statement that failed against the database and a refusal at this stage never reached one (#70).
27
+
28
+ - **Core modules can no longer write to stdout, and CI fails if one starts.** `DataExecutor` printed the generated SQL and blocked on `promptUser.confirm` from inside `src/core`, on the same stdout that carries the JSON envelope agents parse — and `dist/core.mjs` shipped a real `import("@inquirer/prompts")` so that a library consumer could be handed an interactive prompt it never asked for. Core now describes the pending mutation and asks the caller through a `confirm` callback; `src/commands/mutation-confirm.ts` holds the CLI's implementation. `scripts/check-core-no-stdout.ts` enforces the boundary in CI, with the 16 modules that predate the rule in a ratchet list that can only shrink. The reasoning, its alternatives, and the condition that would falsify it are recorded in `docs/adr/0009-core-does-not-write-to-stdout.md` (#70).
29
+
30
+ ### Fixed
31
+
32
+ - **Deleting an Elasticsearch index needed `data-admin`, and three schema changes needed nothing but `query-only`.** The classifier matched paths without looking at the method, so one function mis-tiered four different requests. `DELETE /users` removes an entire index and was classified `DELETE` — the delete-a-document tier — which meant `data-admin` could destroy an index while the SQL equivalent, `DROP TABLE`, has always required `admin`; `DELETE /logs-*` and `DELETE /_all` went the same way, at cluster scale. Worse, `DELETE /users/_alias/a` matched the `_alias` **read** rule and `PUT /users/_mapping` and `PUT /users/_settings` matched theirs, so a `query-only` credential could delete an alias and rewrite a mapping. A DELETE is now the `DELETE` tier only when it names a document (`_doc/<id>` or `_source/<id>`) and `DROP` — `admin` — otherwise, and a read rule requires a read method, with `_search` and `_count` also accepting `POST` because that is how a query with a body is sent. Anything whose scope cannot be established fails closed to `admin`: the cost of being wrong that way is a refusal a user can escalate, rather than an index nobody can get back. The adapter's own calls are unaffected — it reads mappings with `GET` and deletes documents through `_doc/<id>` — so this changes only what a raw request through `query` may do. `tests/unit/core/elasticsearch-destructive-scope.test.ts` pins all seventeen cases as a matrix of classification and tier (#70).
33
+
34
+ - **The last four integration files that never ran anywhere now run everywhere.** `p1-error-classification`, `p2-explain` and `p3-missing-index` were gated on `TEST_MARIADB_HOST` — an operator-supplied MariaDB that `docker-compose.test.yml` did not provide, so 23 assertions about MariaDB error codes, `ANALYZE SELECT`, and EXPLAIN output shape skipped on every machine and every CI run since they were written. The compose file gained a `mariadb:11` service on port 3308 (its own service, not a MySQL alias: the codes and EXPLAIN shape are precisely what those tests distinguish), and the three files now gate on reachability like everything else. `q-live` was gated the same way on `DBCLI_LIVE_PG_HOST`, and wanted nothing more specific than a real PostgreSQL, which the stack already ships. Running it for the first time falsified one of its assertions: it expected `SELECT :v AS x` to return the number `1`, and PostgreSQL types an untyped parameter as text — `EXPLAIN VERBOSE` shows `'1'::text` — so the value is the string `"1"`. The assertion was wrong from the day it was written and no run had ever said so. `tests/integration` now reports 652 passing and **0 skipped** (#70).
35
+
36
+ - **`q`, `query` and the Elasticsearch path told users to grant a level that would not have helped.** `PermissionError.requiredPermission` is what every command interpolates into `Permission denied (required: …)`, and these three throw sites passed the level the caller already had — so a query-only user running `DELETE` through `q` read `required: query-only` above a sentence saying it needs data-admin. The structured-write path was corrected first (#72) by deriving the level at the throw site, which works only where the type alone decides it; a write hidden inside a read needs admin whatever its leading keyword says, a multi-statement SQL needs admin, and an unrecognised statement needs read-write. The level is now decided by the branch that decides the refusal and carried on `PermissionCheckResult.requiredPermission`, so the header and the reason cannot disagree — `tests/unit/core/permission-refusal-level.test.ts` asserts that granting exactly what a refusal names lets the same statement through, rather than pinning a table of strings. Elasticsearch stopped restating the tier table while it was being fixed: it was a third copy, agreeing with the shared one on every type its classifier produces, and its refusal now names the level instead of saying "requires higher permission tier", which is true of every refusal and tells nobody what to change (`errors.elasticsearch_requires_level`, both languages) (#70).
37
+
38
+ - **The integration tests never ran in CI, and several of them never ran anywhere.** The matrix job sets `SKIP_INTEGRATION_TESTS=true`, so `tests/integration` was compiled and skipped on every push — 639 assertions against MySQL, PostgreSQL, Redis, MongoDB and Elasticsearch that only ever executed on a developer's machine. A new `integration` job runs them on Linux against `docker-compose.test.yml`. The part that makes it a signal rather than a formality: `REQUIRE_INTEGRATION_SERVICES=true` turns the suite's auto-skip into a failure naming the address, because a job that starts no services otherwise reports exactly the same green as one that starts all of them, and `bun run services:check` fails first with a readable list, reading the ports out of the compose file rather than a copy that would drift. Turning that flag on immediately found the second half of the bug: `verify-migration`, `verify-rollback`, `verify-safe-backfill` and `assert-verification-artifact` dialled `localhost:5432` as `postgres/postgres` — a server this repo has never shipped — while the adapter tests used `PG_PORT` at `5433` as `dbcli/testpass`, the one in the compose file. Two spellings for one address, and the files using the wrong one had been skipping since they were written, locally included. Connection defaults now live once in `tests/integration/helpers.ts`. (#70).
39
+
40
+ - **`insert` / `update` / `delete` accepted any `--format` value and quietly did something else with it.** The flag is declared as `text or json`, but nothing checked, and `shouldRenderForHuman` only special-cases `json` — so `--format xml` meant "prose in a terminal, envelope in a pipe", which is the default it was trying to override. An unsupported value is now refused before the connection, the schema read, and the audit write, the way `dbcli export` has always validated its own formats, with the offending value named and localised (`errors.invalid_output_format`). `--plan` is covered by the same guard (#70).
41
+
42
+ - **Four refusals reached a zh-TW user as English, or as English glued to Chinese.** The three sentences `permission-guard` builds for the cases the tier table cannot phrase — a write hidden inside a read, a multi-statement SQL below admin, and an unrecognised statement under query-only — were string literals, and `handleMutationError` prefixed the already-translated `PermissionError` message with a literal `Permission denied: `, producing a half-translated sentence. All four are catalogue keys now (`errors.escalated_write_requires_admin`, `errors.multiple_statements_refused`, `errors.unknown_statement_query_only`, `errors.permission_denied_reason`), with the permission levels and SQL keywords interpolated verbatim because those are values a user types into a config file. The English is character-for-character what it was — several tests assert these sentences and none of them changed — and `tests/unit/i18n/permission-refusal-messages.test.ts` renders each in both languages and checks key and placeholder parity across the two `messages.json` files, so an English string added without its translation fails (#70).
43
+
44
+ - **An Elasticsearch `insert` / `update` / `delete` answered every user in Traditional Chinese.** The "Elasticsearch does not support this command" sentence was a string literal in the `error` field of the envelope, in three files where everything else goes through `t()`. It is now `{insert,update,delete}.elasticsearch_unsupported` with English and zh-TW values, so the message follows `DBCLI_LANG` like the rest of the CLI. `tests/unit/commands/redis-es-unsupported.test.ts` asserts the English text under the default locale, which is what an English-locale user was never getting (#70).
45
+
46
+ - **The three write paths' permission checks agreed by luck, and their refusal messages named levels that would not have worked.** `executeInsert` and `executeUpdate` handed the classifier a synthetic statement (`'INSERT INTO dummy'`) while `executeDelete` compared `this.permission` inline — one axis, two implementations, certain to drift. All three now call `enforcePermissionForType`, which skips the classifier because the caller assembled the statement and therefore already knows its type; passing the *real* generated SQL was tried and rejected, since it forces the statement to be built and its columns validated before the caller is known to be authorised, so an unauthorised user would learn `Column not found` first and the schema would leak. Separately, `handleMutationError` discarded the `PermissionError` and substituted a fixed sentence about query-only mode whatever the actual level was, and excluded `delete` outright. Refusals are now derived from the same tier table the decision uses, so they name the lowest level that actually permits the operation alongside the current one — previously a query-only user was told `DELETE` "requires read-write", which read-write does not grant. The header printed above that reason was wrong in the same way and is fixed with it: `PermissionError.requiredPermission` is what every command interpolates into `Permission denied (required: …)`, and the SQL path passed the level the caller already had, so a query-only user read `required: query-only` directly above a sentence saying INSERT requires read-write. It now carries the level that would actually work, which is what the Redis enforcer has always passed and what `delete` used to hardcode. `tests/unit/core/permission-refusal-level.test.ts` asserts the named level really permits the operation rather than pinning a table of strings. `q` and the Elasticsearch enforcer still pass the current level; those refusals cover composite and hidden-write cases where "the level that would work" is not always a single answer, and they are left for a change that can decide it. The verdict matrix itself is unchanged: `tests/unit/core/data-executor-permission-characterisation.test.ts` pinned twelve verdicts before the unification landed, and only four messages moved. `permission-guard.ts` was 1187 lines by the end of this and is now 460: SQL lexical analysis, Redis, and Elasticsearch are three independent classification domains and moved to `src/core/permission/{sql-analysis,redis,elasticsearch}.ts` with no re-export shim — the six importers point at the new paths, and none of the moved symbols was on the published `./core` surface. The three tier branches inside `checkPermissionForClassification` also stopped repeating what `TIER_GRANTS` already says: each tier's permitted set is derived by accumulating the tiers below it, which is the same drift-by-duplication this bullet removed from the refusal messages (#70).
47
+
48
+ ## [1.57.0] - 2026-08-14 - One connection's password, rotated on its own
49
+
50
+ ### Added
51
+
52
+ - **`dbcli password [connection]` — rotating one connection's password no longer means editing the rest of its config.** A connection whose password is rotated on a schedule previously had two options: re-run `init` and re-enter every other field, or hand-edit the env file and hope the key name matched what the reader looks for. The new command changes the password and nothing else. Where the value lands is read from the config rather than derived from a naming rule: `password: { "$env": "NAME" }` rewrites `NAME` in that connection's `envFile`, and a connection still holding a literal password is converted to `{ "$env": "DBCLI_<CONN>_PASSWORD" }` once, so every later rotation touches only the env file. v1 configs rewrite `DBCLI_PASSWORD` in `.env.local`, matching the v1 reader at `src/core/config.ts:200`; a v1 config whose password comes from any other environment variable is refused with the reason, because v1 has no per-connection env file and no file dbcli could write would make that variable resolve. The new password is verified by connecting with it **before** anything is written — a rejected credential exits 1 with the stored value untouched, rather than leaving a config that no longer opens the database — with `--skip-test` for when the database is unreachable from where the rotation runs. Three input paths, one of which must be chosen: a masked prompt (never a plain-text fallback, since that would print the secret into the scrollback), `--stdin` for rotation scripts, and `--password` for callers that accept shell-history exposure. The env file is written `0600` on POSIX (Windows has no equivalent mode bit, so the file inherits the directory's ACL), the value never reaches stdout, stderr, or the audit log, and `DBCLI_AGENT_MODE=1` refuses at the first line of the action rather than after prompting and connecting.
53
+
54
+ ### Fixed
55
+
56
+ - **A password written to a connection that declared no `envFile` could not be read back.** `loadConnectionEnv` (`src/core/config-v2.ts:164`) loads only the file a connection names, and the `.env.local` fallback in `src/core/config.ts:274` recognizes exactly one key, v1's `DBCLI_PASSWORD`. So writing `DBCLI_<CONN>_PASSWORD` into `.env.local` for a v2 connection with no `envFile` produced a config whose `$env` reference resolved to nothing: `Environment variable not defined`, on every subsequent command. Rotation now records `envFile` on the connection as part of the write. `tests/unit/core/connection-credential.test.ts` asserts the round trip through `configModule.read()` for all four paths (v2 with and without `envFile`, v2 converted from a literal, v1) rather than asserting the file merely contains the expected line — the file being right while the reader cannot see it is precisely the failure that shipped otherwise.
57
+
58
+ - **A password containing `$&`, `$1`, or `` $` `` was silently stored as something else.** The in-place rewrite passed the new line as `String.replace`'s second argument, where `$`-patterns are substitution syntax: rotating to `a$&b` over an existing `K=old` wrote `K=aK=oldb`. Verification passed because it used the in-memory value, so the command reported success and the next connection attempt failed with a password nobody could reproduce. The replacement is a function now, which does no `$` expansion. Values are also written quoted (`NAME="…"`), because both env parsers trim the whole line before splitting — an unquoted value silently lost leading and trailing whitespace. `parseEnvPassword` strips one layer of matching quotes to match `parseEnvContent`, so values written by either path read back identically.
59
+
60
+ ### Removed
61
+
62
+ - **`writeConnectionSecret` — the exported helper wrote to a file the reader never opens.** Exported from `@carllee1983/dbcli/core`, it derived the env var name from the connection name and defaulted the file to `.env.<connection>` when a connection declared no `envFile`, while the reader falls back to `.env.local` and, for connections created with `init --use-env-refs --env-password <VAR>`, looks up `<VAR>` rather than the derived name. Both mismatches ended the same way: a write that succeeded and a password that could not be read. `setConnectionPassword` and `resolvePasswordTarget` replace it on the same barrel — they resolve the target from the config, convert a literal password once, and record `envFile` when it is missing. Callers of the old function should switch to `setConnectionPassword(projectPath, connectionName, value)`; the `field` parameter is gone, since `'password'` was its only accepted value.
63
+
8
64
  ## [1.56.0] - 2026-08-13 - The package no longer runs the CLI when you import it
9
65
 
10
66
  ### Removed
package/assets/SKILL.md CHANGED
@@ -310,6 +310,19 @@ dbcli init --rename staging:stg # rename
310
310
  dbcli init --remove stg # remove
311
311
  ```
312
312
 
313
+ Rotating one connection's password — nothing else in the config moves:
314
+
315
+ ```bash
316
+ dbcli password prod # masked prompt
317
+ rotate-secret | dbcli password prod --stdin # for scheduled rotation scripts
318
+ ```
319
+
320
+ The value goes to the env var the config actually references (a literal password
321
+ is converted to `{ "$env": ... }` on first use, and a connection with no
322
+ `envFile` gets one recorded so the reader loads it), is verified by connecting
323
+ before it is saved (`--skip-test` to opt out), and the env file is written
324
+ `0600` on POSIX.
325
+
313
326
  For a connection shared across projects, use the explicit root-level `--global` scope. It stores a v2 registry at `~/.config/dbcli/config.json`; it does not create or modify a project binding:
314
327
 
315
328
  ```bash
@@ -436,8 +449,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
436
449
  (no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
437
450
  (`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
438
451
  - `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
439
- and `rows_affected: 0`; proceed once `status:"success"` and the SQL shape matches the
440
- intended `--where` / `--set`. MongoDB prints a shell-style preview.
452
+ and `status:"dry_run"` with `rows_affected: 0` never `success`, which now means the
453
+ write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
454
+ Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
455
+ MongoDB prints a shell-style preview.
456
+ - `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
457
+ first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
458
+ unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
441
459
  - `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
442
460
  after a failure); optional for one-off manual writes.
443
461
 
@@ -246,6 +246,17 @@ dbcli init --rename staging:stg # rename
246
246
  dbcli init --remove stg # remove
247
247
  ```
248
248
 
249
+ 只輪替單一連線的密碼,其餘設定不動:
250
+
251
+ ```bash
252
+ dbcli password prod # 遮蔽輸入
253
+ rotate-secret | dbcli password prod --stdin # 供排程輪替腳本使用
254
+ ```
255
+
256
+ 新密碼會寫進 config 實際參照的 env 變數(明文密碼會在第一次使用時轉成
257
+ `{ "$env": ... }`;連線沒宣告 `envFile` 時會一併補記錄,讀取端才載得到),
258
+ 存檔前先用它連一次驗證(`--skip-test` 可跳過),env 檔在 POSIX 上以 0600 權限寫入。
259
+
249
260
  若要讓多個專案共用連線,請使用明確的 root-level `--global` scope。它會把 v2 registry 儲存在 `~/.config/dbcli/config.json`,不會建立或修改專案 binding:
250
261
 
251
262
  ```bash
@@ -340,7 +351,10 @@ dbcli init --conn-name prod --env-file .env.production --use-env-refs --skip-tes
340
351
 
341
352
  - `--set`(update)/ `--data`(insert)接受 **JSON 物件字串**,而非 SQL 片段:`dbcli update users --where "id=42" --set '{"email":"new@example.com"}'`。MongoDB 中,不含 `$` 運算子的 JSON 會自動包裝為 `$set`;明確傳入的運算子則直接傳遞。`insert --data` 也可從 stdin 讀取物件。
342
353
  - `--where`(SQL)僅接受 `col=val` 或 `col1=val1 AND col2=val2` — **不**支援完整 SQL(不支援 `>=`、`!=`、`LIKE`、`OR`)。MongoDB 的 `--where` 接受完整 JSON filter(`'{"status":"pending"}'`),若不是合法 JSON 則 fallback 為 `col=val`。
343
- - `--dry-run` 輸出參數化 SQL(使用 `$1` / `?` 佔位符,非真實值)與 `rows_affected: 0`;確認 `status:"success"` SQL 形狀符合預期的 `--where` / `--set` 後再執行。MongoDB 輸出 shell 風格預覽。
354
+ - `--dry-run` 輸出參數化 SQL(使用 `$1` / `?` 佔位符,非真實值),並回報 `status:"dry_run"` 與 `rows_affected: 0`——絕不會是 `success`——後者現在代表寫入真的執行了。確認 SQL 形狀符合預期的 `--where` / `--set` 後再執行。在確認提示回答否會得到 `status:"cancelled"`,同樣不是 `success`。MongoDB 輸出 shell 風格預覽。
355
+ - `--force` 會跳過確認提示。每一個 `insert` / `update` / `delete` 都會先詢問——SQL、
356
+ MongoDB、Redis 都一樣——而非互動式執行無法回答,因此沒有帶 `--force` 的無人值守寫入
357
+ 會以 `status:"cancelled"` 結束,什麼都沒改。
344
358
  - `--recovery` 建議用於自動化 agent pipeline(讓失敗後可執行 `dbcli recover --apply`);手動一次性寫入可選用。
345
359
 
346
360
  ## 查詢工作流程旗標 (Query workflow flags)
@@ -22,6 +22,7 @@ never the right move.
22
22
  **Commands** —
23
23
  [init](#init) ·
24
24
  [use](#use) ·
25
+ [password](#password) ·
25
26
  [list](#list) ·
26
27
  [schema](#schema) ·
27
28
  [query](#query) ·
@@ -196,6 +197,46 @@ dbcli list --use prod
196
197
 
197
198
  **Options:** `--list`, `--format <text|json>`, `--confirm-production <name>` (required when explicitly selecting a production connection as the default).
198
199
 
200
+ ### password
201
+
202
+ Change one connection's password without touching any other setting — built for
203
+ environments where credentials rotate on a schedule.
204
+
205
+ ```bash
206
+ dbcli password # Masked prompt, rotates the default connection
207
+ dbcli password prod # Masked prompt, rotates 'prod'
208
+ rotate-secret | dbcli password prod --stdin # Non-interactive, nothing lands in shell history
209
+ dbcli password prod --password "$NEW" --skip-test --format json
210
+ ```
211
+
212
+ Where the value lands is read from the config, never guessed: a connection whose
213
+ `password` is `{ "$env": "NAME" }` gets `NAME` rewritten in its `envFile`. A
214
+ connection that declares no `envFile` has one recorded (`.env.local`) as part of
215
+ the rotation — without it the reader would never load the file. A connection
216
+ still holding a literal password is converted to
217
+ `{ "$env": "DBCLI_<CONN>_PASSWORD" }` once, so later rotations only touch the env
218
+ file. Values are written quoted (`NAME="..."`), so leading and trailing
219
+ whitespace survives the round trip.
220
+
221
+ v1 configs rewrite `DBCLI_PASSWORD` in `.env.local`, matching the v1 reader. A v1
222
+ config whose password comes from some other environment variable is refused with
223
+ an explanation: v1 has no per-connection env file, so no file dbcli writes could
224
+ make that variable resolve — set it in the environment, or migrate to v2.
225
+
226
+ The new password is verified by connecting with it before anything is written,
227
+ so a bad rotation fails without leaving broken credentials behind. Pass
228
+ `--skip-test` when the database is unreachable from where the command runs. The
229
+ env file is written with `0600` permissions on POSIX systems (Windows has no
230
+ equivalent mode bit — the file inherits the directory's ACL), and the value is
231
+ never echoed or
232
+ logged.
233
+
234
+ **Options:** `[connection]`, `--stdin`, `--password <value>` (visible in shell
235
+ history and the process list — prefer `--stdin`), `--skip-test`,
236
+ `--format <text|json>`.
237
+
238
+ Blocked under `DBCLI_AGENT_MODE=1` like every other credential mutation.
239
+
199
240
  ### Agent configuration trust boundary
200
241
 
201
242
  When `DBCLI_AGENT_MODE=1`, configuration, permission, and credential mutations
@@ -296,6 +337,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
296
337
  **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
297
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
298
339
 
340
+ > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
+ > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
+ > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
343
+ > reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
344
+ > templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
345
+ > scope cannot be established is treated as admin rather than guessed downward.
346
+
299
347
  > **Server-side scripts are rejected on every path.** MongoDB `$where`,
300
348
  > `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
301
349
  > execute code on the database server. The adapters reject them anywhere in a
@@ -884,7 +932,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
884
932
  dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
885
933
  ```
886
934
 
887
- **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
935
+ **Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
888
936
  **Permission:** read-write+
889
937
 
890
938
  ### update
@@ -897,7 +945,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
897
945
  dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
898
946
  ```
899
947
 
900
- **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
948
+ **Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
901
949
  **Permission:** read-write+
902
950
 
903
951
  > **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
@@ -918,7 +966,7 @@ dbcli delete users --where "id=1" --force
918
966
  dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
919
967
  ```
920
968
 
921
- **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
969
+ **Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output; `json` also keeps the result envelope instead of prose in a terminal), `--recovery`
922
970
  **Permission:** data-admin+
923
971
 
924
972
  ### export
@@ -2499,6 +2547,8 @@ dbcli migrate add-enum status active inactive suspended
2499
2547
  dbcli migrate alter-enum status --add-value archived
2500
2548
  dbcli migrate drop-enum status --execute --force
2501
2549
  ```
2550
+ A destructive `migrate` action (`drop`, `drop-column`, `drop-index`, `drop-enum`) asks for confirmation on stderr before it runs, and reports `status: "cancelled"` if you decline — not `success`, which it used to claim with the cancellation buried in `warnings`. `--force` skips the question; a non-interactive run that omits it cannot answer and therefore cancels.
2551
+
2502
2552
 
2503
2553
  **Column spec format:** `name:type[:modifier[:modifier...]]`
2504
2554
  - Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`