@carllee1983/dbcli 9.0.0 → 9.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/dbcli/reference.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/skills/dbcli/reference.md +1 -1
- package/CHANGELOG.md +103 -14
- package/README.dev.md +1 -1
- package/README.md +77 -0
- package/assets/reference.md +1 -1
- package/dist/cli-runtime.mjs +805 -162
- package/dist/cli.mjs +3 -2
- package/dist/core.d.ts +452 -6
- package/dist/core.mjs +220 -19
- package/gemini-extension.json +1 -1
- package/package.json +3 -2
- package/plugins/dbcli-agent/.codex-plugin/plugin.json +1 -1
- package/plugins/dbcli-agent/skills/dbcli/reference.md +1 -1
- package/skills/dbcli/reference.md +1 -1
package/dist/core.mjs
CHANGED
|
@@ -1092,10 +1092,17 @@ var DATABASE_SYSTEMS = Object.freeze([
|
|
|
1092
1092
|
"postgresql",
|
|
1093
1093
|
"mysql",
|
|
1094
1094
|
"mariadb",
|
|
1095
|
+
"sqlite",
|
|
1095
1096
|
"mongodb",
|
|
1096
1097
|
"redis",
|
|
1097
1098
|
"elasticsearch"
|
|
1098
1099
|
]);
|
|
1100
|
+
var SQL_DATABASE_SYSTEMS = Object.freeze([
|
|
1101
|
+
"postgresql",
|
|
1102
|
+
"mysql",
|
|
1103
|
+
"mariadb",
|
|
1104
|
+
"sqlite"
|
|
1105
|
+
]);
|
|
1099
1106
|
|
|
1100
1107
|
class ConnectionError extends Error {
|
|
1101
1108
|
code;
|
|
@@ -1247,6 +1254,38 @@ var ENGINE_CAPABILITIES = Object.freeze({
|
|
|
1247
1254
|
...SQL_BASE,
|
|
1248
1255
|
check: cap("limited", "readonly", "SQL-only and strongest on MySQL/MariaDB.")
|
|
1249
1256
|
}),
|
|
1257
|
+
sqlite: Object.freeze({
|
|
1258
|
+
...SQL_BASE,
|
|
1259
|
+
init: cap("supported", "local-write", "Asks for a file path and a permission; the file must already exist."),
|
|
1260
|
+
use: cap("supported", "local-write", "Switches connections and displays the file path."),
|
|
1261
|
+
schemaFullScan: cap("unsupported", "none", "Full schema cache scan is not implemented for SQLite."),
|
|
1262
|
+
lint: cap("unsupported", "none", "Static lint is not enabled for the SQLite dialect yet."),
|
|
1263
|
+
queries: cap("supported", "local-write", "Snippets may declare `engine: sqlite` and bind with `?` placeholders."),
|
|
1264
|
+
insert: cap("supported", "db-write", "Row insert through the shared data-executor path."),
|
|
1265
|
+
update: cap("supported", "db-write", "Row update with a mandatory --where clause."),
|
|
1266
|
+
delete: cap("supported", "db-write", "Row delete with a mandatory --where clause."),
|
|
1267
|
+
export: cap("supported", "local-write", "Exports SELECT results to json, csv or html through the shared query path."),
|
|
1268
|
+
check: cap("unsupported", "none", "Data health check is not enabled for SQLite."),
|
|
1269
|
+
diff: cap("unsupported", "none", "Schema snapshots are not enabled for SQLite."),
|
|
1270
|
+
migrate: cap("unsupported", "none", "SQLite ALTER TABLE covers few operations and a column change is a table rebuild; no DDL generator exists."),
|
|
1271
|
+
shell: cap("unsupported", "none", "The REPL is not enabled for SQLite."),
|
|
1272
|
+
doctor: cap("supported", "readonly", "Checks the file exists, is readable, and is writable when the permission allows writes."),
|
|
1273
|
+
inspect: cap("unsupported", "none", "Inspection collectors are not enabled for SQLite."),
|
|
1274
|
+
report: cap("unsupported", "none", "Report diagnostics query engine-specific statistics views."),
|
|
1275
|
+
guide: cap("unsupported", "none", "Guides depend on engine-specific EXPLAIN output."),
|
|
1276
|
+
explain: cap("unsupported", "none", "EXPLAIN handling is not enabled for SQLite."),
|
|
1277
|
+
plan: cap("unsupported", "none", "Plan capture is not enabled for SQLite."),
|
|
1278
|
+
impactAssess: cap("unsupported", "none", "Impact assessment is not enabled for SQLite."),
|
|
1279
|
+
assert: cap("unsupported", "none", "Assertions are not enabled for SQLite."),
|
|
1280
|
+
snapshot: cap("unsupported", "none", "Result snapshots are not enabled for SQLite."),
|
|
1281
|
+
verify: cap("unsupported", "none", "Write verification is not enabled for SQLite."),
|
|
1282
|
+
semantic: cap("unsupported", "none", "Semantic context work is not enabled for SQLite."),
|
|
1283
|
+
design: cap("unsupported", "none", "Schema design work is not enabled for SQLite."),
|
|
1284
|
+
proxy: cap("not-applicable", "none", "SQLite has no network protocol to proxy."),
|
|
1285
|
+
proxyAnalyze: cap("not-applicable", "none", "There are no SQLite proxy events to analyse."),
|
|
1286
|
+
password: cap("not-applicable", "none", "A SQLite connection has no credential to store or rotate."),
|
|
1287
|
+
queryLimitGuard: cap("limited", "readonly", "Applies the same LIMIT rewrite as the other SQL engines; under query-only the handle is opened read-only, which a database with an unreplayed WAL cannot satisfy.")
|
|
1288
|
+
}),
|
|
1250
1289
|
mysql: Object.freeze(SQL_BASE),
|
|
1251
1290
|
mariadb: Object.freeze(SQL_BASE),
|
|
1252
1291
|
mongodb: Object.freeze({
|
|
@@ -1616,7 +1655,8 @@ var messages_default = {
|
|
|
1616
1655
|
connection_not_found: "Connection '{name}' does not exist",
|
|
1617
1656
|
cannot_remove_last: "Cannot remove the last connection",
|
|
1618
1657
|
connection_already_exists: "Connection '{name}' already exists",
|
|
1619
|
-
rename_invalid_format: "Usage: --rename <old-name>:<new-name>"
|
|
1658
|
+
rename_invalid_format: "Usage: --rename <old-name>:<new-name>",
|
|
1659
|
+
prompt_sqlite_file: "SQLite database file path: "
|
|
1620
1660
|
},
|
|
1621
1661
|
inspect: {
|
|
1622
1662
|
description: "Read-only snapshot of connection, permission, blacklist, objects, snippets, and suggested next commands.",
|
|
@@ -1785,7 +1825,8 @@ Or use interactive mode: run "dbcli init --use-env-refs" without --no-interactiv
|
|
|
1785
1825
|
env_var_not_defined: `Cannot test connection: environment variable {envKey} is not defined.
|
|
1786
1826
|
Set {envKey} in .env or environment variables.
|
|
1787
1827
|
Hint: run 'export {envKey}=<value>' and retry`,
|
|
1788
|
-
invalid_output_format: "--format {format} is not supported here \u2014 use text or json"
|
|
1828
|
+
invalid_output_format: "--format {format} is not supported here \u2014 use text or json",
|
|
1829
|
+
sqlite_file_required: "A SQLite connection needs a file path. Pass --file <path> or run init interactively."
|
|
1789
1830
|
},
|
|
1790
1831
|
success: {
|
|
1791
1832
|
inserted: "Successfully inserted {count} row(s)",
|
|
@@ -1946,7 +1987,8 @@ var messages_default2 = {
|
|
|
1946
1987
|
connection_not_found: "\u9023\u7DDA '{name}' \u4E0D\u5B58\u5728",
|
|
1947
1988
|
cannot_remove_last: "\u7121\u6CD5\u79FB\u9664\u6700\u5F8C\u4E00\u500B\u9023\u7DDA",
|
|
1948
1989
|
connection_already_exists: "\u9023\u7DDA '{name}' \u5DF2\u5B58\u5728",
|
|
1949
|
-
rename_invalid_format: "\u7528\u6CD5\uFF1A--rename <\u820A\u540D\u7A31>:<\u65B0\u540D\u7A31>"
|
|
1990
|
+
rename_invalid_format: "\u7528\u6CD5\uFF1A--rename <\u820A\u540D\u7A31>:<\u65B0\u540D\u7A31>",
|
|
1991
|
+
prompt_sqlite_file: "SQLite \u8CC7\u6599\u5EAB\u6A94\u6848\u8DEF\u5F91\uFF1A"
|
|
1950
1992
|
},
|
|
1951
1993
|
inspect: {
|
|
1952
1994
|
description: "\u552F\u8B80\u6AA2\u8996\uFF1A\u9023\u7DDA\u3001\u6B0A\u9650\u3001\u9ED1\u540D\u55AE\u3001\u7269\u4EF6\u3001\u7247\u6BB5\u8207\u5EFA\u8B70\u4E0B\u4E00\u6B65\u6307\u4EE4\u3002",
|
|
@@ -2115,7 +2157,8 @@ var messages_default2 = {
|
|
|
2115
2157
|
env_var_not_defined: `\u7121\u6CD5\u6E2C\u8A66\u9023\u7DDA\uFF1A\u74B0\u5883\u8B8A\u6578 {envKey} \u672A\u5B9A\u7FA9\u3002
|
|
2116
2158
|
\u8ACB\u5728 .env \u6216\u74B0\u5883\u8B8A\u6578\u4E2D\u8A2D\u5B9A {envKey}\u3002
|
|
2117
2159
|
\u63D0\u793A\uFF1A\u57F7\u884C 'export {envKey}=<value>' \u5F8C\u91CD\u8A66`,
|
|
2118
|
-
invalid_output_format: "\u7121\u6CD5\u4F7F\u7528 --format {format}\uFF0C\u8ACB\u4F7F\u7528 text \u6216 json"
|
|
2160
|
+
invalid_output_format: "\u7121\u6CD5\u4F7F\u7528 --format {format}\uFF0C\u8ACB\u4F7F\u7528 text \u6216 json",
|
|
2161
|
+
sqlite_file_required: "SQLite \u9023\u7DDA\u9700\u8981\u4E00\u500B\u6A94\u6848\u8DEF\u5F91\u3002\u8ACB\u7528 --file <path>\uFF0C\u6216\u4EE5\u4E92\u52D5\u6A21\u5F0F\u57F7\u884C init\u3002"
|
|
2119
2162
|
},
|
|
2120
2163
|
success: {
|
|
2121
2164
|
inserted: "\u6210\u529F\u63D2\u5165 {count} \u5217",
|
|
@@ -2649,7 +2692,7 @@ function fixDoubleEncodedUtf8(str) {
|
|
|
2649
2692
|
|
|
2650
2693
|
// src/adapters/identifier-quote.ts
|
|
2651
2694
|
function quoteCharFor(dialect) {
|
|
2652
|
-
return dialect === "postgresql" ? '"' : "`";
|
|
2695
|
+
return dialect === "postgresql" || dialect === "sqlite" ? '"' : "`";
|
|
2653
2696
|
}
|
|
2654
2697
|
function assertQuotable(name, what) {
|
|
2655
2698
|
if (name.length === 0) {
|
|
@@ -3951,7 +3994,16 @@ function stripCommentsAndStrings(sql, options = {}) {
|
|
|
3951
3994
|
continue;
|
|
3952
3995
|
}
|
|
3953
3996
|
}
|
|
3954
|
-
if (
|
|
3997
|
+
if (options.dialect === "sqlite" && char === "[") {
|
|
3998
|
+
i++;
|
|
3999
|
+
while (i < sql.length && sql[i] !== "]")
|
|
4000
|
+
i++;
|
|
4001
|
+
if (i < sql.length)
|
|
4002
|
+
i++;
|
|
4003
|
+
result += " ";
|
|
4004
|
+
continue;
|
|
4005
|
+
}
|
|
4006
|
+
if ((mysqlDialect || options.dialect === "sqlite") && char === "`") {
|
|
3955
4007
|
i++;
|
|
3956
4008
|
while (i < sql.length) {
|
|
3957
4009
|
if (sql[i] === "`") {
|
|
@@ -4024,8 +4076,14 @@ function extractFirstKeyword(sql) {
|
|
|
4024
4076
|
}
|
|
4025
4077
|
return firstKeyword;
|
|
4026
4078
|
}
|
|
4027
|
-
|
|
4079
|
+
var DECLARED_ADMIN_ONLY_KEYWORDS = new Set(["PRAGMA", "VACUUM", "REINDEX"]);
|
|
4080
|
+
function isDeclaredAdminOnly(keyword) {
|
|
4081
|
+
return DECLARED_ADMIN_ONLY_KEYWORDS.has(keyword.toUpperCase());
|
|
4082
|
+
}
|
|
4083
|
+
function mapKeywordToType(keyword, dialect) {
|
|
4028
4084
|
const upper = keyword.toUpperCase();
|
|
4085
|
+
if (upper === "REPLACE" && dialect === "sqlite")
|
|
4086
|
+
return "REPLACE";
|
|
4029
4087
|
if (upper === "SELECT")
|
|
4030
4088
|
return "SELECT";
|
|
4031
4089
|
if (upper === "SHOW")
|
|
@@ -4055,7 +4113,7 @@ function mapKeywordToType(keyword) {
|
|
|
4055
4113
|
return "UNKNOWN";
|
|
4056
4114
|
}
|
|
4057
4115
|
function isDestructiveOperation(type) {
|
|
4058
|
-
return ["DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT"].includes(type);
|
|
4116
|
+
return ["DELETE", "REPLACE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT"].includes(type);
|
|
4059
4117
|
}
|
|
4060
4118
|
function extractAllKeywords(sql) {
|
|
4061
4119
|
const upper = sql.toUpperCase();
|
|
@@ -4135,6 +4193,16 @@ function permissionAtLeast(actual, required) {
|
|
|
4135
4193
|
}
|
|
4136
4194
|
|
|
4137
4195
|
// src/core/permission-guard.ts
|
|
4196
|
+
class ConnectionBoundaryError extends Error {
|
|
4197
|
+
classification;
|
|
4198
|
+
constructor(message, classification) {
|
|
4199
|
+
super(message);
|
|
4200
|
+
this.classification = classification;
|
|
4201
|
+
this.name = "ConnectionBoundaryError";
|
|
4202
|
+
Object.setPrototypeOf(this, ConnectionBoundaryError.prototype);
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4138
4206
|
class PermissionError extends Error {
|
|
4139
4207
|
classification;
|
|
4140
4208
|
requiredPermission;
|
|
@@ -4146,9 +4214,9 @@ class PermissionError extends Error {
|
|
|
4146
4214
|
Object.setPrototypeOf(this, PermissionError.prototype);
|
|
4147
4215
|
}
|
|
4148
4216
|
}
|
|
4149
|
-
function classifyStatement(sql) {
|
|
4217
|
+
function classifyStatement(sql, dialect) {
|
|
4150
4218
|
const normalized = normalizeSQL(sql);
|
|
4151
|
-
const stripped = stripCommentsAndStrings(normalized);
|
|
4219
|
+
const stripped = stripCommentsAndStrings(normalized, { dialect });
|
|
4152
4220
|
const upper = stripped.toUpperCase();
|
|
4153
4221
|
if (/^\s*ANALYZE\s+SELECT\b/i.test(stripped)) {
|
|
4154
4222
|
return {
|
|
@@ -4161,7 +4229,16 @@ function classifyStatement(sql) {
|
|
|
4161
4229
|
}
|
|
4162
4230
|
const composite = detectCompositePatterns(upper);
|
|
4163
4231
|
const firstKeyword = extractFirstKeyword(stripped);
|
|
4164
|
-
|
|
4232
|
+
if (isDeclaredAdminOnly(firstKeyword)) {
|
|
4233
|
+
return {
|
|
4234
|
+
type: "UNKNOWN",
|
|
4235
|
+
isDangerous: true,
|
|
4236
|
+
keywords: extractAllKeywords(stripped),
|
|
4237
|
+
isComposite: false,
|
|
4238
|
+
confidence: "HIGH"
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
4241
|
+
const type = mapKeywordToType(firstKeyword, dialect);
|
|
4165
4242
|
return {
|
|
4166
4243
|
type,
|
|
4167
4244
|
isDangerous: isDestructiveOperation(type),
|
|
@@ -4170,7 +4247,7 @@ function classifyStatement(sql) {
|
|
|
4170
4247
|
confidence: determineConfidence(type, firstKeyword, upper)
|
|
4171
4248
|
};
|
|
4172
4249
|
}
|
|
4173
|
-
var SQL_DIALECTS = ["postgresql", "mysql", "mariadb"];
|
|
4250
|
+
var SQL_DIALECTS = ["postgresql", "mysql", "mariadb", "sqlite"];
|
|
4174
4251
|
var SQL_WRITE_OR_DDL_KEYWORDS = /(?<![.\w])(INSERT|UPDATE|DELETE|MERGE|UPSERT|REPLACE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|RENAME|INTO)\b(?!\s*\()/i;
|
|
4175
4252
|
var SQL_LOCK_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b|\bFOR\s+(?:KEY\s+)?SHARE\b/gi;
|
|
4176
4253
|
function findWriteKeyword(sql, dialects) {
|
|
@@ -4207,8 +4284,22 @@ function escalateHiddenWrite(sql, classification, dialect) {
|
|
|
4207
4284
|
escalatedFrom: hidden
|
|
4208
4285
|
};
|
|
4209
4286
|
}
|
|
4287
|
+
var SQLITE_CROSS_DATABASE = /^\s*(ATTACH|DETACH)\b/i;
|
|
4288
|
+
function reachesAnotherDatabase(sql, dialect) {
|
|
4289
|
+
if (dialect !== "sqlite")
|
|
4290
|
+
return false;
|
|
4291
|
+
return stripCommentsAndStrings(sql, { dialect }).split(";").some((statement) => SQLITE_CROSS_DATABASE.test(statement));
|
|
4292
|
+
}
|
|
4210
4293
|
function checkPermission(sql, permission, dialect) {
|
|
4211
|
-
|
|
4294
|
+
if (reachesAnotherDatabase(sql, dialect)) {
|
|
4295
|
+
return {
|
|
4296
|
+
allowed: false,
|
|
4297
|
+
reason: "ATTACH and DETACH are refused on a SQLite connection: they reach a database file " + "outside the one this connection names, which the blacklist, audit log and schema " + "cache are all scoped to. Configure a second connection instead.",
|
|
4298
|
+
classification: classifyStatement(sql),
|
|
4299
|
+
boundary: true
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4302
|
+
const classification = escalateHiddenWrite(sql, classifyStatement(sql, dialect), dialect);
|
|
4212
4303
|
if (permission !== "admin" && containsMultipleStatements(sql, dialect)) {
|
|
4213
4304
|
return {
|
|
4214
4305
|
allowed: false,
|
|
@@ -4222,7 +4313,7 @@ function checkPermission(sql, permission, dialect) {
|
|
|
4222
4313
|
var TIER_GRANTS = [
|
|
4223
4314
|
{ permission: "query-only", types: ["SELECT", "SHOW", "DESCRIBE", "EXPLAIN"] },
|
|
4224
4315
|
{ permission: "read-write", types: ["INSERT", "UPDATE"] },
|
|
4225
|
-
{ permission: "data-admin", types: ["DELETE"] }
|
|
4316
|
+
{ permission: "data-admin", types: ["DELETE", "REPLACE"] }
|
|
4226
4317
|
];
|
|
4227
4318
|
function minimumPermissionFor(type) {
|
|
4228
4319
|
return TIER_GRANTS.find((tier) => tier.types.includes(type))?.permission ?? "admin";
|
|
@@ -4296,6 +4387,9 @@ function enforcePermissionForType(type, permission) {
|
|
|
4296
4387
|
}
|
|
4297
4388
|
function enforcePermission(sql, permission, dialect) {
|
|
4298
4389
|
const result = checkPermission(sql, permission, dialect);
|
|
4390
|
+
if (result.boundary) {
|
|
4391
|
+
throw new ConnectionBoundaryError(result.reason, result.classification);
|
|
4392
|
+
}
|
|
4299
4393
|
if (!result.allowed) {
|
|
4300
4394
|
throw new PermissionError(result.reason, result.classification, result.requiredPermission ?? "admin");
|
|
4301
4395
|
}
|
|
@@ -4577,6 +4671,18 @@ function tokenize(sql, dialect, backslashEscapes) {
|
|
|
4577
4671
|
}
|
|
4578
4672
|
continue;
|
|
4579
4673
|
}
|
|
4674
|
+
if (dialect === "sqlite" && char === "[") {
|
|
4675
|
+
i++;
|
|
4676
|
+
let value = "";
|
|
4677
|
+
while (i < sql.length && sql[i] !== "]") {
|
|
4678
|
+
value += sql[i];
|
|
4679
|
+
i++;
|
|
4680
|
+
}
|
|
4681
|
+
if (i < sql.length)
|
|
4682
|
+
i++;
|
|
4683
|
+
tokens.push({ kind: "identifier", value, quoted: true });
|
|
4684
|
+
continue;
|
|
4685
|
+
}
|
|
4580
4686
|
if (char === '"' || char === "`") {
|
|
4581
4687
|
const quote = char;
|
|
4582
4688
|
i++;
|
|
@@ -5307,7 +5413,7 @@ class DataExecutor {
|
|
|
5307
5413
|
return this.dbSystem;
|
|
5308
5414
|
}
|
|
5309
5415
|
getQuoteChar() {
|
|
5310
|
-
return this.dbSystem === "mysql" ? "`" : '"';
|
|
5416
|
+
return this.dbSystem === "mysql" || this.dbSystem === "mariadb" ? "`" : '"';
|
|
5311
5417
|
}
|
|
5312
5418
|
checkBlacklist(operation, tableName) {
|
|
5313
5419
|
this.blacklistValidator?.checkTableBlacklist(operation, tableName, []);
|
|
@@ -9425,6 +9531,35 @@ var SqlConnectionConfigSchema = exports_external.object({
|
|
|
9425
9531
|
database: StringOrEnvRef,
|
|
9426
9532
|
...TimeoutField
|
|
9427
9533
|
});
|
|
9534
|
+
var SQLITE_MEMORY_TARGET_MESSAGE = "SQLite in-memory databases are refused: a memory target is a different database on every " + "connection, so it cannot be the connection identity the blacklist, audit log and schema " + "cache are scoped to. Point `file` at a database file on disk. See ADR-0038.";
|
|
9535
|
+
function isSqliteMemoryTarget(value) {
|
|
9536
|
+
const trimmed = value.trim();
|
|
9537
|
+
if (trimmed.toLowerCase() === ":memory:")
|
|
9538
|
+
return true;
|
|
9539
|
+
if (!trimmed.toLowerCase().startsWith("file:"))
|
|
9540
|
+
return false;
|
|
9541
|
+
const withoutScheme = trimmed.slice("file:".length);
|
|
9542
|
+
const queryStart = withoutScheme.indexOf("?");
|
|
9543
|
+
const path = queryStart === -1 ? withoutScheme : withoutScheme.slice(0, queryStart);
|
|
9544
|
+
if (path.toLowerCase() === ":memory:")
|
|
9545
|
+
return true;
|
|
9546
|
+
const query = queryStart === -1 ? "" : withoutScheme.slice(queryStart + 1);
|
|
9547
|
+
return new URLSearchParams(query).get("mode")?.toLowerCase() === "memory";
|
|
9548
|
+
}
|
|
9549
|
+
var SqliteConnectionConfigSchema = exports_external.object({
|
|
9550
|
+
system: exports_external.literal("sqlite"),
|
|
9551
|
+
file: exports_external.union([exports_external.string().min(1), EnvRefSchema]).superRefine((value, ctx) => {
|
|
9552
|
+
if (typeof value === "string" && isSqliteMemoryTarget(value)) {
|
|
9553
|
+
ctx.addIssue({ code: exports_external.ZodIssueCode.custom, message: SQLITE_MEMORY_TARGET_MESSAGE });
|
|
9554
|
+
}
|
|
9555
|
+
}),
|
|
9556
|
+
host: OptStringOrEnvRef,
|
|
9557
|
+
port: exports_external.union([exports_external.number().int(), EnvRefSchema]).optional().default(0),
|
|
9558
|
+
user: OptStringOrEnvRef,
|
|
9559
|
+
password: exports_external.union([exports_external.string(), EnvRefSchema]).optional().default(""),
|
|
9560
|
+
database: OptStringOrEnvRef,
|
|
9561
|
+
...TimeoutField
|
|
9562
|
+
});
|
|
9428
9563
|
var RedisConnectionConfigSchema = exports_external.object({
|
|
9429
9564
|
system: exports_external.literal("redis"),
|
|
9430
9565
|
host: StringOrEnvRef,
|
|
@@ -9451,6 +9586,7 @@ var ElasticsearchConnectionConfigSchema = exports_external.object({
|
|
|
9451
9586
|
});
|
|
9452
9587
|
var ConnectionConfigSchema = exports_external.union([
|
|
9453
9588
|
SqlConnectionConfigSchema,
|
|
9589
|
+
SqliteConnectionConfigSchema,
|
|
9454
9590
|
MongoDBConnectionConfigSchema,
|
|
9455
9591
|
RedisConnectionConfigSchema,
|
|
9456
9592
|
ElasticsearchConnectionConfigSchema
|
|
@@ -9521,6 +9657,11 @@ var SqlNamedConnectionSchema = SqlConnectionConfigSchema.extend({
|
|
|
9521
9657
|
envFile: exports_external.string().optional(),
|
|
9522
9658
|
environment: EnvironmentLabelSchema
|
|
9523
9659
|
});
|
|
9660
|
+
var SqliteNamedConnectionSchema = SqliteConnectionConfigSchema.extend({
|
|
9661
|
+
permission: PermissionSchema,
|
|
9662
|
+
envFile: exports_external.string().optional(),
|
|
9663
|
+
environment: EnvironmentLabelSchema
|
|
9664
|
+
});
|
|
9524
9665
|
var MongoDBNamedConnectionSchema = MongoDBConnectionConfigSchema.extend({
|
|
9525
9666
|
permission: PermissionSchema,
|
|
9526
9667
|
envFile: exports_external.string().optional(),
|
|
@@ -9538,6 +9679,7 @@ var ElasticsearchNamedConnectionSchema = ElasticsearchConnectionConfigSchema.ext
|
|
|
9538
9679
|
});
|
|
9539
9680
|
var NamedConnectionUnion = exports_external.union([
|
|
9540
9681
|
SqlNamedConnectionSchema,
|
|
9682
|
+
SqliteNamedConnectionSchema,
|
|
9541
9683
|
MongoDBNamedConnectionSchema,
|
|
9542
9684
|
RedisNamedConnectionSchema,
|
|
9543
9685
|
ElasticsearchNamedConnectionSchema
|
|
@@ -9972,6 +10114,7 @@ function listConnections(config) {
|
|
|
9972
10114
|
port: c.port ?? 27017,
|
|
9973
10115
|
database: c.database ?? "",
|
|
9974
10116
|
...c.uri !== undefined && { uri: c.uri },
|
|
10117
|
+
...c.file !== undefined && { file: c.file },
|
|
9975
10118
|
isDefault: name === config.default
|
|
9976
10119
|
};
|
|
9977
10120
|
});
|
|
@@ -10001,7 +10144,10 @@ function setDefaultConnection(config, name) {
|
|
|
10001
10144
|
function migrateV1ToV2(v1) {
|
|
10002
10145
|
const system = v1.connection.system;
|
|
10003
10146
|
if (!SQL_SYSTEMS.includes(system)) {
|
|
10004
|
-
|
|
10147
|
+
if (system === "sqlite") {
|
|
10148
|
+
throw new Error(`v1\u2192v2 \u81EA\u52D5\u5347\u7D1A\u4E0D\u63A5\u53D7 'sqlite'\uFF1Av1 \u8A2D\u5B9A\u683C\u5F0F\u65E9\u65BC SQLite \u652F\u63F4\uFF0C\u4E0D\u53EF\u80FD\u662F\u7531 dbcli \u5BEB\u51FA\u4F86\u7684\u3002` + `\u8ACB\u7528 'dbcli init --system sqlite' \u76F4\u63A5\u5EFA\u7ACB v2 \u9023\u7DDA\u3002`);
|
|
10149
|
+
}
|
|
10150
|
+
throw new Error(`v1\u2192v2 \u81EA\u52D5\u5347\u7D1A\u76EE\u524D\u50C5\u652F\u63F4 mysql/postgresql/mariadb \u7684\u9010\u6B04\u9023\u7DDA\u8A2D\u5B9A,\u4E0D\u652F\u63F4 '${system}'`);
|
|
10005
10151
|
}
|
|
10006
10152
|
const c = v1.connection;
|
|
10007
10153
|
return {
|
|
@@ -11749,6 +11895,15 @@ function dedupe2(values) {
|
|
|
11749
11895
|
}
|
|
11750
11896
|
return result;
|
|
11751
11897
|
}
|
|
11898
|
+
var NO_MASKING_COST = Object.freeze({
|
|
11899
|
+
rowsScanned: 0,
|
|
11900
|
+
keysScanned: 0,
|
|
11901
|
+
ruleEvaluations: 0,
|
|
11902
|
+
pathSplits: 0,
|
|
11903
|
+
nestedProbeRows: 0,
|
|
11904
|
+
nestedGlobRows: 0,
|
|
11905
|
+
nestedGlobMatches: 0
|
|
11906
|
+
});
|
|
11752
11907
|
function omittedByName(omittedColumns, path) {
|
|
11753
11908
|
return omittedColumns.some((omitted) => path === omitted || path.startsWith(`${omitted}.`));
|
|
11754
11909
|
}
|
|
@@ -11866,13 +12021,25 @@ class BlacklistValidator {
|
|
|
11866
12021
|
const tables = dedupe2(tableNames);
|
|
11867
12022
|
const blacklistedColumns = tables.length === 0 ? this.manager.getAllBlacklistedColumns() : Array.from(new Set(tables.flatMap((table) => this.manager.getBlacklistedColumns(table))));
|
|
11868
12023
|
if (blacklistedColumns.length === 0) {
|
|
11869
|
-
return {
|
|
12024
|
+
return {
|
|
12025
|
+
filteredRows: rows,
|
|
12026
|
+
omittedColumns: [],
|
|
12027
|
+
cost: NO_MASKING_COST,
|
|
12028
|
+
reachesOmitted: () => false
|
|
12029
|
+
};
|
|
11870
12030
|
}
|
|
12031
|
+
let rowsScanned = 0;
|
|
12032
|
+
let keysScanned = 0;
|
|
12033
|
+
let ruleEvaluations = 0;
|
|
12034
|
+
let pathSplits = 0;
|
|
12035
|
+
let nestedProbeRows = 0;
|
|
12036
|
+
let nestedGlobRows = 0;
|
|
11871
12037
|
const probeNested = blacklistedColumns.some((path) => path.includes("."));
|
|
11872
12038
|
const presentColumns = new Set(columnList);
|
|
11873
12039
|
const nestedHeads = new Set;
|
|
11874
12040
|
const collect = (record) => {
|
|
11875
12041
|
for (const key of Object.getOwnPropertyNames(record)) {
|
|
12042
|
+
keysScanned += 1;
|
|
11876
12043
|
presentColumns.add(key);
|
|
11877
12044
|
if (!probeNested)
|
|
11878
12045
|
continue;
|
|
@@ -11882,6 +12049,7 @@ class BlacklistValidator {
|
|
|
11882
12049
|
}
|
|
11883
12050
|
};
|
|
11884
12051
|
for (const row of rows) {
|
|
12052
|
+
rowsScanned += 1;
|
|
11885
12053
|
if (row === null || typeof row !== "object")
|
|
11886
12054
|
continue;
|
|
11887
12055
|
if (Array.isArray(row)) {
|
|
@@ -11904,6 +12072,7 @@ class BlacklistValidator {
|
|
|
11904
12072
|
}
|
|
11905
12073
|
const omitted = new Set;
|
|
11906
12074
|
for (const path of blacklistedColumns) {
|
|
12075
|
+
ruleEvaluations += 1;
|
|
11907
12076
|
const present = presentByFolded.get(foldFieldPath(path));
|
|
11908
12077
|
if (present !== undefined) {
|
|
11909
12078
|
for (const name of present)
|
|
@@ -11915,8 +12084,17 @@ class BlacklistValidator {
|
|
|
11915
12084
|
continue;
|
|
11916
12085
|
if (!nestedHeads.has(foldFieldPath(path.slice(0, dot))))
|
|
11917
12086
|
continue;
|
|
12087
|
+
pathSplits += 1;
|
|
11918
12088
|
const segments = path.split(".").map(foldFieldPath);
|
|
11919
|
-
|
|
12089
|
+
let found = false;
|
|
12090
|
+
for (const row of rows) {
|
|
12091
|
+
nestedProbeRows += 1;
|
|
12092
|
+
if (hasFieldPath(row, segments, PRE_FOLDED)) {
|
|
12093
|
+
found = true;
|
|
12094
|
+
break;
|
|
12095
|
+
}
|
|
12096
|
+
}
|
|
12097
|
+
if (found)
|
|
11920
12098
|
omitted.add(path);
|
|
11921
12099
|
}
|
|
11922
12100
|
const nestedGlobs = globRules.filter((pattern) => pattern.segments.length > 1);
|
|
@@ -11927,6 +12105,7 @@ class BlacklistValidator {
|
|
|
11927
12105
|
for (const row of rows) {
|
|
11928
12106
|
if (pending.size === 0)
|
|
11929
12107
|
break;
|
|
12108
|
+
nestedGlobRows += 1;
|
|
11930
12109
|
if (row === null || typeof row !== "object")
|
|
11931
12110
|
continue;
|
|
11932
12111
|
collectNestedMatches(row, states, pending, (pattern) => {
|
|
@@ -11964,13 +12143,35 @@ class BlacklistValidator {
|
|
|
11964
12143
|
}
|
|
11965
12144
|
const omittedColumns = Array.from(omitted);
|
|
11966
12145
|
if (omittedColumns.length === 0) {
|
|
11967
|
-
return {
|
|
12146
|
+
return {
|
|
12147
|
+
filteredRows: rows,
|
|
12148
|
+
omittedColumns: [],
|
|
12149
|
+
cost: Object.freeze({
|
|
12150
|
+
rowsScanned,
|
|
12151
|
+
keysScanned,
|
|
12152
|
+
ruleEvaluations,
|
|
12153
|
+
pathSplits,
|
|
12154
|
+
nestedProbeRows,
|
|
12155
|
+
nestedGlobRows,
|
|
12156
|
+
nestedGlobMatches: matchedNested.length
|
|
12157
|
+
}),
|
|
12158
|
+
reachesOmitted: () => false
|
|
12159
|
+
};
|
|
11968
12160
|
}
|
|
11969
12161
|
const removalPaths = matchedNested.length === 0 ? omittedColumns : omittedColumns.filter((path) => !matchedNested.some((p) => p.raw === path));
|
|
11970
12162
|
const filteredRows = omitFieldPaths(rows, removalPaths, matchedNested.length > 0 ? { ...FOLD_CASE2, patterns: matchedNested } : FOLD_CASE2);
|
|
11971
12163
|
return {
|
|
11972
12164
|
filteredRows,
|
|
11973
12165
|
omittedColumns,
|
|
12166
|
+
cost: Object.freeze({
|
|
12167
|
+
rowsScanned,
|
|
12168
|
+
keysScanned,
|
|
12169
|
+
ruleEvaluations,
|
|
12170
|
+
pathSplits,
|
|
12171
|
+
nestedProbeRows,
|
|
12172
|
+
nestedGlobRows,
|
|
12173
|
+
nestedGlobMatches: matchedNested.length
|
|
12174
|
+
}),
|
|
11974
12175
|
reachesOmitted: (path) => {
|
|
11975
12176
|
if (omittedByName(removalPaths, path))
|
|
11976
12177
|
return true;
|
package/gemini-extension.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carllee1983/dbcli",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "Database CLI for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -86,9 +86,10 @@
|
|
|
86
86
|
"core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
|
|
87
87
|
"plan:check": "bun run scripts/check-plan-acceptance.ts",
|
|
88
88
|
"forgeflow:check": "bun run scripts/check-forgeflow-adoption.ts && bun run scripts/check-forgeflow-handoff.ts",
|
|
89
|
+
"forgeflow:contract": "bun run scripts/check-forgeflow-contract.ts",
|
|
89
90
|
"typecheck": "tsc --noEmit --pretty false",
|
|
90
91
|
"typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
|
|
91
|
-
"test:perf": "bun test ./tests/perf/*.bench.ts",
|
|
92
|
+
"test:perf": "bun test --timeout 30000 ./tests/perf/*.bench.ts",
|
|
92
93
|
"lint": "eslint src tests scripts --ext .ts --max-warnings=0",
|
|
93
94
|
"format:check": "prettier --check .",
|
|
94
95
|
"format": "prettier --write .",
|
|
@@ -3872,7 +3872,7 @@ GET /orders/_search
|
|
|
3872
3872
|
|
|
3873
3873
|
- Enter a request line `<METHOD> /<path>`, then an optional multi-line JSON body; a **blank line** submits the block. Responses render as pretty-printed JSON.
|
|
3874
3874
|
- **The connection's permission tier applies, as it does to `dbcli query`.** The request is classified through the same classifier the query path uses, on the path the server will actually route — the query string is discarded and percent-encoding and dot segments are resolved first, so `?filter_path=...` cannot change the verdict.
|
|
3875
|
-
- **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/0014-elasticsearch-reads-are-an-allowlist.md).
|
|
3875
|
+
- **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/ADR-0014-elasticsearch-reads-are-an-allowlist.md).
|
|
3876
3876
|
- A path is refused unless it is **byte-identical** to what the URL parser produces — the same parser the request goes through — and the refusal tells you the canonical spelling to write instead. This is what stops a path that reads one way here and routes another way at the server: `#` truncates a path in transit (`POST /_reindex#/_count` reaches Elasticsearch as `POST /_reindex`), and tab, newline and `\` are rewritten too. A document id containing a space or a non-ASCII character must be written percent-encoded; the error message gives you the exact string.
|
|
3877
3877
|
- A quoted string request body is refused; write the body as JSON. So is a `source=` query parameter — Elasticsearch accepts it in place of a body, where none of the body checks can see it. `_source`, `_source_includes` and `_source_excludes` are unaffected.
|
|
3878
3878
|
- Blacklisted field names are refused in the query string as well as the body, because the URI-search form names fields directly (`?q=…`, `?sort=…`, `?docvalue_fields=…`). A name matches on any dot component, so `password.keyword` — the multi-field every `text` field gets by default — and `params._source.password` inside a script are both refused.
|
|
@@ -3872,7 +3872,7 @@ GET /orders/_search
|
|
|
3872
3872
|
|
|
3873
3873
|
- Enter a request line `<METHOD> /<path>`, then an optional multi-line JSON body; a **blank line** submits the block. Responses render as pretty-printed JSON.
|
|
3874
3874
|
- **The connection's permission tier applies, as it does to `dbcli query`.** The request is classified through the same classifier the query path uses, on the path the server will actually route — the query string is discarded and percent-encoding and dot segments are resolved first, so `?filter_path=...` cannot change the verdict.
|
|
3875
|
-
- **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/0014-elasticsearch-reads-are-an-allowlist.md).
|
|
3875
|
+
- **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/ADR-0014-elasticsearch-reads-are-an-allowlist.md).
|
|
3876
3876
|
- A path is refused unless it is **byte-identical** to what the URL parser produces — the same parser the request goes through — and the refusal tells you the canonical spelling to write instead. This is what stops a path that reads one way here and routes another way at the server: `#` truncates a path in transit (`POST /_reindex#/_count` reaches Elasticsearch as `POST /_reindex`), and tab, newline and `\` are rewritten too. A document id containing a space or a non-ASCII character must be written percent-encoded; the error message gives you the exact string.
|
|
3877
3877
|
- A quoted string request body is refused; write the body as JSON. So is a `source=` query parameter — Elasticsearch accepts it in place of a body, where none of the body checks can see it. `_source`, `_source_includes` and `_source_excludes` are unaffected.
|
|
3878
3878
|
- Blacklisted field names are refused in the query string as well as the body, because the URI-search form names fields directly (`?q=…`, `?sort=…`, `?docvalue_fields=…`). A name matches on any dot component, so `password.keyword` — the multi-field every `text` field gets by default — and `params._source.password` inside a script are both refused.
|