@carllee1983/dbcli 1.57.0 → 2.0.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.
- package/.cursor/rules/dbcli.mdc +19 -2
- package/.cursor/skills/dbcli/reference.md +55 -4
- package/.github/skills/dbcli/SKILL.md +19 -2
- package/.github/skills/dbcli/reference.md +55 -4
- package/CHANGELOG.md +71 -0
- package/assets/SKILL.md +19 -2
- package/assets/SKILL.zh-TW.md +15 -1
- package/assets/reference.md +55 -4
- package/dist/cli-runtime.mjs +1323 -688
- package/dist/cli.mjs +4 -2
- package/dist/core.d.ts +79 -24
- package/dist/core.mjs +349 -343
- package/package.json +4 -2
- package/plugins/dbcli-agent/skills/dbcli/SKILL.md +19 -2
- package/plugins/dbcli-agent/skills/dbcli/reference.md +55 -4
- package/skills/dbcli/SKILL.md +19 -2
- package/skills/dbcli/reference.md +55 -4
package/.cursor/rules/dbcli.mdc
CHANGED
|
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
|
|
|
43
43
|
`update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
|
|
44
44
|
takes a full JSON filter and is exempt.)
|
|
45
45
|
|
|
46
|
+
**Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
|
|
47
|
+
tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
|
|
48
|
+
run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
|
|
49
|
+
**Statements that are not limited to specific rows are refused outright when nobody can
|
|
50
|
+
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
|
+
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
|
+
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
+
`reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
|
|
54
|
+
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
|
+
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
|
+
unattended route at all; escalate to a human.
|
|
57
|
+
|
|
46
58
|
> `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
|
|
47
59
|
> `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
|
|
48
60
|
> audit-recent context or to diagnose a connection problem.
|
|
@@ -449,8 +461,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
|
|
|
449
461
|
(no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
|
|
450
462
|
(`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
|
|
451
463
|
- `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
|
|
452
|
-
and `rows_affected: 0
|
|
453
|
-
intended `--where` / `--set`.
|
|
464
|
+
and `status:"dry_run"` with `rows_affected: 0` — never `success`, which now means the
|
|
465
|
+
write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
|
|
466
|
+
Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
|
|
467
|
+
MongoDB prints a shell-style preview.
|
|
468
|
+
- `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
|
|
469
|
+
first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
|
|
470
|
+
unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
|
|
454
471
|
- `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
|
|
455
472
|
after a failure); optional for one-off manual writes.
|
|
456
473
|
|
|
@@ -334,9 +334,50 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
|
|
|
334
334
|
dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
|
|
335
335
|
```
|
|
336
336
|
|
|
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`
|
|
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>`, `--yes`, `--recovery`
|
|
338
338
|
**Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
|
|
339
339
|
|
|
340
|
+
#### Write confirmation gate (2.0.0)
|
|
341
|
+
|
|
342
|
+
A SQL write passes through a two-tier gate before the connection is opened. The gate is
|
|
343
|
+
separate from the permission axis: permission says what the connection may do, the gate
|
|
344
|
+
says whether this particular statement may run right now.
|
|
345
|
+
|
|
346
|
+
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
|
+
| :--- | :--- | :--- | :--- |
|
|
348
|
+
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
|
+
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
353
|
+
connection failure or a permission denial.
|
|
354
|
+
|
|
355
|
+
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
|
|
359
|
+
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
|
+
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
|
+
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
365
|
+
`WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
|
|
366
|
+
statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
|
|
367
|
+
and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
|
|
368
|
+
whether they are possible at all, and the typed confirmation must come from a person.
|
|
369
|
+
|
|
370
|
+
Every tier-two evaluation is written to the audit log with
|
|
371
|
+
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
+
`metadata.write_gate_reason`.
|
|
373
|
+
|
|
374
|
+
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
|
+
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
376
|
+
> `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
|
|
377
|
+
> reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
|
|
378
|
+
> templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
|
|
379
|
+
> scope cannot be established is treated as admin rather than guessed downward.
|
|
380
|
+
|
|
340
381
|
> **Server-side scripts are rejected on every path.** MongoDB `$where`,
|
|
341
382
|
> `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
|
|
342
383
|
> execute code on the database server. The adapters reject them anywhere in a
|
|
@@ -925,7 +966,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
|
|
|
925
966
|
dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
|
|
926
967
|
```
|
|
927
968
|
|
|
928
|
-
**Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
969
|
+
**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`
|
|
929
970
|
**Permission:** read-write+
|
|
930
971
|
|
|
931
972
|
### update
|
|
@@ -938,7 +979,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
|
|
|
938
979
|
dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
|
|
939
980
|
```
|
|
940
981
|
|
|
941
|
-
**Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
982
|
+
**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`
|
|
942
983
|
**Permission:** read-write+
|
|
943
984
|
|
|
944
985
|
> **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
|
|
@@ -948,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
|
|
|
948
989
|
> target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
|
|
949
990
|
> (MongoDB `--where` accepts a full JSON filter and is exempt.)
|
|
950
991
|
|
|
992
|
+
> **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
|
|
993
|
+
> and no unique index selects an unknown number of rows, so it is treated the same as a
|
|
994
|
+
> raw statement with no `WHERE`: the target table name must be typed at an interactive
|
|
995
|
+
> terminal, and a non-interactive run is refused with exit `1` and
|
|
996
|
+
> `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
|
|
997
|
+
> the ordinary confirmation only. Select the primary keys first and write one row at a
|
|
998
|
+
> time, or run it where a person can confirm it.
|
|
999
|
+
|
|
951
1000
|
### delete
|
|
952
1001
|
|
|
953
1002
|
Delete data from a table.
|
|
@@ -959,7 +1008,7 @@ dbcli delete users --where "id=1" --force
|
|
|
959
1008
|
dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
|
|
960
1009
|
```
|
|
961
1010
|
|
|
962
|
-
**Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
1011
|
+
**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`
|
|
963
1012
|
**Permission:** data-admin+
|
|
964
1013
|
|
|
965
1014
|
### export
|
|
@@ -2540,6 +2589,8 @@ dbcli migrate add-enum status active inactive suspended
|
|
|
2540
2589
|
dbcli migrate alter-enum status --add-value archived
|
|
2541
2590
|
dbcli migrate drop-enum status --execute --force
|
|
2542
2591
|
```
|
|
2592
|
+
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.
|
|
2593
|
+
|
|
2543
2594
|
|
|
2544
2595
|
**Column spec format:** `name:type[:modifier[:modifier...]]`
|
|
2545
2596
|
- Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`
|
|
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
|
|
|
43
43
|
`update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
|
|
44
44
|
takes a full JSON filter and is exempt.)
|
|
45
45
|
|
|
46
|
+
**Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
|
|
47
|
+
tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
|
|
48
|
+
run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
|
|
49
|
+
**Statements that are not limited to specific rows are refused outright when nobody can
|
|
50
|
+
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
|
+
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
|
+
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
+
`reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
|
|
54
|
+
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
|
+
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
|
+
unattended route at all; escalate to a human.
|
|
57
|
+
|
|
46
58
|
> `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
|
|
47
59
|
> `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
|
|
48
60
|
> audit-recent context or to diagnose a connection problem.
|
|
@@ -449,8 +461,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
|
|
|
449
461
|
(no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
|
|
450
462
|
(`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
|
|
451
463
|
- `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
|
|
452
|
-
and `rows_affected: 0
|
|
453
|
-
intended `--where` / `--set`.
|
|
464
|
+
and `status:"dry_run"` with `rows_affected: 0` — never `success`, which now means the
|
|
465
|
+
write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
|
|
466
|
+
Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
|
|
467
|
+
MongoDB prints a shell-style preview.
|
|
468
|
+
- `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
|
|
469
|
+
first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
|
|
470
|
+
unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
|
|
454
471
|
- `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
|
|
455
472
|
after a failure); optional for one-off manual writes.
|
|
456
473
|
|
|
@@ -334,9 +334,50 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
|
|
|
334
334
|
dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
|
|
335
335
|
```
|
|
336
336
|
|
|
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`
|
|
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>`, `--yes`, `--recovery`
|
|
338
338
|
**Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
|
|
339
339
|
|
|
340
|
+
#### Write confirmation gate (2.0.0)
|
|
341
|
+
|
|
342
|
+
A SQL write passes through a two-tier gate before the connection is opened. The gate is
|
|
343
|
+
separate from the permission axis: permission says what the connection may do, the gate
|
|
344
|
+
says whether this particular statement may run right now.
|
|
345
|
+
|
|
346
|
+
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
|
+
| :--- | :--- | :--- | :--- |
|
|
348
|
+
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
|
+
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
353
|
+
connection failure or a permission denial.
|
|
354
|
+
|
|
355
|
+
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
|
|
359
|
+
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
|
+
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
|
+
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
365
|
+
`WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
|
|
366
|
+
statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
|
|
367
|
+
and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
|
|
368
|
+
whether they are possible at all, and the typed confirmation must come from a person.
|
|
369
|
+
|
|
370
|
+
Every tier-two evaluation is written to the audit log with
|
|
371
|
+
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
+
`metadata.write_gate_reason`.
|
|
373
|
+
|
|
374
|
+
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
|
+
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
376
|
+
> `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
|
|
377
|
+
> reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
|
|
378
|
+
> templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
|
|
379
|
+
> scope cannot be established is treated as admin rather than guessed downward.
|
|
380
|
+
|
|
340
381
|
> **Server-side scripts are rejected on every path.** MongoDB `$where`,
|
|
341
382
|
> `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
|
|
342
383
|
> execute code on the database server. The adapters reject them anywhere in a
|
|
@@ -925,7 +966,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
|
|
|
925
966
|
dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
|
|
926
967
|
```
|
|
927
968
|
|
|
928
|
-
**Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
969
|
+
**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`
|
|
929
970
|
**Permission:** read-write+
|
|
930
971
|
|
|
931
972
|
### update
|
|
@@ -938,7 +979,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
|
|
|
938
979
|
dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
|
|
939
980
|
```
|
|
940
981
|
|
|
941
|
-
**Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
982
|
+
**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`
|
|
942
983
|
**Permission:** read-write+
|
|
943
984
|
|
|
944
985
|
> **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
|
|
@@ -948,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
|
|
|
948
989
|
> target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
|
|
949
990
|
> (MongoDB `--where` accepts a full JSON filter and is exempt.)
|
|
950
991
|
|
|
992
|
+
> **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
|
|
993
|
+
> and no unique index selects an unknown number of rows, so it is treated the same as a
|
|
994
|
+
> raw statement with no `WHERE`: the target table name must be typed at an interactive
|
|
995
|
+
> terminal, and a non-interactive run is refused with exit `1` and
|
|
996
|
+
> `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
|
|
997
|
+
> the ordinary confirmation only. Select the primary keys first and write one row at a
|
|
998
|
+
> time, or run it where a person can confirm it.
|
|
999
|
+
|
|
951
1000
|
### delete
|
|
952
1001
|
|
|
953
1002
|
Delete data from a table.
|
|
@@ -959,7 +1008,7 @@ dbcli delete users --where "id=1" --force
|
|
|
959
1008
|
dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
|
|
960
1009
|
```
|
|
961
1010
|
|
|
962
|
-
**Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
1011
|
+
**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`
|
|
963
1012
|
**Permission:** data-admin+
|
|
964
1013
|
|
|
965
1014
|
### export
|
|
@@ -2540,6 +2589,8 @@ dbcli migrate add-enum status active inactive suspended
|
|
|
2540
2589
|
dbcli migrate alter-enum status --add-value archived
|
|
2541
2590
|
dbcli migrate drop-enum status --execute --force
|
|
2542
2591
|
```
|
|
2592
|
+
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.
|
|
2593
|
+
|
|
2543
2594
|
|
|
2544
2595
|
**Column spec format:** `name:type[:modifier[:modifier...]]`
|
|
2545
2596
|
- Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,77 @@ 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
|
+
## [2.0.0] - 2026-08-14 - A write nobody can confirm does not run
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **BREAKING: a statement that is not limited to particular rows is refused when nobody is watching.** `dbcli query "UPDATE users SET banned = 1"` used to execute against any read-write connection without asking anything, and the caller most likely to produce an unqualified `UPDATE` is the agent this product exists to serve. Raw SQL now passes through a two-tier gate before the connection is opened. Tier one is any write — an `INSERT`, an `UPDATE` or `DELETE` that has a `WHERE` or `LIMIT`, a `CREATE`, an `ALTER` — and behaves as it always has for a non-interactive caller; at a terminal it shows what dbcli understood the statement to do and asks, which `--yes` skips. Tier two is `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, several statements in one string — one statement to a classifier reading the leading keyword, two to a driver — and any statement the SQL parser cannot read: at a terminal the operator types the target table name, and **no flag skips it** — not `--yes`, not `--force`. Away from a terminal, or under `--format json`, tier two is refused: exit `1`, a `reason=` a caller can branch on (`no_where`, `ddl_destruction`, `unparseable`, `multiple_statements`), and the statement never sent, because the gate runs before the adapter is built rather than after. A parse failure resolves to tier two rather than tier one; the cost of being wrong is a needlessly typed table name against a needlessly emptied table. The reasoning, the alternatives, and the condition that would falsify it are in `docs/adr/0010-unattended-callers-are-refused-full-table-writes.md` (#70).
|
|
13
|
+
|
|
14
|
+
- **BREAKING: `dbcli update` / `dbcli delete` refuse a `--where` that matches on nothing unique.** Their `WHERE` is mandatory, so "no `WHERE`" cannot happen — but `--where "status=active"` reads like a filter and writes like a full-table statement. When the conditions cover neither the primary key nor any unique index, the same tier-two treatment applies: type the table name, or be refused with `reason=non_unique_where`. The schema needed to tell the two apart is already in hand at that point, so this costs no extra round trip. `--force` is unaffected in what it always did — skip the ordinary confirmation — and does not open this gate (#70).
|
|
15
|
+
|
|
16
|
+
- **BREAKING: `admin` permission no longer means "everything runs".** Permission and the gate are separate axes now: permission says what the connection may do, the gate says whether this statement may run right now. An `admin` connection running `DROP TABLE users` from a script is refused, because `DROP` and `TRUNCATE` have no clause to add and therefore no unattended route at all. Whether they are possible in an environment remains a `permission` decision that lives in version control; whether one happens today is a decision a person makes at a terminal (#70).
|
|
17
|
+
|
|
18
|
+
- **The permission check for raw SQL now runs before the connection is opened.** It ran inside `QueryExecutor`, after `connect()`, so a refusal cost a round trip and arrived after the gate had already asked its question. The command layer now calls the same `enforcePermission` with the same real statement before either — a connection that may not run this at all is told so rather than asked to confirm something it was never going to be allowed to do. The executor still checks; this only moves the verdict earlier (#70).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`dbcli query --yes`** skips the tier-one confirmation, so a long sequence of routine writes does not become a sequence of keypresses. It has no effect on tier two by design: a flag that could be set once and forgotten is exactly what the escape route for a full-table write must not be (#70).
|
|
23
|
+
|
|
24
|
+
- **Every tier-two evaluation is written to the audit log — allowed, declined and refused alike** — with `metadata.write_gate_outcome` and `metadata.write_gate_reason`. Deliberately unconditional: a log that kept only the refusals could not tell "nobody writes like that" apart from "everybody found a way around it". In six months, whether this gate prevented anything is a query rather than an impression, and if tier two turns out to be almost never reached, the criterion is wrong rather than the gate unnecessary (#70).
|
|
25
|
+
|
|
26
|
+
### Migration
|
|
27
|
+
|
|
28
|
+
Automation that performs unqualified full-table writes stops working. Three shapes are affected, and each has a fixed remedy:
|
|
29
|
+
|
|
30
|
+
| Invocation | Now | Remedy |
|
|
31
|
+
| :--- | :--- | :--- |
|
|
32
|
+
| `dbcli query "UPDATE t SET c = v"` | exit 1, `reason=no_where` | `dbcli query "UPDATE t SET c = v WHERE 1=1"`, or add a `LIMIT` |
|
|
33
|
+
| `dbcli query "DELETE FROM t"` | exit 1, `reason=no_where` | `dbcli query "DELETE FROM t WHERE 1=1"`, or add a `LIMIT` |
|
|
34
|
+
| `dbcli query "DROP TABLE t"` / `TRUNCATE` | exit 1, `reason=ddl_destruction` | run it at a terminal, or apply the schema change through a reviewed migration |
|
|
35
|
+
| `dbcli update t --where "status=x" …` | exit 1, `reason=non_unique_where` | select the primary keys first, then one write per key — or run it where a person can confirm |
|
|
36
|
+
|
|
37
|
+
`WHERE 1=1` is the supported way to say "yes, every row". It is intentionally not a flag: appended to a statement that already has a `WHERE` it is a syntax error, so it cannot be added blanket-style to a script and forgotten. There is no environment variable that restores the old behaviour — a flag that makes the same version behave differently on different machines makes every later bug report ambiguous, and never gets removed.
|
|
38
|
+
|
|
39
|
+
## [1.58.0] - 2026-08-14 - A write that did not happen stops reporting success
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **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).
|
|
44
|
+
|
|
45
|
+
- **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).
|
|
46
|
+
|
|
47
|
+
- **`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).
|
|
48
|
+
|
|
49
|
+
- **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).
|
|
50
|
+
|
|
51
|
+
- **`--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).
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **`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).
|
|
56
|
+
|
|
57
|
+
- **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).
|
|
58
|
+
|
|
59
|
+
- **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).
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **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).
|
|
64
|
+
|
|
65
|
+
- **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).
|
|
66
|
+
|
|
67
|
+
- **`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).
|
|
68
|
+
|
|
69
|
+
- **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).
|
|
70
|
+
|
|
71
|
+
- **`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).
|
|
72
|
+
|
|
73
|
+
- **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).
|
|
74
|
+
|
|
75
|
+
- **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).
|
|
76
|
+
|
|
77
|
+
- **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).
|
|
78
|
+
|
|
8
79
|
## [1.57.0] - 2026-08-14 - One connection's password, rotated on its own
|
|
9
80
|
|
|
10
81
|
### Added
|
package/assets/SKILL.md
CHANGED
|
@@ -43,6 +43,18 @@ condition, first `query` / `export` the target rows' primary keys, then run one
|
|
|
43
43
|
`update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
|
|
44
44
|
takes a full JSON filter and is exempt.)
|
|
45
45
|
|
|
46
|
+
**Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
|
|
47
|
+
tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
|
|
48
|
+
run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
|
|
49
|
+
**Statements that are not limited to specific rows are refused outright when nobody can
|
|
50
|
+
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
|
+
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
|
+
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
+
`reason=unparseable` or `reason=non_unique_where`, and **nothing reaches the database**.
|
|
54
|
+
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
55
|
+
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
56
|
+
unattended route at all; escalate to a human.
|
|
57
|
+
|
|
46
58
|
> `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
|
|
47
59
|
> `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
|
|
48
60
|
> audit-recent context or to diagnose a connection problem.
|
|
@@ -449,8 +461,13 @@ changing the default. `--recovery` is honoured by `query`, `q`, `insert`, `updat
|
|
|
449
461
|
(no `>=`, `!=`, `LIKE`, `OR`). MongoDB `--where` takes a full JSON filter
|
|
450
462
|
(`'{"status":"pending"}'`), falling back to `col=val` when it is not valid JSON.
|
|
451
463
|
- `--dry-run` prints the parameterized SQL (with `$1` / `?` placeholders, not real values)
|
|
452
|
-
and `rows_affected: 0
|
|
453
|
-
intended `--where` / `--set`.
|
|
464
|
+
and `status:"dry_run"` with `rows_affected: 0` — never `success`, which now means the
|
|
465
|
+
write really ran. Proceed once the SQL shape matches the intended `--where` / `--set`.
|
|
466
|
+
Declining at the confirmation prompt reports `status:"cancelled"`, also not `success`.
|
|
467
|
+
MongoDB prints a shell-style preview.
|
|
468
|
+
- `--force` skips the confirmation prompt. Every `insert` / `update` / `delete` asks
|
|
469
|
+
first — SQL, MongoDB and Redis alike — and a non-interactive run cannot answer, so an
|
|
470
|
+
unattended write without `--force` ends as `status:"cancelled"` having changed nothing.
|
|
454
471
|
- `--recovery` is recommended for automated agent pipelines (enables `dbcli recover --apply`
|
|
455
472
|
after a failure); optional for one-off manual writes.
|
|
456
473
|
|
package/assets/SKILL.zh-TW.md
CHANGED
|
@@ -36,6 +36,17 @@ legacy 單檔 `.dbcli`。若要防護同一 OS 使用者的惡意 process,host
|
|
|
36
36
|
`update` / `delete --where "id=<pk>"`(逐一等式)— 或升級交給人類處理。(MongoDB 的
|
|
37
37
|
`--where` 接受完整 JSON filter,不受此限。)
|
|
38
38
|
|
|
39
|
+
**寫入閘門(2.0.0)— 會直接拒絕你的那條規則。** 所有寫入都會分成兩級。一般寫入
|
|
40
|
+
(`INSERT`、帶 `WHERE` 的 `UPDATE` / `DELETE`、`CREATE`、`ALTER`)在無人看管下照跑,
|
|
41
|
+
與過去相同;`--yes` 用來跳過人類在終端機看到的提問。**沒有限定要動哪些列的語句,在沒有
|
|
42
|
+
人能回答提問時會直接被拒絕** — 沒有 `WHERE` 的 `UPDATE` / `DELETE`、`DROP`、`TRUNCATE`、
|
|
43
|
+
SQL parser 讀不懂的語句、一個字串裡塞了多句語句,以及 `update` / `delete --where` 沒有命中主鍵或唯一索引的情況。
|
|
44
|
+
行程以 `1` 結束,訊息點名 `reason=no_where`、`reason=ddl_destruction`、
|
|
45
|
+
`reason=unparseable` 或 `reason=non_unique_where`,而且**什麼都不會送到資料庫**。
|
|
46
|
+
**沒有任何旗標可以繞過** — `--yes` 不行,`--force` 也不行。真的要寫全表,就把意圖寫進
|
|
47
|
+
SQL 本身:補上 `WHERE 1=1` 或 `LIMIT`。`DROP` / `TRUNCATE` 完全沒有無人看管的路徑,請
|
|
48
|
+
升級交給人類處理。
|
|
49
|
+
|
|
39
50
|
> `report` 與 `guide` 已內嵌 `inspect` 快照 — **不需要**先跑 `dbcli inspect`。只有在需要 audit-recent 脈絡或診斷連線問題時,才手動跑 `dbcli inspect --for-agent`。
|
|
40
51
|
|
|
41
52
|
**依任務路由:**
|
|
@@ -351,7 +362,10 @@ dbcli init --conn-name prod --env-file .env.production --use-env-refs --skip-tes
|
|
|
351
362
|
|
|
352
363
|
- `--set`(update)/ `--data`(insert)接受 **JSON 物件字串**,而非 SQL 片段:`dbcli update users --where "id=42" --set '{"email":"new@example.com"}'`。MongoDB 中,不含 `$` 運算子的 JSON 會自動包裝為 `$set`;明確傳入的運算子則直接傳遞。`insert --data` 也可從 stdin 讀取物件。
|
|
353
364
|
- `--where`(SQL)僅接受 `col=val` 或 `col1=val1 AND col2=val2` — **不**支援完整 SQL(不支援 `>=`、`!=`、`LIKE`、`OR`)。MongoDB 的 `--where` 接受完整 JSON filter(`'{"status":"pending"}'`),若不是合法 JSON 則 fallback 為 `col=val`。
|
|
354
|
-
- `--dry-run` 輸出參數化 SQL(使用 `$1` / `?`
|
|
365
|
+
- `--dry-run` 輸出參數化 SQL(使用 `$1` / `?` 佔位符,非真實值),並回報 `status:"dry_run"` 與 `rows_affected: 0`——絕不會是 `success`——後者現在代表寫入真的執行了。確認 SQL 形狀符合預期的 `--where` / `--set` 後再執行。在確認提示回答否會得到 `status:"cancelled"`,同樣不是 `success`。MongoDB 輸出 shell 風格預覽。
|
|
366
|
+
- `--force` 會跳過確認提示。每一個 `insert` / `update` / `delete` 都會先詢問——SQL、
|
|
367
|
+
MongoDB、Redis 都一樣——而非互動式執行無法回答,因此沒有帶 `--force` 的無人值守寫入
|
|
368
|
+
會以 `status:"cancelled"` 結束,什麼都沒改。
|
|
355
369
|
- `--recovery` 建議用於自動化 agent pipeline(讓失敗後可執行 `dbcli recover --apply`);手動一次性寫入可選用。
|
|
356
370
|
|
|
357
371
|
## 查詢工作流程旗標 (Query workflow flags)
|
package/assets/reference.md
CHANGED
|
@@ -334,9 +334,50 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
|
|
|
334
334
|
dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
|
|
335
335
|
```
|
|
336
336
|
|
|
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`
|
|
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>`, `--yes`, `--recovery`
|
|
338
338
|
**Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
|
|
339
339
|
|
|
340
|
+
#### Write confirmation gate (2.0.0)
|
|
341
|
+
|
|
342
|
+
A SQL write passes through a two-tier gate before the connection is opened. The gate is
|
|
343
|
+
separate from the permission axis: permission says what the connection may do, the gate
|
|
344
|
+
says whether this particular statement may run right now.
|
|
345
|
+
|
|
346
|
+
| Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
|
|
347
|
+
| :--- | :--- | :--- | :--- |
|
|
348
|
+
| One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
|
|
349
|
+
| Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
|
|
350
|
+
|
|
351
|
+
A refusal message names a machine-readable reason — `reason=no_where`,
|
|
352
|
+
`reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements` — so a caller can tell it apart from a
|
|
353
|
+
connection failure or a permission denial.
|
|
354
|
+
|
|
355
|
+
**Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
|
|
359
|
+
dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
|
|
360
|
+
dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
|
|
361
|
+
dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
|
|
365
|
+
`WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
|
|
366
|
+
statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
|
|
367
|
+
and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
|
|
368
|
+
whether they are possible at all, and the typed confirmation must come from a person.
|
|
369
|
+
|
|
370
|
+
Every tier-two evaluation is written to the audit log with
|
|
371
|
+
`metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
|
|
372
|
+
`metadata.write_gate_reason`.
|
|
373
|
+
|
|
374
|
+
> **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
|
|
375
|
+
> `POST _count`) is query-only. Indexing or updating a document is read-write.
|
|
376
|
+
> `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
|
|
377
|
+
> reshapes a container is **admin**: `DELETE /<index>`, a wildcard or `_all` delete,
|
|
378
|
+
> templates and aliases, and `PUT` against `_mapping` or `_settings`. A request whose
|
|
379
|
+
> scope cannot be established is treated as admin rather than guessed downward.
|
|
380
|
+
|
|
340
381
|
> **Server-side scripts are rejected on every path.** MongoDB `$where`,
|
|
341
382
|
> `$function`, and `$accumulator`, and Elasticsearch `script` / `script_fields`,
|
|
342
383
|
> execute code on the database server. The adapters reject them anywhere in a
|
|
@@ -925,7 +966,7 @@ dbcli insert users --data '{"name":"Alice"}' --force
|
|
|
925
966
|
dbcli insert users --data '{"name":"Alice"}' --plan --format json # risk analysis only; no DB connection
|
|
926
967
|
```
|
|
927
968
|
|
|
928
|
-
**Options:** `--data <json>`, `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
969
|
+
**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`
|
|
929
970
|
**Permission:** read-write+
|
|
930
971
|
|
|
931
972
|
### update
|
|
@@ -938,7 +979,7 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --dry-run
|
|
|
938
979
|
dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json # risk analysis only; no DB connection
|
|
939
980
|
```
|
|
940
981
|
|
|
941
|
-
**Options:** `--where <condition>` (required), `--set <json>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
982
|
+
**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`
|
|
942
983
|
**Permission:** read-write+
|
|
943
984
|
|
|
944
985
|
> **`--where` grammar (SQL `update` / `delete`)** — equality only: `col=val` or
|
|
@@ -948,6 +989,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
|
|
|
948
989
|
> target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
|
|
949
990
|
> (MongoDB `--where` accepts a full JSON filter and is exempt.)
|
|
950
991
|
|
|
992
|
+
> **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
|
|
993
|
+
> and no unique index selects an unknown number of rows, so it is treated the same as a
|
|
994
|
+
> raw statement with no `WHERE`: the target table name must be typed at an interactive
|
|
995
|
+
> terminal, and a non-interactive run is refused with exit `1` and
|
|
996
|
+
> `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
|
|
997
|
+
> the ordinary confirmation only. Select the primary keys first and write one row at a
|
|
998
|
+
> time, or run it where a person can confirm it.
|
|
999
|
+
|
|
951
1000
|
### delete
|
|
952
1001
|
|
|
953
1002
|
Delete data from a table.
|
|
@@ -959,7 +1008,7 @@ dbcli delete users --where "id=1" --force
|
|
|
959
1008
|
dbcli delete users --where "id=1" --plan --format json # risk analysis only; no DB connection
|
|
960
1009
|
```
|
|
961
1010
|
|
|
962
|
-
**Options:** `--where <condition>` (required), `--dry-run`, `--force`, `--plan` (analyze risk without connecting or executing), `--format <text|json>` (`--plan` output), `--recovery`
|
|
1011
|
+
**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`
|
|
963
1012
|
**Permission:** data-admin+
|
|
964
1013
|
|
|
965
1014
|
### export
|
|
@@ -2540,6 +2589,8 @@ dbcli migrate add-enum status active inactive suspended
|
|
|
2540
2589
|
dbcli migrate alter-enum status --add-value archived
|
|
2541
2590
|
dbcli migrate drop-enum status --execute --force
|
|
2542
2591
|
```
|
|
2592
|
+
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.
|
|
2593
|
+
|
|
2543
2594
|
|
|
2544
2595
|
**Column spec format:** `name:type[:modifier[:modifier...]]`
|
|
2545
2596
|
- Modifiers: `pk`, `not-null`, `unique`, `auto-increment`, `default=<value>`, `references=<table>.<column>`
|