@carllee1983/dbcli 2.0.0 → 2.1.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.
@@ -51,7 +51,7 @@ var package_default;
51
51
  var init_package = __esm(() => {
52
52
  package_default = {
53
53
  name: "@carllee1983/dbcli",
54
- version: "2.0.0",
54
+ version: "2.1.0",
55
55
  description: "Database CLI for AI agents",
56
56
  type: "module",
57
57
  publishConfig: {
@@ -134,6 +134,7 @@ var init_package = __esm(() => {
134
134
  "agent-core:check": "bun run scripts/check-agent-core-purity.ts",
135
135
  "core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
136
136
  typecheck: "tsc --noEmit --pretty false",
137
+ "typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
137
138
  "test:perf": "bun test ./tests/perf/*.bench.ts",
138
139
  lint: "eslint src tests scripts --ext .ts --max-warnings=0",
139
140
  "format:check": "prettier --check .",
@@ -8903,6 +8904,15 @@ var init_messages = __esm(() => {
8903
8904
  health: {
8904
8905
  description: "Show AuditLogger health snapshot (size, entries, lock state, last write)"
8905
8906
  },
8907
+ write_gate: {
8908
+ description: "Summarize tier-two write-gate decisions (ADR 0010's measurement)",
8909
+ header: "Write-gate summary \u2014 connection: {conn}",
8910
+ window: "Audit entries scanned: {scanned}, {from} \u2192 {to}",
8911
+ decisions: "Tier-two evaluations: {count}, {from} \u2192 {to}",
8912
+ tier_one: "Tier one, counted apart: {count} ({outcomes})",
8913
+ no_decisions: "Tier two was never reached in this window. Per ADR 0010 that falsifies the criterion, not the gate: the shapes it looks for are not the shapes people write.",
8914
+ no_entries: "The audit log is empty, so this says nothing about the gate either way. Run some writes, or check `dbcli audit health`."
8915
+ },
8906
8916
  disabled_hint: "Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.",
8907
8917
  no_entries: "No audit entries.",
8908
8918
  show_no_match: "No audit entry matches '{prefix}'.",
@@ -9211,6 +9221,15 @@ var init_messages2 = __esm(() => {
9211
9221
  health: {
9212
9222
  description: "\u986F\u793A AuditLogger \u5065\u5EB7\u72C0\u614B\uFF08\u6A94\u6848\u5927\u5C0F\u3001\u7B46\u6578\u3001lock \u72C0\u614B\u3001\u6700\u5F8C\u5BEB\u5165\u7D50\u679C\uFF09"
9213
9223
  },
9224
+ write_gate: {
9225
+ description: "\u7D71\u8A08\u7B2C\u4E8C\u7D1A\u5BEB\u5165\u9598\u9580\u7684\u5224\u5B9A\uFF08ADR 0010 \u62BC\u6CE8\u7684\u90A3\u500B\u91CF\u6E2C\uFF09",
9226
+ header: "\u5BEB\u5165\u9598\u9580\u6458\u8981 \u2014 \u9023\u7DDA\uFF1A{conn}",
9227
+ window: "\u6383\u904E\u7684\u7A3D\u6838\u7D00\u9304\uFF1A{scanned} \u7B46\uFF0C{from} \u2192 {to}",
9228
+ decisions: "\u7B2C\u4E8C\u7D1A\u5224\u5B9A\uFF1A{count} \u6B21\uFF0C{from} \u2192 {to}",
9229
+ tier_one: "\u7B2C\u4E00\u7D1A\uFF0C\u5206\u958B\u8A08\u7B97\uFF1A{count} \u6B21\uFF08{outcomes}\uFF09",
9230
+ no_decisions: "\u9019\u6BB5\u5340\u9593\u5167\u7B2C\u4E8C\u7D1A\u4E00\u6B21\u90FD\u6C92\u88AB\u89F8\u53CA\u3002\u4F9D ADR 0010\uFF0C\u9019\u63A8\u7FFB\u7684\u662F\u5224\u6E96\u800C\u4E0D\u662F\u9598\u9580\uFF1A\u5B83\u5728\u627E\u7684\u5F62\u72C0\u4E0D\u662F\u5927\u5BB6\u5BE6\u969B\u6703\u5BEB\u7684\u5F62\u72C0\u3002",
9231
+ no_entries: "\u7A3D\u6838\u7D00\u9304\u662F\u7A7A\u7684\uFF0C\u6240\u4EE5\u9019\u5C0D\u9598\u9580\u6C92\u6709\u4EFB\u4F55\u7D50\u8AD6\u3002\u5148\u8DD1\u4E00\u4E9B\u5BEB\u5165\uFF0C\u6216\u7528 `dbcli audit health` \u6AA2\u67E5\u3002"
9232
+ },
9214
9233
  disabled_hint: "Audit \u5DF2\u505C\u7528\uFF08.dbcli \u4E2D audit.enabled = false\uFF09\u3002\u57F7\u884C `dbcli audit health` \u67E5\u770B\u8A73\u60C5\u3002",
9215
9234
  no_entries: "\u6C92\u6709 audit \u7D00\u9304\u3002",
9216
9235
  show_no_match: "\u627E\u4E0D\u5230\u7B26\u5408 '{prefix}' \u7684 audit \u7D00\u9304\u3002",
@@ -9422,7 +9441,8 @@ var init_shell = __esm(() => {
9422
9441
  multiline_cancelled: "Multiline input cancelled.",
9423
9442
  timing_display: "Time: {ms}ms",
9424
9443
  rows_display: "{count} row(s)",
9425
- unknown_command: "Unknown command: '{command}'. Type '.help' for available commands."
9444
+ unknown_command: "Unknown command: '{command}'. Type '.help' for available commands.",
9445
+ subcommand_prefix_hint: "This line is being read as SQL and is waiting for its `;`. If you meant the dbcli subcommand, type {command} instead. Ctrl-C cancels."
9426
9446
  };
9427
9447
  });
9428
9448
 
@@ -9448,7 +9468,8 @@ var init_shell2 = __esm(() => {
9448
9468
  multiline_cancelled: "\u5DF2\u53D6\u6D88\u591A\u884C\u8F38\u5165\u3002",
9449
9469
  timing_display: "\u8017\u6642\uFF1A{ms}ms",
9450
9470
  rows_display: "{count} \u7B46\u8CC7\u6599",
9451
- unknown_command: "\u672A\u77E5\u6307\u4EE4\uFF1A'{command}'\u3002\u8F38\u5165 '.help' \u67E5\u770B\u53EF\u7528\u6307\u4EE4\u3002"
9471
+ unknown_command: "\u672A\u77E5\u6307\u4EE4\uFF1A'{command}'\u3002\u8F38\u5165 '.help' \u67E5\u770B\u53EF\u7528\u6307\u4EE4\u3002",
9472
+ subcommand_prefix_hint: "\u9019\u4E00\u884C\u88AB\u7576\u6210 SQL\uFF0C\u6B63\u5728\u7B49\u5206\u865F\u3002\u5982\u679C\u4F60\u8981\u7684\u662F dbcli \u5B50\u6307\u4EE4\uFF0C\u8ACB\u6539\u6253 {command}\u3002Ctrl-C \u53EF\u53D6\u6D88\u3002"
9452
9473
  };
9453
9474
  });
9454
9475
 
@@ -9482,14 +9503,20 @@ var init_ceremony = __esm(() => {
9482
9503
  gate_ddl_warning_untargeted: "\u26A0\uFE0F This is a destructive schema change and cannot be undone.",
9483
9504
  gate_unparseable_warning: "\u26A0\uFE0F dbcli could not parse this statement, so it is treated as affecting everything.",
9484
9505
  gate_multiple_statements_warning: "\u26A0\uFE0F This is more than one statement, and dbcli will not judge them one at a time.",
9506
+ gate_multi_table_warning: "This write joins another table. How many rows of the target it touches depends on the data, not on the statement, so it cannot be checked before it runs.",
9507
+ gate_nested_write_warning: "\u26A0\uFE0F This statement carries a second write inside it, and dbcli will not read the tier of that write off this one.",
9485
9508
  gate_typed_prompt: "Type {phrase} to run it",
9486
9509
  gate_typed_mismatch: "That did not match {phrase}. Nothing was run.",
9487
9510
  gate_refused: "Refused: this statement is not limited to specific rows and nobody is here to confirm it (reason={reason}).",
9511
+ gate_refused_in_shell: "Refused: a dbcli subcommand started from the shell has no way to ask you, even though you are right here (reason={reason}).",
9488
9512
  gate_remedy_no_where: "Add a WHERE clause \u2014 WHERE 1=1 if you really do mean every row \u2014 or a LIMIT, then run it again.",
9489
9513
  gate_remedy_ddl_destruction: "No flag skips this. Run it from an interactive terminal, or apply the schema change through a reviewed migration.",
9490
9514
  gate_remedy_unparseable: "dbcli could not parse this statement and will not guess. Simplify it, or run it from an interactive terminal.",
9491
9515
  gate_remedy_multiple_statements: "Run one statement per command.",
9516
+ gate_remedy_multi_table: "Rewrite it as a single-table statement \u2014 move the other table into a subquery in the WHERE \u2014 or run it where someone can confirm it.",
9517
+ gate_remedy_nested_write: "Run the nested write as its own statement, or run this one where someone can confirm it.",
9492
9518
  gate_remedy_non_unique_where: "Match on a primary key or a uniquely indexed column, or run it from an interactive terminal.",
9519
+ gate_remedy_in_shell: "Type the statement at the dbcli> prompt instead \u2014 there the shell asks you to type the target table name.",
9493
9520
  recovery_none: "This cannot be undone automatically \u2014 restore from a backup to get the previous values back.",
9494
9521
  recovery_retry: "Re-run with --recovery to write a recovery plan, then read it with: dbcli recover",
9495
9522
  blacklist_hint: "Run `dbcli blacklist list` to see what is protected"
@@ -9526,14 +9553,20 @@ var init_ceremony2 = __esm(() => {
9526
9553
  gate_ddl_warning_untargeted: "\u26A0\uFE0F \u9019\u662F\u7834\u58DE\u6027\u7684 schema \u8B8A\u66F4\uFF0C\u57F7\u884C\u5F8C\u7121\u6CD5\u5FA9\u539F\u3002",
9527
9554
  gate_unparseable_warning: "\u26A0\uFE0F dbcli \u7121\u6CD5\u89E3\u6790\u9019\u53E5\uFF0C\u56E0\u6B64\u4E00\u5F8B\u8996\u70BA\u6703\u5F71\u97FF\u5168\u90E8\u8CC7\u6599\u3002",
9528
9555
  gate_multiple_statements_warning: "\u26A0\uFE0F \u9019\u88E1\u4E0D\u53EA\u4E00\u53E5\u8A9E\u53E5\uFF0Cdbcli \u4E0D\u6703\u9010\u53E5\u5224\u65B7\u3002",
9556
+ gate_multi_table_warning: "\u9019\u500B\u5BEB\u5165\u63A5\u4E86\u53E6\u4E00\u5F35\u8868\u3002\u5B83\u6703\u52D5\u5230\u76EE\u6A19\u8868\u5E7E\u5217\u53D6\u6C7A\u65BC\u8CC7\u6599\u800C\u4E0D\u662F\u8A9E\u53E5\u672C\u8EAB\uFF0C\u57F7\u884C\u524D\u7121\u6CD5\u6AA2\u67E5\u3002",
9557
+ gate_nested_write_warning: "\u26A0\uFE0F \u9019\u53E5\u88E1\u9762\u9084\u593E\u8457\u53E6\u4E00\u500B\u5BEB\u5165\uFF0Cdbcli \u4E0D\u6703\u62FF\u9019\u53E5\u7684\u5C64\u7D1A\u4EE3\u66FF\u90A3\u500B\u5BEB\u5165\u7684\u5C64\u7D1A\u3002",
9529
9558
  gate_typed_prompt: "\u8F38\u5165 {phrase} \u624D\u6703\u57F7\u884C",
9530
9559
  gate_typed_mismatch: "\u8F38\u5165\u7684\u5167\u5BB9\u8207 {phrase} \u4E0D\u7B26\uFF0C\u6C92\u6709\u57F7\u884C\u4EFB\u4F55\u8A9E\u53E5\u3002",
9531
9560
  gate_refused: "\u5DF2\u62D2\u7D55\uFF1A\u9019\u53E5\u6C92\u6709\u9650\u5B9A\u8981\u52D5\u54EA\u4E9B\u5217\uFF0C\u800C\u73FE\u5728\u6C92\u6709\u4EBA\u80FD\u78BA\u8A8D\uFF08reason={reason}\uFF09\u3002",
9561
+ gate_refused_in_shell: "\u5DF2\u62D2\u7D55\uFF1A\u5F9E shell \u555F\u52D5\u7684 dbcli \u5B50\u6307\u4EE4\u6C92\u6709\u8FA6\u6CD5\u5411\u4F60\u63D0\u554F\uFF0C\u5373\u4F7F\u4F60\u4EBA\u5C31\u5728\u9019\u88E1\uFF08reason={reason}\uFF09\u3002",
9532
9562
  gate_remedy_no_where: "\u88DC\u4E0A WHERE\uFF08\u771F\u7684\u8981\u5168\u8868\u5C31\u5BEB WHERE 1=1\uFF09\u6216 LIMIT\uFF0C\u7136\u5F8C\u91CD\u8DD1\u3002",
9533
9563
  gate_remedy_ddl_destruction: "\u6C92\u6709\u4EFB\u4F55\u65D7\u6A19\u53EF\u4EE5\u8DF3\u904E\u9019\u4E00\u95DC\u3002\u8ACB\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\uFF0C\u6216\u6539\u8D70\u7D93\u904E\u5BE9\u67E5\u7684 migration\u3002",
9534
9564
  gate_remedy_unparseable: "dbcli \u7121\u6CD5\u89E3\u6790\u9019\u53E5\uFF0C\u4E5F\u4E0D\u6703\u7528\u731C\u7684\u3002\u8ACB\u7C21\u5316\u8A9E\u53E5\uFF0C\u6216\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\u3002",
9535
9565
  gate_remedy_multiple_statements: "\u4E00\u6B21\u53EA\u57F7\u884C\u4E00\u53E5\u8A9E\u53E5\u3002",
9566
+ gate_remedy_multi_table: "\u6539\u5BEB\u6210\u55AE\u8868\u8A9E\u53E5\u2014\u2014\u628A\u53E6\u4E00\u5F35\u8868\u79FB\u9032 WHERE \u7684\u5B50\u67E5\u8A62\u88E1\u2014\u2014\u6216\u6539\u5728\u6709\u4EBA\u53EF\u4EE5\u78BA\u8A8D\u7684\u5730\u65B9\u57F7\u884C\u3002",
9567
+ gate_remedy_nested_write: "\u628A\u88E1\u9762\u90A3\u500B\u5BEB\u5165\u62C6\u6210\u7368\u7ACB\u7684\u4E00\u53E5\u4F86\u57F7\u884C\uFF0C\u6216\u6539\u5728\u6709\u4EBA\u53EF\u4EE5\u78BA\u8A8D\u7684\u5730\u65B9\u57F7\u884C\u3002",
9536
9568
  gate_remedy_non_unique_where: "\u6539\u7528\u4E3B\u9375\u6216\u552F\u4E00\u7D22\u5F15\u6B04\u4F4D\u7576\u689D\u4EF6\uFF0C\u6216\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\u3002",
9569
+ gate_remedy_in_shell: "\u8ACB\u76F4\u63A5\u5728 dbcli> \u63D0\u793A\u7B26\u6253\u9019\u53E5\u8A9E\u53E5\u2014\u2014\u5728\u90A3\u88E1 dbcli \u6703\u8ACB\u4F60\u6253\u51FA\u76EE\u6A19\u8CC7\u6599\u8868\u7684\u540D\u7A31\u4F86\u78BA\u8A8D\u3002",
9537
9570
  recovery_none: "\u6B64\u64CD\u4F5C\u7121\u6CD5\u81EA\u52D5\u9084\u539F\uFF0C\u8981\u53D6\u56DE\u5148\u524D\u7684\u8CC7\u6599\u8ACB\u5F9E\u5099\u4EFD\u56DE\u5FA9\u3002",
9538
9571
  recovery_retry: "\u52A0\u4E0A --recovery \u91CD\u8DD1\u53EF\u7522\u751F\u56DE\u5FA9\u8A08\u756B\uFF0C\u518D\u7528 dbcli recover \u8B80\u53D6",
9539
9572
  blacklist_hint: "\u57F7\u884C `dbcli blacklist list` \u67E5\u770B\u53D7\u4FDD\u8B77\u7684\u9805\u76EE"
@@ -18007,7 +18040,7 @@ async function writeAuditEntry(config, commandName, options, outcome) {
18007
18040
  const logger = await getAuditLogger(config, options.config || ".dbcli", connectionName);
18008
18041
  const engine = config.connection?.system || "postgresql";
18009
18042
  const target = outcome.target || getOperationTarget(engine, commandName, options, outcome.sql);
18010
- let tier = getEngineCapability(engine, commandName).tier;
18043
+ let tier = outcome.sideEffectTier ?? getEngineCapability(engine, commandName).tier;
18011
18044
  if (options.dryRun || options.plan) {
18012
18045
  tier = "dry-run";
18013
18046
  }
@@ -18940,6 +18973,7 @@ __export(exports_permission_guard, {
18940
18973
  checkPermissionForClassification: () => checkPermissionForClassification,
18941
18974
  checkPermission: () => checkPermission,
18942
18975
  SQL_WRITE_OR_DDL_KEYWORDS: () => SQL_WRITE_OR_DDL_KEYWORDS,
18976
+ SQL_LOCK_CLAUSE: () => SQL_LOCK_CLAUSE,
18943
18977
  SQL_DIALECTS: () => SQL_DIALECTS,
18944
18978
  PermissionError: () => PermissionError
18945
18979
  });
@@ -22358,7 +22392,8 @@ async function enforceTierTwo(request, attended) {
22358
22392
  const { verdict } = request;
22359
22393
  const reason = verdict.reason ?? "no_where";
22360
22394
  if (!attended) {
22361
- throw new WriteGateRefusal(`${t_vars("ceremony.gate_refused", { reason })} ${t(REMEDY_KEY[reason])}`, reason, verdict.table);
22395
+ const inShell = process.env.DBCLI_SHELL_SUBCOMMAND === "1";
22396
+ throw new WriteGateRefusal(inShell ? `${t_vars("ceremony.gate_refused_in_shell", { reason })} ${t("ceremony.gate_remedy_in_shell")}` : `${t_vars("ceremony.gate_refused", { reason })} ${t(REMEDY_KEY[reason])}`, reason, verdict.table);
22362
22397
  }
22363
22398
  writeSummary(request);
22364
22399
  const warning = verdict.table === undefined && reason === "ddl_destruction" ? t("ceremony.gate_ddl_warning_untargeted") : t_vars(WARNING_KEY[reason], { table: verdict.table ?? "" });
@@ -22366,7 +22401,13 @@ async function enforceTierTwo(request, attended) {
22366
22401
  ${warning}
22367
22402
  ${t(REMEDY_KEY[reason])}
22368
22403
  `);
22369
- const typed = await promptUser.text(t_vars("ceremony.gate_typed_prompt", { phrase: verdict.confirmationPhrase }));
22404
+ const ask = request.ask ?? promptUser.text;
22405
+ const typed = await ask(t_vars("ceremony.gate_typed_prompt", { phrase: verdict.confirmationPhrase }));
22406
+ if (typed === null) {
22407
+ process.stderr.write(`${t("ceremony.gate_cancelled")}
22408
+ `);
22409
+ return false;
22410
+ }
22370
22411
  const matched = typed.trim().toLowerCase() === verdict.confirmationPhrase.toLowerCase();
22371
22412
  if (!matched) {
22372
22413
  process.stderr.write(`${t_vars("ceremony.gate_typed_mismatch", { phrase: verdict.confirmationPhrase })}
@@ -22404,14 +22445,18 @@ var init_write_gate_prompt = __esm(() => {
22404
22445
  ddl_destruction: "ceremony.gate_remedy_ddl_destruction",
22405
22446
  unparseable: "ceremony.gate_remedy_unparseable",
22406
22447
  non_unique_where: "ceremony.gate_remedy_non_unique_where",
22407
- multiple_statements: "ceremony.gate_remedy_multiple_statements"
22448
+ multiple_statements: "ceremony.gate_remedy_multiple_statements",
22449
+ multi_table: "ceremony.gate_remedy_multi_table",
22450
+ nested_write: "ceremony.gate_remedy_nested_write"
22408
22451
  };
22409
22452
  WARNING_KEY = {
22410
22453
  no_where: "ceremony.gate_full_table_warning",
22411
22454
  ddl_destruction: "ceremony.gate_ddl_warning",
22412
22455
  unparseable: "ceremony.gate_unparseable_warning",
22413
22456
  non_unique_where: "ceremony.gate_full_table_warning",
22414
- multiple_statements: "ceremony.gate_multiple_statements_warning"
22457
+ multiple_statements: "ceremony.gate_multiple_statements_warning",
22458
+ multi_table: "ceremony.gate_multi_table_warning",
22459
+ nested_write: "ceremony.gate_nested_write_warning"
22415
22460
  };
22416
22461
  });
22417
22462
 
@@ -22446,6 +22491,12 @@ async function classifySqlWriteGate(sql, options = {}) {
22446
22491
  if (containsMultipleStatements(sql, dialect)) {
22447
22492
  return verdict("two", "multiple_statements", undefined, type);
22448
22493
  }
22494
+ const nested = nestedWrite(sql, dialect);
22495
+ if (nested)
22496
+ return verdict("two", "nested_write", undefined, nested);
22497
+ const merge = classifyMerge(sql, dialect);
22498
+ if (merge)
22499
+ return merge;
22449
22500
  if (type === "DROP" || type === "TRUNCATE") {
22450
22501
  const matched = DDL_TARGET.exec(sql)?.[1];
22451
22502
  const table = matched ? matched.replace(/[`"[\]]/g, "") : undefined;
@@ -22464,20 +22515,139 @@ async function classifyQualification(sql, dialect, type) {
22464
22515
  return unparseable(sql, dialect, type);
22465
22516
  }
22466
22517
  const statement = Array.isArray(ast) ? ast[0] : ast;
22467
- if (!statement)
22518
+ if (!statement || statement.type !== "update" && statement.type !== "delete") {
22468
22519
  return unparseable(sql, dialect, type);
22469
- const table = tableName(statement.from?.[0] ?? statement.table?.[0]);
22470
- if (statement.where != null || statement.limit != null) {
22520
+ }
22521
+ const scope = [...statement.table ?? [], ...statement.from ?? []];
22522
+ const sources = new Set(scope.map((ref, index) => refIdentity(ref) ?? `#unnamed-${index}`));
22523
+ if (sources.size > 1) {
22524
+ return verdict("two", "multi_table", undefined, type);
22525
+ }
22526
+ const target = normaliseRef(statement.table?.[0] ?? scope[0]);
22527
+ const table = tableName(target);
22528
+ if (statement.limit != null)
22529
+ return verdict("one", undefined, table, type);
22530
+ if (statement.where != null && narrowsTarget(statement.where, refNames(target))) {
22471
22531
  return verdict("one", undefined, table, type);
22472
22532
  }
22473
22533
  return verdict("two", "no_where", table, type);
22474
22534
  }
22535
+ function normaliseRef(ref) {
22536
+ if (ref?.table?.toUpperCase() !== "ONLY")
22537
+ return ref;
22538
+ if (typeof ref.as !== "string" || ref.as.length === 0)
22539
+ return ref;
22540
+ return {
22541
+ db: ref.db,
22542
+ on: ref.on,
22543
+ as: null,
22544
+ table: typeof ref.as === "string" ? ref.as : undefined
22545
+ };
22546
+ }
22547
+ function refIdentity(reference) {
22548
+ const ref = normaliseRef(reference);
22549
+ if (typeof ref?.table !== "string")
22550
+ return;
22551
+ return (ref.db ? `${ref.db}.${ref.table}` : ref.table).toLowerCase();
22552
+ }
22553
+ function refNames(reference) {
22554
+ const ref = normaliseRef(reference);
22555
+ const name = typeof ref?.as === "string" && ref.as.length > 0 ? ref.as : ref?.table;
22556
+ return typeof name === "string" ? [name.toLowerCase()] : [];
22557
+ }
22558
+ function narrowsTarget(where, targetNames) {
22559
+ const names = new Set(targetNames);
22560
+ const { owners, correlated, skippedSubquery } = scanCondition(where);
22561
+ if (owners.length === 0 && correlated.length === 0)
22562
+ return !skippedSubquery;
22563
+ return owners.some((owner) => owner === null || names.has(owner)) || correlated.some((owner) => names.has(owner));
22564
+ }
22565
+ function mergeScans(scans) {
22566
+ return {
22567
+ owners: scans.flatMap((scan) => scan.owners),
22568
+ correlated: scans.flatMap((scan) => scan.correlated),
22569
+ skippedSubquery: scans.some((scan) => scan.skippedSubquery)
22570
+ };
22571
+ }
22572
+ function scanCondition(node, bound) {
22573
+ if (node === null || typeof node !== "object")
22574
+ return EMPTY_SCAN;
22575
+ const record = node;
22576
+ const inner = record.ast ?? (record.type === "select" ? record : undefined);
22577
+ const nested = inner !== undefined;
22578
+ if (record.type === "column_ref") {
22579
+ const owner = record.table;
22580
+ const name = owner == null ? null : String(owner).toLowerCase();
22581
+ if (bound === undefined)
22582
+ return { owners: [name], correlated: [], skippedSubquery: false };
22583
+ if (name === null || bound.has(name))
22584
+ return EMPTY_SCAN;
22585
+ return { owners: [], correlated: [name], skippedSubquery: false };
22586
+ }
22587
+ const scope = nested ? new Set([...bound ?? [], ...boundNames(inner)]) : bound;
22588
+ const scans = Object.values(record).map((value) => Array.isArray(value) ? mergeScans(value.map((item) => scanCondition(item, scope))) : scanCondition(value, scope));
22589
+ const merged = mergeScans(scans);
22590
+ return nested ? { ...merged, skippedSubquery: true } : merged;
22591
+ }
22592
+ function boundNames(select2) {
22593
+ const from = select2?.from ?? [];
22594
+ return Array.isArray(from) ? from.flatMap((ref) => refNames(ref)) : [];
22595
+ }
22596
+ function stripParenthesised(text2) {
22597
+ let previous = text2;
22598
+ for (;; ) {
22599
+ const next = previous.replace(/\([^()]*\)/g, " ");
22600
+ if (next === previous)
22601
+ return next;
22602
+ previous = next;
22603
+ }
22604
+ }
22475
22605
  function unparseable(sql, dialect, type) {
22476
22606
  const executable = stripCommentsAndStrings(sql, dialect ? { dialect } : {});
22607
+ const statementLevel = stripParenthesised(executable);
22608
+ const singleTable = SINGLE_TARGET.test(executable) && !SECOND_SOURCE_ANYWHERE.test(executable) && !SECOND_SOURCE_AT_STATEMENT_LEVEL.some((pattern) => pattern.test(statementLevel));
22609
+ if (!singleTable)
22610
+ return verdict("two", "unparseable", undefined, type);
22477
22611
  if (/\bWHERE\b|\bLIMIT\b/i.test(executable))
22478
22612
  return verdict("one", undefined, undefined, type);
22479
22613
  return verdict("two", "unparseable", undefined, type);
22480
22614
  }
22615
+ function classifyMerge(sql, dialect) {
22616
+ const executable = stripParenthesised(stripCommentsAndStrings(sql, dialect ? { dialect } : {}));
22617
+ if (!MERGE_STATEMENT.test(executable))
22618
+ return;
22619
+ const matched = MERGE_TARGET.exec(executable)?.[1];
22620
+ const table = matched === undefined || matched.endsWith(".") || /^USING$/i.test(matched) ? undefined : matched;
22621
+ const destructive = /\bTHEN\s+DELETE\b/i.test(executable) ? "DELETE" : /\bTHEN\s+UPDATE\b/i.test(executable) ? "UPDATE" : undefined;
22622
+ if (!destructive)
22623
+ return verdict("one", undefined, table, "INSERT");
22624
+ return verdict("two", "multi_table", table, destructive);
22625
+ }
22626
+ function nestedWrite(sql, dialect) {
22627
+ const executable = stripCommentsAndStrings(sql, dialect ? { dialect } : {}).replace(SQL_LOCK_CLAUSE, " ").replace(REFERENTIAL_ACTION, " ");
22628
+ const match = /\b(INSERT|UPDATE|DELETE)\b|\bMERGE\s+INTO\b/i.exec(insideParentheses(executable));
22629
+ if (match === null)
22630
+ return;
22631
+ return keywordToType(match[1] ?? "MERGE");
22632
+ }
22633
+ function insideParentheses(text2) {
22634
+ let depth = 0;
22635
+ let result = "";
22636
+ for (const char of text2) {
22637
+ if (char === "(") {
22638
+ depth += 1;
22639
+ result += " ";
22640
+ continue;
22641
+ }
22642
+ if (char === ")") {
22643
+ depth = Math.max(0, depth - 1);
22644
+ result += " ";
22645
+ continue;
22646
+ }
22647
+ result += depth > 0 ? char : " ";
22648
+ }
22649
+ return result;
22650
+ }
22481
22651
  function classifyStructuredWriteGate(request) {
22482
22652
  const conditions = new Set(Object.keys(request.where).map((key) => key.toLowerCase()));
22483
22653
  const identifies = uniqueSelectors(request.schema).some((selector) => selector.length > 0 && selector.every((column) => conditions.has(column.toLowerCase())));
@@ -22511,7 +22681,7 @@ function verdict(tier, reason, table, operation) {
22511
22681
  confirmationPhrase: table ?? WRITE_GATE_FALLBACK_PHRASE
22512
22682
  };
22513
22683
  }
22514
- var WRITE_GATE_FALLBACK_PHRASE = "CONFIRM", PARSER_DIALECTS, WRITE_TYPES, DDL_TARGET;
22684
+ var WRITE_GATE_FALLBACK_PHRASE = "CONFIRM", PARSER_DIALECTS, WRITE_TYPES, DDL_TARGET, EMPTY_SCAN, SINGLE_TARGET, SECOND_SOURCE_ANYWHERE, SECOND_SOURCE_AT_STATEMENT_LEVEL, MERGE_STATEMENT, MERGE_TARGET, REFERENTIAL_ACTION;
22515
22685
  var init_write_gate = __esm(() => {
22516
22686
  init_permission_guard();
22517
22687
  init_sql_analysis();
@@ -22530,6 +22700,13 @@ var init_write_gate = __esm(() => {
22530
22700
  "CREATE"
22531
22701
  ]);
22532
22702
  DDL_TARGET = /\b(?:DROP\s+TABLE|TRUNCATE(?:\s+TABLE)?)\s+(?:IF\s+EXISTS\s+)?([`"[\]\w$.]+)\s*;?\s*$/i;
22703
+ EMPTY_SCAN = { owners: [], correlated: [], skippedSubquery: false };
22704
+ SINGLE_TARGET = /^\s*(?:UPDATE\s+(?:ONLY\s+)?[\w`"[\]$.]+\s+SET\b|DELETE\s+FROM\s+(?:ONLY\s+)?[\w`"[\]$.]+(?!\s*,)(?:\s|$))/i;
22705
+ SECOND_SOURCE_ANYWHERE = /\b(?:SELECT|TABLE|VALUES|JOIN|USING)\b/i;
22706
+ SECOND_SOURCE_AT_STATEMENT_LEVEL = [/\bWITH\b/i, /\bUPDATE\b[\s\S]*?\bFROM\b/i];
22707
+ MERGE_STATEMENT = /\bMERGE\s+INTO\b/i;
22708
+ MERGE_TARGET = /\bMERGE\s+INTO\s+(?:ONLY\s+)?([\w$.]+)/i;
22709
+ REFERENTIAL_ACTION = /\bON\s+(?:DELETE|UPDATE)\b/gi;
22533
22710
  });
22534
22711
 
22535
22712
  // src/commands/write-gate-guard.ts
@@ -22541,6 +22718,7 @@ __export(exports_write_gate_guard, {
22541
22718
  async function recordGateDecision(request) {
22542
22719
  await writeAuditEntry(request.config, request.command, request.options, {
22543
22720
  success: request.outcome === "allowed",
22721
+ sideEffectTier: "db-write",
22544
22722
  ...request.sql && { sql: request.sql },
22545
22723
  ...request.verdict.table && { target: request.verdict.table },
22546
22724
  metadata: {
@@ -25491,9 +25669,12 @@ async function qCommand(name, options, command) {
25491
25669
  console.error(`\u26A0 ${w2}`);
25492
25670
  if (prepared.warnings.length > 0)
25493
25671
  console.error("");
25672
+ const family = engineFamily(engine);
25673
+ const sqlDialect = SQL_DIALECTS.find((dialect) => dialect === connectionSystem);
25674
+ const classification = family === "sql" ? enforcePermission(prepared.rewrittenSql, config.permission, sqlDialect) : undefined;
25494
25675
  if (options.dryRun) {
25495
25676
  console.log(formatDryRun({
25496
- family: engineFamily(engine),
25677
+ family,
25497
25678
  driverSql: prepared.driver.sql,
25498
25679
  values: prepared.driver.values,
25499
25680
  execHints: prepared.execHints,
@@ -25514,8 +25695,6 @@ async function qCommand(name, options, command) {
25514
25695
  }
25515
25696
  const blacklistManager = new BlacklistManager(config);
25516
25697
  const blacklistValidator = new BlacklistValidator(blacklistManager);
25517
- const family = engineFamily(engine);
25518
- const sqlDialect = SQL_DIALECTS.find((dialect) => dialect === connectionSystem);
25519
25698
  const targets = family === "sql" ? extractTableReferences(prepared.rewrittenSql, {
25520
25699
  ...sqlDialect ? { dialect: sqlDialect } : {}
25521
25700
  }) : family === "es" ? [prepared.execHints?.index ?? ""] : [];
@@ -25523,8 +25702,8 @@ async function qCommand(name, options, command) {
25523
25702
  targetNameForAudit = targetName || name;
25524
25703
  if (family === "es") {
25525
25704
  blacklistValidator.checkIndexBlacklist("SELECT", targetName);
25526
- } else if (family !== "redis" && targets.length > 0) {
25527
- blacklistValidator.checkTablesBlacklist("SELECT", targets);
25705
+ } else if (classification && targets.length > 0) {
25706
+ blacklistValidator.checkTablesBlacklist(classification.type, targets);
25528
25707
  }
25529
25708
  const adapter = AdapterFactory.createAdapter(config.connection);
25530
25709
  await adapter.connect();
@@ -36541,6 +36720,81 @@ Verification artifact: ${verificationArtifactPath}`);
36541
36720
  });
36542
36721
  });
36543
36722
 
36723
+ // src/core/audit/write-gate-summary.ts
36724
+ function metadataOf(entry) {
36725
+ const metadata = entry.metadata;
36726
+ return metadata && typeof metadata === "object" ? metadata : {};
36727
+ }
36728
+ function readString(source, key) {
36729
+ const value = source[key];
36730
+ return typeof value === "string" && value.length > 0 ? value : undefined;
36731
+ }
36732
+
36733
+ class Span {
36734
+ from;
36735
+ to;
36736
+ add(ts) {
36737
+ if (this.from === undefined || ts < this.from)
36738
+ this.from = ts;
36739
+ if (this.to === undefined || ts > this.to)
36740
+ this.to = ts;
36741
+ }
36742
+ value() {
36743
+ return this.from === undefined || this.to === undefined ? null : { from: this.from, to: this.to };
36744
+ }
36745
+ }
36746
+ function summarizeWriteGate(entries) {
36747
+ const outcomes = Object.fromEntries(GATE_OUTCOMES.map((o) => [o, 0]));
36748
+ const reasons = Object.fromEntries(GATE_REASONS.map((r) => [r, 0]));
36749
+ const tierOneOutcomes = {};
36750
+ const window = new Span;
36751
+ const range = new Span;
36752
+ let total = 0;
36753
+ let tierOneTotal = 0;
36754
+ const tally = (into, key) => {
36755
+ into[key] = (into[key] ?? 0) + 1;
36756
+ };
36757
+ for (const entry of entries) {
36758
+ window.add(entry.ts);
36759
+ const metadata = metadataOf(entry);
36760
+ const tier = readString(metadata, "write_gate_tier");
36761
+ if (!tier)
36762
+ continue;
36763
+ const outcome = readString(metadata, "write_gate_outcome") ?? "unknown";
36764
+ if (tier !== "two") {
36765
+ tierOneTotal += 1;
36766
+ tally(tierOneOutcomes, outcome);
36767
+ continue;
36768
+ }
36769
+ total += 1;
36770
+ range.add(entry.ts);
36771
+ tally(outcomes, outcome);
36772
+ tally(reasons, readString(metadata, "write_gate_reason") ?? "unknown");
36773
+ }
36774
+ return {
36775
+ scanned: entries.length,
36776
+ window: window.value(),
36777
+ total,
36778
+ outcomes,
36779
+ reasons,
36780
+ tierOne: { total: tierOneTotal, outcomes: tierOneOutcomes },
36781
+ range: range.value()
36782
+ };
36783
+ }
36784
+ var GATE_OUTCOMES, GATE_REASONS;
36785
+ var init_write_gate_summary = __esm(() => {
36786
+ GATE_OUTCOMES = ["allowed", "declined", "refused"];
36787
+ GATE_REASONS = [
36788
+ "no_where",
36789
+ "ddl_destruction",
36790
+ "unparseable",
36791
+ "multiple_statements",
36792
+ "non_unique_where",
36793
+ "multi_table",
36794
+ "nested_write"
36795
+ ];
36796
+ });
36797
+
36544
36798
  // src/commands/audit.ts
36545
36799
  var exports_audit = {};
36546
36800
  __export(exports_audit, {
@@ -36726,6 +36980,53 @@ function readLineFromStdinWithStderrPrompt(prompt) {
36726
36980
  process.stdin.resume();
36727
36981
  });
36728
36982
  }
36983
+ function formatOutcomeCounts(outcomes) {
36984
+ const seen = Object.entries(outcomes).filter(([, count]) => count > 0);
36985
+ if (seen.length === 0)
36986
+ return MISSING_PLACEHOLDER;
36987
+ return seen.map(([name, count]) => `${name} ${count}`).join(", ");
36988
+ }
36989
+ function renderWriteGateSummary(summary, connections) {
36990
+ const lines = [];
36991
+ lines.push(t_vars("audit.write_gate.header", {
36992
+ conn: connections.length > 0 ? connections.join(", ") : MISSING_PLACEHOLDER
36993
+ }));
36994
+ if (summary.window) {
36995
+ lines.push(t_vars("audit.write_gate.window", {
36996
+ scanned: String(summary.scanned),
36997
+ from: summary.window.from,
36998
+ to: summary.window.to
36999
+ }));
37000
+ }
37001
+ if (summary.total === 0) {
37002
+ lines.push("");
37003
+ lines.push(summary.scanned === 0 ? t("audit.write_gate.no_entries") : t("audit.write_gate.no_decisions"));
37004
+ if (summary.tierOne.total > 0) {
37005
+ lines.push(t_vars("audit.write_gate.tier_one", {
37006
+ count: String(summary.tierOne.total),
37007
+ outcomes: formatOutcomeCounts(summary.tierOne.outcomes)
37008
+ }));
37009
+ }
37010
+ return lines.join(`
37011
+ `);
37012
+ }
37013
+ lines.push(t_vars("audit.write_gate.decisions", {
37014
+ count: String(summary.total),
37015
+ from: summary.range?.from ?? MISSING_PLACEHOLDER,
37016
+ to: summary.range?.to ?? MISSING_PLACEHOLDER
37017
+ }));
37018
+ lines.push("");
37019
+ lines.push(renderTable(Object.entries(summary.outcomes).map(([name, count]) => [name, String(count)]), ["outcome", "count"]));
37020
+ lines.push("");
37021
+ lines.push(renderTable(Object.entries(summary.reasons).map(([name, count]) => [name, String(count)]), ["reason", "count"]));
37022
+ lines.push("");
37023
+ lines.push(t_vars("audit.write_gate.tier_one", {
37024
+ count: String(summary.tierOne.total),
37025
+ outcomes: formatOutcomeCounts(summary.tierOne.outcomes)
37026
+ }));
37027
+ return lines.join(`
37028
+ `);
37029
+ }
36729
37030
  async function statAuditFile(file) {
36730
37031
  try {
36731
37032
  const s = await stat7(file);
@@ -36745,6 +37046,7 @@ var init_audit = __esm(() => {
36745
37046
  init_config_binding();
36746
37047
  init_reader2();
36747
37048
  init_integration_helper();
37049
+ init_write_gate_summary();
36748
37050
  ALLOWED_FORMATS14 = ["table", "json"];
36749
37051
  auditCommand = new Command("audit").description(t("audit.description"));
36750
37052
  auditCommand.command("tail").description(t("audit.tail.description")).option("--n <number>", `Number of recent entries to show (1..${MAX_TAIL_N})`, String(DEFAULT_TAIL_N)).option("--all", "Merge entries across all connections", false).option("--format <format>", `Output format: ${ALLOWED_FORMATS14.join(" | ")} (default: table)`, "table").option("--brief", "Trim each entry to ts/command/target/success", false).option("--no-brief", "Disable brief mode (override --for-agent default)").option("--for-agent", "Shortcut for --format json --brief", false).action(async (options, command) => {
@@ -36952,6 +37254,33 @@ var init_audit = __esm(() => {
36952
37254
  `);
36953
37255
  process.exit(0);
36954
37256
  });
37257
+ auditCommand.command("write-gate").description(t("audit.write_gate.description")).option("--all", "Summarize across all connections", false).option("--format <format>", `Output format: ${ALLOWED_FORMATS14.join(" | ")} (default: table)`, "table").option("--for-agent", "Shortcut for --format json", false).action(async (options, command) => {
37258
+ const format = options.forAgent === true ? "json" : options.format;
37259
+ validateFormat(format, ALLOWED_FORMATS14, "audit write-gate");
37260
+ const configPath = resolveConfigPath(command, options);
37261
+ const config = await configModule.read(configPath);
37262
+ if (isAuditDisabled(config))
37263
+ emitDisabledAndExit0();
37264
+ const { auditDir, auditFile, connectionName } = await resolveAuditPaths(configPath, config);
37265
+ let entries = [];
37266
+ let connections = [connectionName];
37267
+ if (options.all === true) {
37268
+ const discovered = await discoverConnections(auditDir);
37269
+ connections = discovered.map((c) => c.connection);
37270
+ for (const c of discovered) {
37271
+ for (const file of c.files)
37272
+ entries.push(...await readEntries(file));
37273
+ }
37274
+ } else {
37275
+ entries = await readEntries(auditFile, { include_rotated: true });
37276
+ }
37277
+ const summary = summarizeWriteGate(entries);
37278
+ if (format === "json") {
37279
+ console.log(JSON.stringify({ connections, summary }, null, 2));
37280
+ return;
37281
+ }
37282
+ console.log(renderWriteGateSummary(summary, connections));
37283
+ });
36955
37284
  auditCommand.command("health").description(t("audit.health.description")).option("--format <format>", `Output format: ${ALLOWED_FORMATS14.join(" | ")} (default: table)`, "table").option("--brief", "Trim to enabled / lastWrite / rotationUsage", false).option("--no-brief", "Disable brief mode (override --for-agent default)").option("--for-agent", "Shortcut for --format json --brief", false).action(async (options, command) => {
36956
37285
  const forAgent = options.forAgent === true;
36957
37286
  const format = forAgent ? "json" : options.format;
@@ -44242,6 +44571,9 @@ function classifyInput(raw) {
44242
44571
  return { type: "meta", raw, normalized: normalized2 };
44243
44572
  }
44244
44573
  }
44574
+ if (normalized2.startsWith(COMMAND_PREFIX)) {
44575
+ return { type: "command", raw, normalized: normalized2 };
44576
+ }
44245
44577
  const firstWord = (normalized2.split(/\s+/)[0] ?? "").toUpperCase();
44246
44578
  if (sqlKeywordSet.has(firstWord)) {
44247
44579
  return { type: "sql", raw, normalized: normalized2 };
@@ -44251,7 +44583,7 @@ function classifyInput(raw) {
44251
44583
  }
44252
44584
  return { type: "command", raw, normalized: normalized2 };
44253
44585
  }
44254
- var META_PREFIX = ".", SQL_TERMINATOR = ";", sqlKeywordSet, metaCommandNames;
44586
+ var META_PREFIX = ".", COMMAND_PREFIX = "\\", SQL_TERMINATOR = ";", sqlKeywordSet, metaCommandNames;
44255
44587
  var init_input_classifier = __esm(() => {
44256
44588
  init_types11();
44257
44589
  sqlKeywordSet = new Set(SQL_KEYWORDS_FOR_DETECTION);
@@ -44275,6 +44607,10 @@ class MultilineBuffer {
44275
44607
  isActive() {
44276
44608
  return this.lines.length > 0;
44277
44609
  }
44610
+ isInsideLiteral() {
44611
+ return insideLiteral(this.lines.join(`
44612
+ `));
44613
+ }
44278
44614
  getPartial() {
44279
44615
  return this.lines.join(`
44280
44616
  `);
@@ -44284,9 +44620,17 @@ class MultilineBuffer {
44284
44620
  }
44285
44621
  }
44286
44622
  function hasUnquotedSemicolon(sql) {
44623
+ return scan(sql).semicolon;
44624
+ }
44625
+ function insideLiteral(sql) {
44626
+ const { inSingleQuote, inDoubleQuote } = scan(sql);
44627
+ return inSingleQuote || inDoubleQuote;
44628
+ }
44629
+ function scan(sql) {
44287
44630
  let inSingleQuote = false;
44288
44631
  let inDoubleQuote = false;
44289
44632
  let escaped = false;
44633
+ let semicolon = false;
44290
44634
  for (let i = 0;i < sql.length; i++) {
44291
44635
  const ch = sql[i];
44292
44636
  if (escaped) {
@@ -44306,10 +44650,10 @@ function hasUnquotedSemicolon(sql) {
44306
44650
  continue;
44307
44651
  }
44308
44652
  if (ch === ";" && !inSingleQuote && !inDoubleQuote) {
44309
- return true;
44653
+ semicolon = true;
44310
44654
  }
44311
44655
  }
44312
- return false;
44656
+ return { semicolon, inSingleQuote, inDoubleQuote };
44313
44657
  }
44314
44658
 
44315
44659
  // src/core/repl/meta-commands.ts
@@ -44353,7 +44697,8 @@ function helpText() {
44353
44697
  "",
44354
44698
  import_picocolors2.default.bold("Usage:"),
44355
44699
  ` SQL statements Execute directly (end with ${import_picocolors2.default.cyan(";")})`,
44356
- ` dbcli commands Run without ${import_picocolors2.default.dim("dbcli")} prefix (e.g. ${import_picocolors2.default.cyan("schema users")})`,
44700
+ ` dbcli commands Run without the ${import_picocolors2.default.dim("dbcli")} prefix (e.g. ${import_picocolors2.default.cyan("schema users")})`,
44701
+ ` ${import_picocolors2.default.cyan("\\<command>")} Force the dbcli subcommand \u2014 required for ${import_picocolors2.default.dim("delete")}, ${import_picocolors2.default.dim("update")}, ${import_picocolors2.default.dim("insert")} and other SQL keywords (e.g. ${import_picocolors2.default.cyan("\\delete users --where id=1")})`,
44357
44702
  ` Ctrl+D Exit`
44358
44703
  ];
44359
44704
  return lines.join(`
@@ -44457,24 +44802,26 @@ function splitRespectingQuotes(input) {
44457
44802
  let current = "";
44458
44803
  let inDoubleQuote = false;
44459
44804
  let inSingleQuote = false;
44805
+ let quoted = false;
44460
44806
  for (let i = 0;i < input.length; i++) {
44461
44807
  const ch = input[i];
44462
44808
  if (ch === '"' && !inSingleQuote) {
44463
44809
  inDoubleQuote = !inDoubleQuote;
44464
- current += ch;
44810
+ quoted = true;
44465
44811
  } else if (ch === "'" && !inDoubleQuote) {
44466
44812
  inSingleQuote = !inSingleQuote;
44467
- current += ch;
44813
+ quoted = true;
44468
44814
  } else if (ch === " " && !inDoubleQuote && !inSingleQuote) {
44469
- if (current.length > 0) {
44815
+ if (current.length > 0 || quoted) {
44470
44816
  tokens.push(current);
44471
44817
  current = "";
44818
+ quoted = false;
44472
44819
  }
44473
44820
  } else {
44474
44821
  current += ch;
44475
44822
  }
44476
44823
  }
44477
- if (current.length > 0) {
44824
+ if (current.length > 0 || quoted) {
44478
44825
  tokens.push(current);
44479
44826
  }
44480
44827
  return tokens;
@@ -44540,14 +44887,16 @@ var init_history_manager = () => {};
44540
44887
  class ReplEngine {
44541
44888
  adapter;
44542
44889
  context;
44890
+ writeGate;
44543
44891
  state;
44544
44892
  buffer;
44545
44893
  history;
44546
44894
  formatter;
44547
44895
  config;
44548
- constructor(adapter, context, historyPath, config = null) {
44896
+ constructor(adapter, context, historyPath, config = null, writeGate = null) {
44549
44897
  this.adapter = adapter;
44550
44898
  this.context = context;
44899
+ this.writeGate = writeGate;
44551
44900
  this.state = { format: "table", timing: false, connected: true, noLimit: false };
44552
44901
  this.buffer = new MultilineBuffer;
44553
44902
  this.history = new HistoryManager(historyPath);
@@ -44560,6 +44909,9 @@ class ReplEngine {
44560
44909
  isMultiline() {
44561
44910
  return this.buffer.isActive();
44562
44911
  }
44912
+ cancelMultiline() {
44913
+ this.buffer.reset();
44914
+ }
44563
44915
  getHistory() {
44564
44916
  return this.history.getAll();
44565
44917
  }
@@ -44568,6 +44920,13 @@ class ReplEngine {
44568
44920
  }
44569
44921
  async processInput(line) {
44570
44922
  if (this.buffer.isActive()) {
44923
+ const classified2 = classifyInput(line);
44924
+ if (classified2.type === "meta" && !this.buffer.isInsideLiteral()) {
44925
+ const result = this.handleMeta(classified2.normalized);
44926
+ if (result.action === "quit" || result.action === "clear")
44927
+ this.buffer.reset();
44928
+ return result;
44929
+ }
44571
44930
  const bufResult = this.buffer.append(line);
44572
44931
  if (bufResult.complete) {
44573
44932
  this.history.add(bufResult.sql);
@@ -44604,14 +44963,27 @@ class ReplEngine {
44604
44963
  this.history.add(bufResult.sql);
44605
44964
  return this.executeSql(bufResult.sql);
44606
44965
  }
44607
- return { action: "multiline" };
44966
+ const hint = this.subcommandHint(input);
44967
+ return hint ? { action: "multiline", output: hint } : { action: "multiline" };
44968
+ }
44969
+ subcommandHint(input) {
44970
+ const words = input.trim().split(/\s+/);
44971
+ const firstWord = (words[0] ?? "").toLowerCase();
44972
+ if (!this.context.commandNames.includes(firstWord))
44973
+ return;
44974
+ if (!/\s--[a-z]/.test(input))
44975
+ return;
44976
+ if (STATEMENT_CONTINUATION.has((words[1] ?? "").toUpperCase()))
44977
+ return;
44978
+ return import_picocolors3.default.dim(t_vars("shell.subcommand_prefix_hint", { command: `${COMMAND_PREFIX}${firstWord}` }));
44608
44979
  }
44609
44980
  async handleCommand(input) {
44610
- const parsed = parseCommandLine(input);
44981
+ const invocation = input.startsWith(COMMAND_PREFIX) ? input.slice(COMMAND_PREFIX.length) : input;
44982
+ const parsed = parseCommandLine(invocation);
44611
44983
  if (!isKnownCommand(parsed.command, this.context.commandNames)) {
44612
44984
  if (this.context.system === "redis") {
44613
44985
  this.history.add(input);
44614
- return this.executeSql(input);
44986
+ return this.executeSql(invocation);
44615
44987
  }
44616
44988
  return {
44617
44989
  action: "continue",
@@ -44621,11 +44993,11 @@ class ReplEngine {
44621
44993
  this.history.add(input);
44622
44994
  try {
44623
44995
  const cliPath = new URL("../../cli.ts", import.meta.url).pathname;
44624
- const argv = [parsed.command, "--config", this.context.configPath, ...parsed.args];
44996
+ const argv = ["--config", this.context.configPath, parsed.command, ...parsed.args];
44625
44997
  const proc = Bun.spawn(["bun", "run", cliPath, ...argv], {
44626
44998
  stdout: "pipe",
44627
44999
  stderr: "pipe",
44628
- env: { ...process.env }
45000
+ env: { ...process.env, DBCLI_SHELL_SUBCOMMAND: "1" }
44629
45001
  });
44630
45002
  const [stdoutBuf, stderrBuf] = await Promise.all([
44631
45003
  new Response(proc.stdout).text(),
@@ -44681,6 +45053,11 @@ class ReplEngine {
44681
45053
  };
44682
45054
  }
44683
45055
  }
45056
+ if (this.writeGate && !await this.writeGate(sql))
45057
+ return { action: "continue" };
45058
+ return this.runStatement(sql, blacklistValidator, referencedTables);
45059
+ }
45060
+ async runStatement(sql, blacklistValidator, referencedTables) {
44684
45061
  const startTime = Date.now();
44685
45062
  try {
44686
45063
  const result = await this.adapter.execute(sql, undefined, {
@@ -44716,7 +45093,7 @@ class ReplEngine {
44716
45093
  await this.adapter.connect();
44717
45094
  this.state = { ...this.state, connected: true };
44718
45095
  console.error(import_picocolors3.default.green(t("shell.error_reconnect_success")));
44719
- return this.executeSql(sql);
45096
+ return this.runStatement(sql, blacklistValidator, referencedTables);
44720
45097
  } catch (reconnectError) {
44721
45098
  return {
44722
45099
  action: "continue",
@@ -44747,7 +45124,7 @@ class ReplEngine {
44747
45124
  return isTransportFailure(error);
44748
45125
  }
44749
45126
  }
44750
- var import_picocolors3;
45127
+ var import_picocolors3, STATEMENT_CONTINUATION;
44751
45128
  var init_repl_engine = __esm(() => {
44752
45129
  init_connection_error_message();
44753
45130
  init_input_classifier();
@@ -44761,6 +45138,15 @@ var init_repl_engine = __esm(() => {
44761
45138
  init_blacklist_validator();
44762
45139
  init_blacklist();
44763
45140
  import_picocolors3 = __toESM(require_picocolors(), 1);
45141
+ STATEMENT_CONTINUATION = new Set([
45142
+ "FROM",
45143
+ "INTO",
45144
+ "SET",
45145
+ "TABLE",
45146
+ "INDEX",
45147
+ "VIEW",
45148
+ "DATABASE"
45149
+ ]);
44764
45150
  });
44765
45151
 
44766
45152
  // src/core/repl/completer.ts
@@ -44803,6 +45189,11 @@ function createCompleter(ctx) {
44803
45189
  }
44804
45190
  const words = trimmed.split(/\s+/);
44805
45191
  if (words.length === 1) {
45192
+ if (lastWord.startsWith(COMMAND_PREFIX)) {
45193
+ const bare = lastWord.slice(COMMAND_PREFIX.length);
45194
+ const [hits] = matchWithSuffix([...ctx.commandNames], bare);
45195
+ return [hits.map((hit) => `${COMMAND_PREFIX}${hit}`), lastWord];
45196
+ }
44806
45197
  const cmdHits = matchWithSuffix([...ctx.commandNames], lastWord);
44807
45198
  const sqlHits = matchWithSuffix([...SQL_KEYWORDS_FOR_COMPLETION], lastWord);
44808
45199
  return [[...cmdHits[0], ...sqlHits[0]], lastWord];
@@ -44856,6 +45247,7 @@ function matchWithSuffix(candidates, prefix) {
44856
45247
  var REDIS_COMMANDS, TABLE_POSITION_KEYWORDS, COMMANDS_TAKING_TABLE_ARG;
44857
45248
  var init_completer = __esm(() => {
44858
45249
  init_types11();
45250
+ init_input_classifier();
44859
45251
  init_command_metadata();
44860
45252
  REDIS_COMMANDS = Object.keys(REDIS_COMMAND_TABLE);
44861
45253
  TABLE_POSITION_KEYWORDS = new Set([
@@ -45153,6 +45545,94 @@ var init_command_registry = __esm(() => {
45153
45545
  REPL_DENYLIST = new Set(["shell"]);
45154
45546
  });
45155
45547
 
45548
+ // src/commands/shell-prompt-asker.ts
45549
+ var exports_shell_prompt_asker = {};
45550
+ __export(exports_shell_prompt_asker, {
45551
+ createPromptAsker: () => createPromptAsker
45552
+ });
45553
+ function createPromptAsker(getInterface) {
45554
+ let pending = null;
45555
+ return {
45556
+ async ask(question) {
45557
+ const rl = getInterface();
45558
+ if (!rl)
45559
+ throw new Error("Write gate asked for confirmation before the shell started");
45560
+ return new Promise((resolve10) => {
45561
+ const controller = new AbortController;
45562
+ const settle = (answer) => {
45563
+ pending = null;
45564
+ rl.pause();
45565
+ resolve10(answer);
45566
+ };
45567
+ pending = {
45568
+ abort: () => {
45569
+ controller.abort();
45570
+ settle(null);
45571
+ }
45572
+ };
45573
+ try {
45574
+ rl.question(`${question}: `, { signal: controller.signal }, settle);
45575
+ } catch (error) {
45576
+ pending = null;
45577
+ throw error;
45578
+ }
45579
+ });
45580
+ },
45581
+ cancel() {
45582
+ if (!pending)
45583
+ return false;
45584
+ pending.abort();
45585
+ return true;
45586
+ }
45587
+ };
45588
+ }
45589
+
45590
+ // src/commands/shell-write-gate.ts
45591
+ var exports_shell_write_gate = {};
45592
+ __export(exports_shell_write_gate, {
45593
+ createShellWriteGate: () => createShellWriteGate
45594
+ });
45595
+ function createShellWriteGate(options) {
45596
+ const record4 = options.record ?? ((decision) => recordGateDecision({
45597
+ config: options.config,
45598
+ command: "shell",
45599
+ options: { config: options.configPath },
45600
+ verdict: decision.verdict,
45601
+ outcome: decision.outcome,
45602
+ tier: decision.tier,
45603
+ sql: decision.sql
45604
+ }));
45605
+ return async (sql) => {
45606
+ const verdict2 = await classifySqlWriteGate(sql, { dialect: options.dialect });
45607
+ if (verdict2.tier !== "two")
45608
+ return true;
45609
+ const decide5 = (outcome) => record4({ verdict: verdict2, outcome, tier: "two", sql });
45610
+ try {
45611
+ const proceed = await enforceWriteGate({
45612
+ verdict: verdict2,
45613
+ statement: sql,
45614
+ operation: verdict2.operation ?? "WRITE",
45615
+ human: { isTTY: process.stdin.isTTY === true },
45616
+ ...options.ask && { ask: options.ask }
45617
+ });
45618
+ await decide5(proceed ? "allowed" : "declined");
45619
+ return proceed;
45620
+ } catch (error) {
45621
+ if (!(error instanceof WriteGateRefusal))
45622
+ throw error;
45623
+ await decide5("refused");
45624
+ process.stderr.write(`${error.message}
45625
+ `);
45626
+ return false;
45627
+ }
45628
+ };
45629
+ }
45630
+ var init_shell_write_gate = __esm(() => {
45631
+ init_write_gate();
45632
+ init_write_gate_prompt();
45633
+ init_write_gate_guard();
45634
+ });
45635
+
45156
45636
  // src/commands/shell.ts
45157
45637
  var exports_shell = {};
45158
45638
  __export(exports_shell, {
@@ -45259,7 +45739,18 @@ async function runShell(options, configPath) {
45259
45739
  columnsByTable,
45260
45740
  commandNames
45261
45741
  };
45262
- const engine = new ReplEngine(adapter, context, HISTORY_PATH, config);
45742
+ let replInterface = null;
45743
+ const { createPromptAsker: createPromptAsker2 } = await Promise.resolve().then(() => exports_shell_prompt_asker);
45744
+ const asker = createPromptAsker2(() => replInterface);
45745
+ const { toSqlDialect: toSqlDialect2 } = await Promise.resolve().then(() => (init_permission_guard(), exports_permission_guard));
45746
+ const dialect = toSqlDialect2(config.connection.system);
45747
+ const writeGate = dialect ? (await Promise.resolve().then(() => (init_shell_write_gate(), exports_shell_write_gate))).createShellWriteGate({
45748
+ config,
45749
+ configPath,
45750
+ dialect,
45751
+ ask: asker.ask
45752
+ }) : null;
45753
+ const engine = new ReplEngine(adapter, context, HISTORY_PATH, config, writeGate);
45263
45754
  const complete = createCompleter(context);
45264
45755
  console.error(import_picocolors5.default.bold(t_vars("shell.welcome", {
45265
45756
  system: config.connection.system,
@@ -45293,10 +45784,22 @@ async function runShell(options, configPath) {
45293
45784
  completer: (line) => complete(line),
45294
45785
  terminal: process.stdin.isTTY ?? false
45295
45786
  });
45787
+ replInterface = rl;
45296
45788
  const continuationPrompt = import_picocolors5.default.dim(t("shell.continuation_prompt") + "> ");
45297
45789
  rl.prompt();
45298
- rl.on("line", async (line) => {
45299
- const result = await engine.processInput(line);
45790
+ const handleLine = async (line) => {
45791
+ rl.pause();
45792
+ let result;
45793
+ try {
45794
+ result = await engine.processInput(line);
45795
+ } catch (error) {
45796
+ console.error(import_picocolors5.default.red(t_vars("shell.error_sql_failed", { message: error.message })));
45797
+ rl.setPrompt(import_picocolors5.default.cyan(t("shell.prompt") + "> "));
45798
+ rl.prompt();
45799
+ return;
45800
+ } finally {
45801
+ rl.resume();
45802
+ }
45300
45803
  switch (result.action) {
45301
45804
  case "quit":
45302
45805
  if (result.output)
@@ -45307,16 +45810,21 @@ async function runShell(options, configPath) {
45307
45810
  console.clear();
45308
45811
  break;
45309
45812
  case "multiline":
45310
- rl.setPrompt(continuationPrompt);
45813
+ if (result.output)
45814
+ console.error(result.output);
45311
45815
  break;
45312
45816
  case "continue":
45313
45817
  if (result.output) {
45314
45818
  console.log(result.output);
45315
45819
  }
45316
- rl.setPrompt(import_picocolors5.default.cyan(t("shell.prompt") + "> "));
45317
45820
  break;
45318
45821
  }
45822
+ rl.setPrompt(engine.isMultiline() ? continuationPrompt : import_picocolors5.default.cyan(t("shell.prompt") + "> "));
45319
45823
  rl.prompt();
45824
+ };
45825
+ let pending = Promise.resolve();
45826
+ rl.on("line", (line) => {
45827
+ pending = pending.then(() => handleLine(line));
45320
45828
  });
45321
45829
  rl.on("close", async () => {
45322
45830
  console.error(import_picocolors5.default.dim(t("shell.goodbye")));
@@ -45325,7 +45833,10 @@ async function runShell(options, configPath) {
45325
45833
  process.exit(0);
45326
45834
  });
45327
45835
  rl.on("SIGINT", () => {
45836
+ if (asker.cancel())
45837
+ return;
45328
45838
  if (engine.isMultiline()) {
45839
+ engine.cancelMultiline();
45329
45840
  console.error(import_picocolors5.default.dim(t("shell.multiline_cancelled")));
45330
45841
  rl.setPrompt(import_picocolors5.default.cyan(t("shell.prompt") + "> "));
45331
45842
  }
@@ -45341,7 +45852,10 @@ async function runBatchSession(engine, input) {
45341
45852
  continue;
45342
45853
  const result = await engine.processInput(line);
45343
45854
  if (result.output) {
45344
- console.log(result.output);
45855
+ if (result.action === "multiline")
45856
+ console.error(result.output);
45857
+ else
45858
+ console.log(result.output);
45345
45859
  }
45346
45860
  if (result.action === "quit") {
45347
45861
  return;