@carllee1983/dbcli 1.46.0 → 1.47.1

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/dist/core.mjs CHANGED
@@ -16725,9 +16725,22 @@ class ElasticsearchAdapter {
16725
16725
  }
16726
16726
  }
16727
16727
 
16728
+ // src/utils/connection-timeout.ts
16729
+ var _globalConnectionTimeout;
16730
+ function resolveConnectionTimeout(configured) {
16731
+ return _globalConnectionTimeout ?? configured;
16732
+ }
16733
+ function withResolvedTimeout(options) {
16734
+ const timeout = resolveConnectionTimeout(options.timeout);
16735
+ if (timeout === options.timeout)
16736
+ return options;
16737
+ return { ...options, timeout };
16738
+ }
16739
+
16728
16740
  // src/adapters/factory.ts
16729
16741
  class AdapterFactory {
16730
- static createSqlAdapter(options) {
16742
+ static createSqlAdapter(rawOptions) {
16743
+ const options = withResolvedTimeout(rawOptions);
16731
16744
  switch (options.system) {
16732
16745
  case "postgresql":
16733
16746
  return new PostgreSQLAdapter(options);
@@ -16738,7 +16751,8 @@ class AdapterFactory {
16738
16751
  throw new Error(`createSqlAdapter requires a SQL system, got: ${options.system}`);
16739
16752
  }
16740
16753
  }
16741
- static createQueryableAdapter(options) {
16754
+ static createQueryableAdapter(rawOptions) {
16755
+ const options = withResolvedTimeout(rawOptions);
16742
16756
  switch (options.system) {
16743
16757
  case "mongodb":
16744
16758
  return new MongoDBAdapter(options);
@@ -16770,10 +16784,11 @@ class AdapterFactory {
16770
16784
  }
16771
16785
  return AdapterFactory.createQueryableAdapter(options);
16772
16786
  }
16773
- static createRedisAdapter(options, blacklistRules = [], maskRules = []) {
16774
- if (options.system !== "redis") {
16787
+ static createRedisAdapter(rawOptions, blacklistRules = [], maskRules = []) {
16788
+ if (rawOptions.system !== "redis") {
16775
16789
  throw new Error("createRedisAdapter requires system: redis");
16776
16790
  }
16791
+ const options = withResolvedTimeout(rawOptions);
16777
16792
  const adapter = new RedisAdapter(options);
16778
16793
  adapter.setBlacklistRules(blacklistRules);
16779
16794
  adapter.setMaskRules(maskRules);
@@ -16802,6 +16817,7 @@ function normalizeSQL(sql) {
16802
16817
  return sql.replace(/--[^\n]*\n/g, `
16803
16818
  `).replace(/\/\*[\s\S]*?\*\//g, " ").trim().replace(/\s+/g, " ");
16804
16819
  }
16820
+ var IDENTIFIER_CONTINUATION = /[A-Za-z0-9_$]|[\u0080-\uFFFF]/;
16805
16821
  function stripCommentsAndStrings(sql, options = {}) {
16806
16822
  let result = "";
16807
16823
  let i = 0;
@@ -16844,11 +16860,19 @@ function stripCommentsAndStrings(sql, options = {}) {
16844
16860
  i = closingIndex === -1 ? sql.length : closingIndex + 2;
16845
16861
  continue;
16846
16862
  }
16863
+ const nests = options.dialect === "postgresql";
16864
+ let depth = 1;
16847
16865
  i += 2;
16848
- while (i < sql.length) {
16866
+ while (i < sql.length && depth > 0) {
16867
+ if (nests && sql[i] === "/" && sql[i + 1] === "*") {
16868
+ depth++;
16869
+ i += 2;
16870
+ continue;
16871
+ }
16849
16872
  if (sql[i] === "*" && sql[i + 1] === "/") {
16873
+ depth--;
16850
16874
  i += 2;
16851
- break;
16875
+ continue;
16852
16876
  }
16853
16877
  i++;
16854
16878
  }
@@ -16856,7 +16880,8 @@ function stripCommentsAndStrings(sql, options = {}) {
16856
16880
  continue;
16857
16881
  }
16858
16882
  if (options.dialect === "postgresql" && char === "$") {
16859
- const delimiter = sql.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0];
16883
+ const opensToken = !IDENTIFIER_CONTINUATION.test(sql[i - 1] ?? "");
16884
+ const delimiter = opensToken ? sql.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0] : undefined;
16860
16885
  if (delimiter) {
16861
16886
  i += delimiter.length;
16862
16887
  const closingIndex = sql.indexOf(delimiter, i);
@@ -16884,7 +16909,7 @@ function stripCommentsAndStrings(sql, options = {}) {
16884
16909
  if (char === "'" || char === '"') {
16885
16910
  const quote = char;
16886
16911
  const quoteIndex = i;
16887
- const postgresEscapeString = options.dialect === "postgresql" && quote === "'" && /[eE]/.test(sql[quoteIndex - 1] ?? "") && !/[A-Za-z0-9_$]/.test(sql[quoteIndex - 2] ?? "");
16912
+ const postgresEscapeString = options.dialect === "postgresql" && quote === "'" && /[eE]/.test(sql[quoteIndex - 1] ?? "") && !IDENTIFIER_CONTINUATION.test(sql[quoteIndex - 2] ?? "");
16888
16913
  const backslashEscapes = options.dialect === undefined || postgresEscapeString;
16889
16914
  i++;
16890
16915
  while (i < sql.length) {
@@ -17060,8 +17085,59 @@ function classifyStatement(sql) {
17060
17085
  confidence: determineConfidence(type, firstKeyword, upper)
17061
17086
  };
17062
17087
  }
17063
- function checkPermission(sql, permission) {
17064
- const classification = classifyStatement(sql);
17088
+ var SQL_DIALECTS = ["postgresql", "mysql", "mariadb"];
17089
+ var SQL_WRITE_OR_DDL_KEYWORDS = /(?<![.\w])(INSERT|UPDATE|DELETE|MERGE|UPSERT|REPLACE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|RENAME|INTO)\b(?!\s*\()/i;
17090
+ var SQL_LOCK_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b|\bFOR\s+(?:KEY\s+)?SHARE\b/gi;
17091
+ function findWriteKeyword(sql, dialects) {
17092
+ const candidates = dialects && dialects.length > 0 ? dialects : SQL_DIALECTS;
17093
+ for (const dialect of candidates) {
17094
+ const executable = stripCommentsAndStrings(sql, { dialect }).replace(SQL_LOCK_CLAUSE, " ");
17095
+ const match = executable.match(SQL_WRITE_OR_DDL_KEYWORDS);
17096
+ if (match?.[1])
17097
+ return match[1].toUpperCase();
17098
+ }
17099
+ return;
17100
+ }
17101
+ function containsMultipleStatements(sql, dialect) {
17102
+ const statementCount = (candidate) => stripCommentsAndStrings(sql, { dialect: candidate }).split(";").filter((part) => part.trim().length > 0).length;
17103
+ if (dialect)
17104
+ return statementCount(dialect) > 1;
17105
+ return SQL_DIALECTS.some((candidate) => statementCount(candidate) > 1);
17106
+ }
17107
+ var ESCALATABLE_READ_TYPES = new Set(["SELECT", "EXPLAIN", "DESCRIBE"]);
17108
+ function escalateHiddenWrite(sql, classification, dialect) {
17109
+ if (!ESCALATABLE_READ_TYPES.has(classification.type))
17110
+ return classification;
17111
+ const plansOnly = classification.type === "EXPLAIN" || classification.type === "DESCRIBE";
17112
+ if (plansOnly && !/\bANALYZE\b/i.test(sql))
17113
+ return classification;
17114
+ const hidden = findWriteKeyword(sql, dialect ? [dialect] : undefined);
17115
+ if (!hidden)
17116
+ return classification;
17117
+ return {
17118
+ ...classification,
17119
+ type: "UNKNOWN",
17120
+ isDangerous: true,
17121
+ confidence: "HIGH",
17122
+ escalatedFrom: hidden
17123
+ };
17124
+ }
17125
+ function checkPermission(sql, permission, dialect) {
17126
+ const classification = escalateHiddenWrite(sql, classifyStatement(sql), dialect);
17127
+ if (permission !== "admin" && containsMultipleStatements(sql, dialect)) {
17128
+ return {
17129
+ allowed: false,
17130
+ reason: "SQL containing multiple statements is refused below admin permission, because only " + "the first statement determines the permission check. Run each statement separately.",
17131
+ classification
17132
+ };
17133
+ }
17134
+ if (permission !== "admin" && classification.escalatedFrom) {
17135
+ return {
17136
+ allowed: false,
17137
+ reason: `This statement opens as a read but contains an executable ` + `${classification.escalatedFrom}. A write hidden inside a read statement ` + `requires admin permission (current level: ${permission}).`,
17138
+ classification
17139
+ };
17140
+ }
17065
17141
  if (permission === "admin") {
17066
17142
  return {
17067
17143
  allowed: true,
@@ -17121,8 +17197,8 @@ function checkPermission(sql, permission) {
17121
17197
  classification
17122
17198
  };
17123
17199
  }
17124
- function enforcePermission(sql, permission) {
17125
- const result = checkPermission(sql, permission);
17200
+ function enforcePermission(sql, permission, dialect) {
17201
+ const result = checkPermission(sql, permission, dialect);
17126
17202
  if (!result.allowed) {
17127
17203
  throw new PermissionError(result.reason, result.classification, permission);
17128
17204
  }
@@ -21851,6 +21927,11 @@ var StringOrEnvRef = exports_external.union([exports_external.string().min(1), E
21851
21927
  var NumberOrEnvRef = exports_external.union([exports_external.number().int().min(1).max(65535), EnvRefSchema]);
21852
21928
  var OptStringOrEnvRef = exports_external.union([exports_external.string(), EnvRefSchema]).optional().default("");
21853
21929
  var OptNumberOrEnvRef = exports_external.union([exports_external.number().int(), EnvRefSchema]).optional().default(27017);
21930
+ var MIN_CONNECTION_TIMEOUT_MS = 100;
21931
+ var MAX_CONNECTION_TIMEOUT_MS = 600000;
21932
+ var TimeoutField = {
21933
+ timeout: exports_external.number().int().min(MIN_CONNECTION_TIMEOUT_MS).max(MAX_CONNECTION_TIMEOUT_MS).optional()
21934
+ };
21854
21935
  var MongoDBConnectionConfigSchema = exports_external.object({
21855
21936
  system: exports_external.literal("mongodb"),
21856
21937
  uri: exports_external.union([exports_external.string(), EnvRefSchema]).optional(),
@@ -21862,7 +21943,8 @@ var MongoDBConnectionConfigSchema = exports_external.object({
21862
21943
  authSource: exports_external.union([exports_external.string(), EnvRefSchema]).optional(),
21863
21944
  replicaSet: exports_external.union([exports_external.string(), EnvRefSchema]).optional(),
21864
21945
  tls: exports_external.boolean().optional(),
21865
- srv: exports_external.boolean().optional().default(false)
21946
+ srv: exports_external.boolean().optional().default(false),
21947
+ ...TimeoutField
21866
21948
  });
21867
21949
  var SqlConnectionConfigSchema = exports_external.object({
21868
21950
  system: exports_external.enum(["postgresql", "mysql", "mariadb"]),
@@ -21870,7 +21952,8 @@ var SqlConnectionConfigSchema = exports_external.object({
21870
21952
  port: NumberOrEnvRef,
21871
21953
  user: StringOrEnvRef,
21872
21954
  password: exports_external.union([exports_external.string(), EnvRefSchema]).default(""),
21873
- database: StringOrEnvRef
21955
+ database: StringOrEnvRef,
21956
+ ...TimeoutField
21874
21957
  });
21875
21958
  var RedisConnectionConfigSchema = exports_external.object({
21876
21959
  system: exports_external.literal("redis"),
@@ -21878,7 +21961,8 @@ var RedisConnectionConfigSchema = exports_external.object({
21878
21961
  port: NumberOrEnvRef,
21879
21962
  user: OptStringOrEnvRef,
21880
21963
  password: exports_external.union([exports_external.string(), EnvRefSchema]).optional().default(""),
21881
- database: OptStringOrEnvRef
21964
+ database: OptStringOrEnvRef,
21965
+ ...TimeoutField
21882
21966
  });
21883
21967
  var ElasticsearchConnectionConfigSchema = exports_external.object({
21884
21968
  system: exports_external.literal("elasticsearch"),
@@ -21892,7 +21976,8 @@ var ElasticsearchConnectionConfigSchema = exports_external.object({
21892
21976
  cloudId: exports_external.union([exports_external.string(), EnvRefSchema]).optional(),
21893
21977
  apiKey: exports_external.union([exports_external.string(), EnvRefSchema]).optional(),
21894
21978
  caPath: exports_external.string().optional(),
21895
- rejectUnauthorized: exports_external.boolean().optional().default(true)
21979
+ rejectUnauthorized: exports_external.boolean().optional().default(true),
21980
+ ...TimeoutField
21896
21981
  });
21897
21982
  var ConnectionConfigSchema = exports_external.union([
21898
21983
  SqlConnectionConfigSchema,
@@ -21982,6 +22067,89 @@ var DbcliConfigV2Schema = exports_external.object({
21982
22067
  path: ["default"]
21983
22068
  });
21984
22069
 
22070
+ // src/utils/config-error-format.ts
22071
+ var SUPPORTED_SYSTEMS = [
22072
+ "postgresql",
22073
+ "mysql",
22074
+ "mariadb",
22075
+ "mongodb",
22076
+ "redis",
22077
+ "elasticsearch"
22078
+ ];
22079
+ function isConnectionNode(raw, path) {
22080
+ const last = path[path.length - 1];
22081
+ const parent = path[path.length - 2];
22082
+ if (last !== "connection" && parent !== "connections")
22083
+ return false;
22084
+ const node = valueAtPath(raw, path);
22085
+ return typeof node === "object" && node !== null && !Array.isArray(node);
22086
+ }
22087
+ function valueAtPath(raw, path) {
22088
+ let current = raw;
22089
+ for (const key of path) {
22090
+ if (current == null || typeof current !== "object")
22091
+ return;
22092
+ current = current[key];
22093
+ }
22094
+ return current;
22095
+ }
22096
+ function declaredSystem(raw, path) {
22097
+ const node = valueAtPath(raw, path);
22098
+ if (node == null || typeof node !== "object")
22099
+ return;
22100
+ const system = node.system;
22101
+ return typeof system === "string" ? system : undefined;
22102
+ }
22103
+ function branchMatchesSystem(issues) {
22104
+ return !issues.some((issue) => issue.path[issue.path.length - 1] === "system" && (issue.code === "invalid_literal" || issue.code === "invalid_enum_value" || issue.code === "invalid_type"));
22105
+ }
22106
+ function formatPath(path) {
22107
+ return path.length > 0 ? path.join(".") : "(root)";
22108
+ }
22109
+ function flattenIssues(issues, raw, basePath = []) {
22110
+ const flat = [];
22111
+ for (const issue of issues) {
22112
+ const fullPath = [...basePath, ...issue.path];
22113
+ if (issue.code === "invalid_union") {
22114
+ const system = declaredSystem(raw, fullPath);
22115
+ const branches = issue.unionErrors.map((error) => error.issues);
22116
+ const candidates = system ? branches.filter((branchIssues) => branchMatchesSystem(branchIssues)) : [];
22117
+ if (isConnectionNode(raw, fullPath) && candidates.length === 0) {
22118
+ flat.push({
22119
+ path: formatPath([...fullPath, "system"]),
22120
+ message: `must be one of ${SUPPORTED_SYSTEMS.join(" | ")}` + (system === undefined ? " (missing)" : ` (received '${system}')`)
22121
+ });
22122
+ continue;
22123
+ }
22124
+ const chosen = candidates[0] ?? branches.reduce((best, current) => current.length < best.length ? current : best, branches[0] ?? []);
22125
+ flat.push(...flattenIssues(chosen, raw, basePath));
22126
+ continue;
22127
+ }
22128
+ flat.push({ path: formatPath(fullPath), message: issue.message });
22129
+ }
22130
+ return flat;
22131
+ }
22132
+ function dedupe(issues) {
22133
+ const seen = new Set;
22134
+ return issues.filter((issue) => {
22135
+ const key = `${issue.path}\x00${issue.message}`;
22136
+ if (seen.has(key))
22137
+ return false;
22138
+ seen.add(key);
22139
+ return true;
22140
+ });
22141
+ }
22142
+ function formatConfigValidationError(error, raw) {
22143
+ const issues = dedupe(flattenIssues(error.issues, raw));
22144
+ if (issues.length === 0)
22145
+ return error.message;
22146
+ return issues.map((issue) => ` - ${issue.path}: ${issue.message}`).join(`
22147
+ `);
22148
+ }
22149
+ function isZodError(error) {
22150
+ return error instanceof Error && error.name === "ZodError" && Array.isArray(error.issues);
22151
+ }
22152
+
21985
22153
  // src/agent-core/env-loader.ts
21986
22154
  import { readFile as readFile3 } from "fs/promises";
21987
22155
  function parseEnvContent(content) {
@@ -22188,6 +22356,7 @@ function parseEnvPassword(content) {
22188
22356
  var configModule = {
22189
22357
  async read(path, connectionName, options = {}) {
22190
22358
  const effectiveConnectionName = connectionName ?? _globalConnectionName;
22359
+ let rawForDiagnostics;
22191
22360
  try {
22192
22361
  const binding = await readProjectBinding(path);
22193
22362
  const storagePath = await resolveConfigStoragePath(path);
@@ -22213,6 +22382,7 @@ var configModule = {
22213
22382
  const content = await configFile.text();
22214
22383
  await assertConfigIntegrity(storagePath, content, { requireRecord: true });
22215
22384
  const config = JSON.parse(content);
22385
+ rawForDiagnostics = config;
22216
22386
  if (detectConfigVersion(config) === 2) {
22217
22387
  const v2Config = DbcliConfigV2Schema.parse(config);
22218
22388
  const resolved = resolveConnection(v2Config, effectiveConnectionName);
@@ -22293,6 +22463,7 @@ var configModule = {
22293
22463
  assertNoConnectionSelectorOnV1(effectiveConnectionName);
22294
22464
  const content = await file.text();
22295
22465
  const raw = JSON.parse(content);
22466
+ rawForDiagnostics = raw;
22296
22467
  const resolved = resolveEnvReferences(raw, process.env);
22297
22468
  return DbcliConfigSchema.parse(resolved);
22298
22469
  }
@@ -22303,6 +22474,10 @@ var configModule = {
22303
22474
  } catch (error) {
22304
22475
  if (error instanceof ConfigError)
22305
22476
  throw error;
22477
+ if (isZodError(error)) {
22478
+ throw new ConfigError(`Failed to read .dbcli config: \u8A2D\u5B9A\u5167\u5BB9\u4E0D\u7B26\u5408\u7D50\u69CB
22479
+ ${formatConfigValidationError(error, rawForDiagnostics)}`);
22480
+ }
22306
22481
  if (error instanceof Error && error.message.includes("JSON")) {
22307
22482
  throw new ConfigError(`Failed to parse .dbcli file: ${error.message}`);
22308
22483
  }
@@ -22313,6 +22488,10 @@ var configModule = {
22313
22488
  try {
22314
22489
  return DbcliConfigSchema.parse(raw);
22315
22490
  } catch (error) {
22491
+ if (isZodError(error)) {
22492
+ throw new ConfigError(`Invalid .dbcli config structure:
22493
+ ${formatConfigValidationError(error, raw)}`);
22494
+ }
22316
22495
  const errorMessage = error instanceof Error ? error.message : String(error);
22317
22496
  throw new ConfigError(`Invalid .dbcli config structure: ${errorMessage}`);
22318
22497
  }
@@ -22413,6 +22592,7 @@ var KEEP_VALUE_FLAGS = new Set([
22413
22592
  "--min-severity",
22414
22593
  "--output",
22415
22594
  "--limit",
22595
+ "--timeout",
22416
22596
  "--collection",
22417
22597
  "--index",
22418
22598
  "--fields",
@@ -22766,6 +22946,12 @@ class QueryExecutor {
22766
22946
  this.config = config;
22767
22947
  this.options = options;
22768
22948
  }
22949
+ resolveDialect() {
22950
+ if (this.options.dialect)
22951
+ return this.options.dialect;
22952
+ const system = this.config?.connection?.system;
22953
+ return SQL_DIALECTS.find((dialect) => dialect === system);
22954
+ }
22769
22955
  takeDiagnostics() {
22770
22956
  const diagnostics = this.pendingDiagnostics;
22771
22957
  this.pendingDiagnostics = [];
@@ -22775,7 +22961,7 @@ class QueryExecutor {
22775
22961
  const start = performance.now();
22776
22962
  this.pendingDiagnostics = [];
22777
22963
  try {
22778
- const classification = enforcePermission(sql, this.permission);
22964
+ const classification = enforcePermission(sql, this.permission, this.resolveDialect());
22779
22965
  const dangerousOperationWarning = classification.isDangerous && this.permission === "admin" ? `\u26A0 Warning: executing ${classification.type} operation (admin mode)` : undefined;
22780
22966
  const AUTO_LIMIT_TYPES = new Set(["SELECT"]);
22781
22967
  let executeSql = sql;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "1.46.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI skill and command reference for AI agents.",
5
5
  "contextFileName": "AGENTS.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carllee1983/dbcli",
3
- "version": "1.46.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI for AI agents",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "1.46.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI skill and command reference for AI agents",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -77,7 +77,10 @@ When reporting a check's outcome use the vocabulary `verified` (evidence matched
77
77
  `not_verified` (check ran and contradicted) / `indeterminate` (ran but ambiguous) /
78
78
  `blocked` (could not run due to config, permission, schema, placeholder, or safety gate).
79
79
 
80
- Prefer `--format json` for agent-friendly output.
80
+ Prefer `--format json` for agent-friendly output. Diagnostics (auto-limit notices,
81
+ warnings) go to stderr so stdout stays parseable — when piping JSON into a parser,
82
+ use `2>/dev/null` or leave stderr alone. **Never `2>&1`**: it merges those lines back
83
+ into stdout and the parse fails.
81
84
 
82
85
  ## Agent Task Packs
83
86
 
@@ -214,7 +217,10 @@ or `doctor` / `status` reports a missing or invalid config, follow this flow.
214
217
  `--password` / `--name` (and `--system`).
215
218
  3. **What permission tier?** Default to the **lowest** that satisfies the task:
216
219
  `query-only` → `read-write` → `data-admin` → `admin`. Set with `--permission`
217
- (defaults to `query-only`).
220
+ (defaults to `query-only`). Tiers judge what a statement does, not how it
221
+ opens: below `admin`, multi-statement SQL is rejected; snippets must be free
222
+ of write and DDL keywords; MongoDB `$out` / `$merge` need `data-admin` and are
223
+ refused entirely in snippets and `export`.
218
224
  4. **Verify, never assume.** After init: `dbcli status` (system + permission +
219
225
  blacklist summary, no creds) and `dbcli doctor --format json` (env, config
220
226
  shape, connectivity, schema-cache age, Mongo SRV path).
@@ -553,4 +559,4 @@ schema. Raw `query` / `export` invocations render a sortable table only.
553
559
  - Blacklisted tables and columns are redacted from query output.
554
560
  - `schema` reports `estimatedRowCount` and `sizeCategory` (small / medium / large / huge). For large/huge tables add `WHERE` or `LIMIT` — bands in reference.md.
555
561
  - `doctor` on `mongodb+srv://` reports whether SRV resolves natively or through the DoH fallback — useful when the runtime restricts DNS.
556
- - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
562
+ - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `--timeout <ms>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
@@ -19,6 +19,28 @@ command-level option is only valid after the command that declares it.
19
19
  | `--config <path>` | Select the `.dbcli` configuration path. |
20
20
  | `--global` | Select the user-global registry at `~/.config/dbcli/config.json` instead of the current project's `.dbcli` config. Place it before the command path. |
21
21
  | `--use <connection>` | Select a named connection for this invocation; place it before the command path unless that command explicitly lists a command-level `--use`. |
22
+ | `--timeout <ms>` | Connection timeout in milliseconds (integer, 100–600000), overriding the connection config's `timeout` field for this invocation. Applies to every engine adapter. Without either the flag or the config field, adapters fall back to their built-in 5000ms default. |
23
+
24
+ `--timeout` is applied only when the adapter is constructed for this invocation — it is
25
+ never written back to `config.json`. Set the connection's `timeout` field instead for a
26
+ value that persists across runs. On PostgreSQL, the same value is also used as the
27
+ session's `statement_timeout` (not just the connection timeout), so a low value can cut
28
+ off a long-running query with an error that looks like a connection timeout; the 100ms
29
+ floor exists specifically to keep that failure mode from being too easy to trigger.
30
+ Elasticsearch applies its timeout per request rather than once for the whole connection.
31
+ The `timeout` field itself always takes a literal number — unlike other connection
32
+ fields, it does not accept an `{"$env": "..."}` reference.
33
+
34
+ ### Redirecting output
35
+
36
+ Results go to stdout; diagnostics (auto-limit notices, warnings, update hints) go to
37
+ stderr. That split is what keeps `--format json` machine-parseable, so do not collapse
38
+ it with `2>&1` — the diagnostic lines land in front of the JSON document and the parse
39
+ fails. Pipe stdout alone, or add `2>/dev/null` when the diagnostics are not wanted:
40
+
41
+ ```bash
42
+ dbcli query '{}' --collection events --format json 2>/dev/null | jq '.rows | length'
43
+ ```
22
44
 
23
45
  ## Commands
24
46
 
@@ -118,6 +140,10 @@ agent mode disabled. A host that needs protection from a same-user hostile
118
140
  process can set `DBCLI_CONFIG_INTEGRITY_ANCHOR_DIR` to a protected or read-only
119
141
  directory; trusted writes publish detached digests there.
120
142
 
143
+ When a connection's config fails schema validation, dbcli reports the specific field
144
+ path(s) that are wrong for that connection's declared `system` — not the raw Zod union
145
+ error tree — so a broken `.dbcli` can be fixed without guessing which branch applies.
146
+
121
147
  ### list
122
148
 
123
149
  List all tables (SQL), collections (MongoDB), keys (Redis), or indices (Elasticsearch).
@@ -197,6 +223,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
197
223
  **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]>`, `--recovery`
198
224
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
199
225
 
226
+ Below `admin`, SQL holding more than one statement is rejected, because only the
227
+ first statement would decide the permission check while a driver on the simple
228
+ query protocol executes them all. Semicolons inside string literals, backtick
229
+ identifiers, and `#` comments are not separators. A MongoDB pipeline containing
230
+ `$out` or `$merge` requires `data-admin`, and is rejected outright on `export`,
231
+ in snippets, and in multi-connection fan-out.
232
+
200
233
  #### Field projection (`--fields`)
201
234
 
202
235
  ```bash
@@ -253,7 +286,7 @@ instead of silently running its only connection.
253
286
  An explicit comma-separated `--use primary,staging` fans one query out to several named
254
287
  connections. `DBCLI_CONNECTION` always names one literal connection and never enables
255
288
  fan-out. SQL permits `SELECT`, `SHOW`, `DESCRIBE`, and `EXPLAIN`; MongoDB permits filters and
256
- read-only pipelines without top-level `$out` / `$merge`; Elasticsearch permits searches.
289
+ read-only pipelines without `$out` / `$merge`; Elasticsearch permits searches.
257
290
  Redis, writes, `--recovery`, `--ui`, CSV, and HTML are rejected before execution. Each
258
291
  connection keeps its own blacklist, limit metadata, audit entry, and error. Aggregate exit
259
292
  codes are `0` when all succeed, `2` for mixed outcomes, and `1` when all fail or preflight
@@ -505,6 +538,13 @@ dbcli q @analytics/revenue --param days=30 --format html > report.html
505
538
 
506
539
  Each `.sql` file is plain SQL with optional YAML frontmatter inside a leading `-- ---` block. Lines outside frontmatter form the SQL body.
507
540
 
541
+ Snippets are read-only by contract, at every permission level including `admin`.
542
+ A body must be a single statement opening with `SELECT` or `WITH` **and** free of
543
+ write or DDL keywords, so a data-modifying CTE (`WITH x AS (DELETE … RETURNING *)
544
+ SELECT * FROM x`) and `SELECT … INTO` are rejected at parse time rather than at
545
+ execution. A MongoDB body may not contain `$out` or `$merge`. The same rule
546
+ applies to `verify.query` in frontmatter, which `q --verify` executes verbatim.
547
+
508
548
  ```sql
509
549
  -- ---
510
550
  -- name: DAU
@@ -2605,6 +2645,8 @@ MongoDB connections use a JSON-based query model instead of SQL. Treat MongoDB s
2605
2645
 
2606
2646
  `init --system mongodb` defaults to a field-by-field wizard (`host`, `srv`, `port`, `user`, `password` + `authSource`, then optional `replicaSet` / `tls`); a full `uri` is an explicit advanced choice in the interactive flow and the unchanged non-interactive path via `--uri`. Optional fields `authSource`, `replicaSet`, `tls`, and `srv` express what previously required embedding options in the `uri` query string. Atlas-style `mongodb+srv://` URIs are supported both as a full `uri` and via the per-field `srv: true` option. `list` and `query` run against the database configured for the connection, and `query` always requires `--collection <name>`.
2607
2647
 
2648
+ The 5000ms default server-selection timeout is often too tight for a connection over a VPN or to Atlas. Set a `timeout` field (ms) in the connection config, or override it per invocation with root-level `--timeout`, e.g. `dbcli --timeout 20000 --use <conn> list`.
2649
+
2608
2650
  **Supported commands:** `init`, `use`, `list`, `schema`, `query`, `q`, `insert`, `update`, `delete`, `export`, `status`, `shell`, `doctor`, `upgrade`, `completion`
2609
2651
 
2610
2652
  **Limited support:**
@@ -2669,7 +2711,7 @@ Redis connections speak Redis commands rather than SQL. The adapter uses Bun's n
2669
2711
 
2670
2712
  - Required fields: `system: redis`, `host`, `port`. `password` and `database` are optional.
2671
2713
  - `database` is the **logical DB index** (`"0"` … `"15"`), kept as a string to play nicely with env-ref bindings. `list` and the connection metadata both label it as the active DB.
2672
- - `connection.timeout` (ms, default 5000) maps to the client's `connectionTimeout`.
2714
+ - `connection.timeout` (ms, default 5000) maps to the client's `connectionTimeout`; root-level `--timeout <ms>` overrides it for a single invocation.
2673
2715
 
2674
2716
  ### Permission classification
2675
2717
 
@@ -2799,7 +2841,7 @@ Elasticsearch connections speak the REST API. The adapter is fetch-based (no SDK
2799
2841
  - Either `host` + `port` (default `https://localhost:9200`) or `nodes: [...]` (first node is used) or `cloudId`.
2800
2842
  - Auth precedence: `apiKey` → `user`/`password` (HTTP Basic). Leave both unset for an open cluster.
2801
2843
  - `protocol` defaults to `https`. For TLS quirks: `caPath` (path to a PEM bundle) and `rejectUnauthorized: false` (last resort).
2802
- - `connection.timeout` (ms, default 5000) is wired to `AbortController` on every request.
2844
+ - `connection.timeout` (ms, default 5000) is wired to `AbortController` on every request; root-level `--timeout <ms>` overrides it for a single invocation.
2803
2845
 
2804
2846
  ### Permission classification
2805
2847
 
@@ -77,7 +77,10 @@ When reporting a check's outcome use the vocabulary `verified` (evidence matched
77
77
  `not_verified` (check ran and contradicted) / `indeterminate` (ran but ambiguous) /
78
78
  `blocked` (could not run due to config, permission, schema, placeholder, or safety gate).
79
79
 
80
- Prefer `--format json` for agent-friendly output.
80
+ Prefer `--format json` for agent-friendly output. Diagnostics (auto-limit notices,
81
+ warnings) go to stderr so stdout stays parseable — when piping JSON into a parser,
82
+ use `2>/dev/null` or leave stderr alone. **Never `2>&1`**: it merges those lines back
83
+ into stdout and the parse fails.
81
84
 
82
85
  ## Agent Task Packs
83
86
 
@@ -214,7 +217,10 @@ or `doctor` / `status` reports a missing or invalid config, follow this flow.
214
217
  `--password` / `--name` (and `--system`).
215
218
  3. **What permission tier?** Default to the **lowest** that satisfies the task:
216
219
  `query-only` → `read-write` → `data-admin` → `admin`. Set with `--permission`
217
- (defaults to `query-only`).
220
+ (defaults to `query-only`). Tiers judge what a statement does, not how it
221
+ opens: below `admin`, multi-statement SQL is rejected; snippets must be free
222
+ of write and DDL keywords; MongoDB `$out` / `$merge` need `data-admin` and are
223
+ refused entirely in snippets and `export`.
218
224
  4. **Verify, never assume.** After init: `dbcli status` (system + permission +
219
225
  blacklist summary, no creds) and `dbcli doctor --format json` (env, config
220
226
  shape, connectivity, schema-cache age, Mongo SRV path).
@@ -553,4 +559,4 @@ schema. Raw `query` / `export` invocations render a sortable table only.
553
559
  - Blacklisted tables and columns are redacted from query output.
554
560
  - `schema` reports `estimatedRowCount` and `sizeCategory` (small / medium / large / huge). For large/huge tables add `WHERE` or `LIMIT` — bands in reference.md.
555
561
  - `doctor` on `mongodb+srv://` reports whether SRV resolves natively or through the DoH fallback — useful when the runtime restricts DNS.
556
- - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.
562
+ - **Global flags:** `--version`, `--config <path>`, `--global`, `--use <name>`, `--timeout <ms>`, `-v` / `--verbose` / `-vv`, `-q` / `--quiet`, `--no-color` (also honours `NO_COLOR`). Root-level flags must precede the command unless the command explicitly declares a command-level option.