dsh-api-runner 0.3.0 → 0.3.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/src/main.js CHANGED
@@ -19,23 +19,48 @@ import { ApiRunnerService, asObject, toText } from './ops.js';
19
19
  import { ProjectStore } from './project-store.js';
20
20
  import { JavaRunner } from './java-runner.js';
21
21
  import { SettingsStore } from './settings.js';
22
+ import { normalizeProjectKey } from './util.js';
22
23
 
23
24
  export const inject = ['webServer', 'sessions', 'tools', 'connection', 'systemPrompt'];
24
25
 
25
- const TOOL_DESCRIPTION = `开发联调工具(dsh-api-runner):按配置调用后端 HTTP 接口、启停本地服务、增量读取日志。
26
+ /**
27
+ * 会话 cwd → 项目 key。数据库授权按这个 key 归档。
28
+ * 没有 cwd 时回落 'default',和 ProjectStore 的约定一致。
29
+ */
30
+ function projectKeyOf(cwd) {
31
+ return normalizeProjectKey(String(cwd || '')) || 'default';
32
+ }
33
+
34
+ const TOOL_DESCRIPTION = `开发联调工具(dsh-api-runner):按配置调用后端 HTTP 接口、启停本地服务、增量读取日志、直接查数据库。
26
35
 
27
36
  典型用法(改完后端代码后自测):
28
- 1. action=describe —— 先看有哪些接口/服务/日志可用(含每个接口需要的参数名)。
37
+ 1. action=describe —— 先看有哪些接口/服务/日志/数据库可用(含每个接口需要的参数名)。
29
38
  2. action=service + service_action=restart —— 重启本地服务(managed 模式由插件托管进程;
30
39
  observe 模式下插件不托管,需你到 IDEA 里重启,然后用 service_action=wait 等就绪)。
31
40
  3. action=log + pattern —— 增量读日志;或 action=wait_log 等启动完成/报错特征出现。
32
41
  4. action=call —— 调用接口,params 里的键会替换 URL 里的 {name} 并拼进 query/body。
33
42
  5. action=verify —— 一条命令串起「重启 → 读日志 → 调接口」,适合改完后端后自检。
43
+ 6. action=db —— 直接查数据库:db_action=list_connections 看有哪些库;
44
+ list_tables / describe_table 看结构;query 执行 SQL。
45
+
46
+ 安全约束(务必遵守):
47
+ - 只有**已被当前项目授权**的连接才会出现在 describe 里;未授权的连接查不到也调不动。
48
+ - 模型侧**永远只读**:只允许 SELECT / SHOW / DESCRIBE / EXPLAIN 等查询。
49
+ 传 db_readonly=false 也不会放开写操作——写数据只能由人在面板里做。
50
+ - DROP / TRUNCATE / 无 WHERE 的 DELETE 等毁灭性操作一律拒绝。
51
+
52
+ 什么时候该用 db:
53
+ - 需要确认数据是否真的写进去了(接口返回成功但不确认落库)。
54
+ - 排查数据问题:某条记录状态不对、关联数据缺失、字段值与预期不符。
55
+ - 需要先了解表结构才能写对查询时,先 describe_table 再 query。
56
+ 遇到「数据对不对」这类问题就主动查,不用等用户提醒。
34
57
 
35
58
  注意:
36
59
  - 日志游标按 logId 持久化,第一次读只取文件末尾一段;要全量请传 reset=true。
37
60
  - 服务重启会 kill 整个进程树后重新拉起(managed);失败时返回 exit code 与健康检查明细。
38
- - 接口响应体默认截断到 32KB,可用 max_bytes 调整;请求头在返回里已脱敏。`;
61
+ - 接口响应体默认截断到 32KB,可用 max_bytes 调整;请求头在返回里已脱敏。
62
+ - SQL 只允许单条语句;用 db_params 传参,不要拼接字符串(防注入)。
63
+ - 数据库连接是跨项目的全局配置,但**访问权按项目隔离**。`;
39
64
 
40
65
  const PARAMETERS = {
41
66
  type: 'object',
@@ -88,12 +113,12 @@ const PARAMETERS = {
88
113
  },
89
114
  db_readonly: {
90
115
  type: 'boolean',
91
- description: 'db_action=query 时是否只读(默认 true,拒绝 INSERT/UPDATE/DELETE/DDL)',
116
+ description: '(已废弃,保留仅为兼容)模型侧永远只读,传 false 也不会放开写操作。写数据只能由人在面板里操作。',
92
117
  },
93
118
  connection: {
94
119
  type: 'object',
95
120
  additionalProperties: true,
96
- description: '连接配置(db_action=save/test 用):{ id?, name, type(sqlite|mysql|postgres|oracle), host, port, database, username, password, extra }',
121
+ description: '连接配置(db_action=save/test 用):{ id?, name, type(sqlite|mysql|postgres|oracle|dm), host, port, database, username, password, connectString?, extra }。connectString 是 URL 模式(如 dm://user:pass@host:5236),填了就不用 host/port',
97
122
  },
98
123
  table: { type: 'string', description: '表名(db_action=describe_table 用)' },
99
124
  database: { type: 'string', description: '库名/模式名(db_action=list_tables 用;MySQL 必填)' },
@@ -240,10 +265,15 @@ async function runTool(service, args, projectPath) {
240
265
  }
241
266
  case 'db': {
242
267
  const dbAction = String(args.db_action || 'list_connections');
268
+ /**
269
+ * 项目(工作空间)key:授权是按项目走的,所有 db 动作都要带上它。
270
+ * runTool 的 projectPath 就是会话 cwd,用它派生 key。
271
+ */
272
+ const projectKey = projectKeyOf(projectPath);
243
273
  if (dbAction === 'list_connections') return service.dbConnections();
244
274
  if (dbAction === 'list_databases') {
245
275
  if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_databases 需要 connection_id' };
246
- return service.dbDatabases(String(args.connection_id));
276
+ return service.dbDatabases(String(args.connection_id), { projectKey });
247
277
  }
248
278
  if (dbAction === 'test') {
249
279
  const target = args.connection || args.connection_id;
@@ -279,20 +309,25 @@ async function runTool(service, args, projectPath) {
279
309
  if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 connection_id' };
280
310
  if (!args.sql) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 sql' };
281
311
  return service.dbQuery(String(args.connection_id), String(args.sql), Array.isArray(args.db_params) ? args.db_params : [], {
282
- // 默认只读:模型直接执行写操作是高风险动作,必须显式关闭
283
- readonly: args.db_readonly !== false,
312
+ /**
313
+ * 模型侧**永远只读**:不接受 args 里传的 db_readonly=false。
314
+ * 写操作只能由人在面板里做(面板走的是另一条带 UI 确认的路径)。
315
+ * 这样即使模型被诱导,也删不掉任何数据。
316
+ */
317
+ readonly: true,
318
+ projectKey,
284
319
  maxRows: args.max_lines ? Number(args.max_lines) : undefined,
285
320
  timeoutMs,
286
321
  });
287
322
  }
288
323
  if (dbAction === 'list_tables') {
289
324
  if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_tables 需要 connection_id' };
290
- return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '');
325
+ return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '', { projectKey });
291
326
  }
292
327
  if (dbAction === 'describe_table') {
293
328
  if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 connection_id' };
294
329
  if (!args.table) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 table' };
295
- return service.dbDescribeTable(String(args.connection_id), String(args.table));
330
+ return service.dbDescribeTable(String(args.connection_id), String(args.table), { projectKey });
296
331
  }
297
332
  return { ok: false, code: 'BAD_ACTION', message: `不支持的 db_action: ${dbAction}` };
298
333
  }
@@ -414,13 +449,15 @@ export function apply(ctx) {
414
449
  const apis = info.apis.map((api) => `- ${api.id}(${api.method} ${api.url})${api.params.length ? ` 参数: ${api.params.join(', ')}` : ''}`).join('\n');
415
450
  const services = info.services.map((item) => `- ${item.id}(${item.mode})${item.name}`).join('\n');
416
451
  const logs = info.logs.map((item) => `- ${item.id} → ${item.path}`).join('\n');
417
- if (!apis && !services && !logs) return '';
452
+ const databases = (info.databases || []).map((item) => `- ${item.id}(${item.type})${item.name} → ${item.target}`).join('\n');
453
+ if (!apis && !services && !logs && !databases) return '';
418
454
  return [
419
455
  '# 本地开发联调(dsh-api-runner)',
420
456
  '可用工具 ApiRunner 已配置以下资源;改完后端代码后可用它重启服务、读日志、直接调接口验证。',
421
457
  apis ? `\n接口:\n${apis}` : '',
422
458
  services ? `\n服务:\n${services}` : '',
423
459
  logs ? `\n日志:\n${logs}` : '',
460
+ databases ? `\n数据库(可用 action=db 直接查询,默认只读):\n${databases}\n需要看表结构时用 db_action=list_tables / describe_table;查数据用 db_action=query + sql(用 db_params 传参,别拼字符串)。` : '',
424
461
  ].join('\n');
425
462
  } catch {
426
463
  return '';
@@ -673,7 +710,10 @@ async function handleHttp(req, res, service, projectOf) {
673
710
  if (!target || (typeof target === 'string' && target === '')) {
674
711
  return send(400, { ok: false, code: 'MISSING_ARGUMENT', message: '需要 connection_id 或 connection 配置' });
675
712
  }
676
- return send(200, await service.dbTest(target));
713
+ // timeout_ms 让前端能把等待压到可接受范围(默认 15s,见 DbManager.probeTimeoutMs)
714
+ return send(200, await service.dbTest(target, {
715
+ timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
716
+ }));
677
717
  }
678
718
  if (route === 'db/save' && req.method === 'POST') {
679
719
  const body = await readBody();
@@ -690,25 +730,49 @@ async function handleHttp(req, res, service, projectOf) {
690
730
  String(body.sql || ''),
691
731
  Array.isArray(body.params) ? body.params : [],
692
732
  {
733
+ /**
734
+ * 面板里的写操作需要「项目授权 = readwrite」才放行,
735
+ * 且 DROP/TRUNCATE/无 WHERE 的 DELETE 仍会被 DESTRUCTIVE_BLOCKED 拦住。
736
+ * readonly 由前端传,但真正的闸门是项目授权等级。
737
+ */
693
738
  readonly: body.readonly !== false,
739
+ projectKey: projectKeyOf(cwd),
694
740
  maxRows: body.max_rows ? Number(body.max_rows) : undefined,
695
741
  timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
696
742
  },
697
743
  ));
698
744
  }
699
745
  if (route === 'db/databases' && req.method === 'GET') {
700
- return send(200, await service.dbDatabases(String(url.searchParams.get('connection_id') || '')));
746
+ return send(200, await service.dbDatabases(
747
+ String(url.searchParams.get('connection_id') || ''),
748
+ { projectKey: projectKeyOf(cwd) },
749
+ ));
701
750
  }
702
751
  if (route === 'db/tables' && req.method === 'GET') {
703
752
  return send(200, await service.dbTables(
704
753
  String(url.searchParams.get('connection_id') || ''),
705
754
  String(url.searchParams.get('database') || ''),
755
+ { projectKey: projectKeyOf(cwd) },
706
756
  ));
707
757
  }
708
758
  if (route === 'db/table' && req.method === 'GET') {
709
759
  return send(200, await service.dbDescribeTable(
710
760
  String(url.searchParams.get('connection_id') || ''),
711
761
  String(url.searchParams.get('name') || url.searchParams.get('table') || ''),
762
+ { projectKey: projectKeyOf(cwd) },
763
+ ));
764
+ }
765
+
766
+ // ---- 数据库项目授权(默认拒绝,必须显式授权) ----
767
+ if (route === 'db/access' && req.method === 'GET') {
768
+ return send(200, service.dbAccess(projectKeyOf(cwd)));
769
+ }
770
+ if (route === 'db/access' && req.method === 'POST') {
771
+ const body = await readBody();
772
+ return send(200, service.dbSetAccess(
773
+ projectKeyOf(body.project_key ? String(body.project_key) : cwd),
774
+ String(body.connection_id || ''),
775
+ String(body.level || 'none'),
712
776
  ));
713
777
  }
714
778
 
package/src/ops.js CHANGED
@@ -105,6 +105,33 @@ export class ApiRunnerService {
105
105
  exists: fileExists(log.path),
106
106
  size: fileSize(log.path),
107
107
  })),
108
+ /**
109
+ * 数据库连接也放进 describe:模型据此知道「有哪些库可查」。
110
+ *
111
+ * **只列当前项目已授权的连接**——未授权的连接模型不该看见,
112
+ * 否则它会反复尝试然后被拒,浪费轮次还误导用户。
113
+ * 只给 id / 名称 / 类型 / 目标 / 权限等级,不给账号密码。
114
+ */
115
+ databases: this.dbManager
116
+ ? (() => {
117
+ const access = this.dbManager.accessOf('default');
118
+ return this.dbManager.listConnections()
119
+ .map((item) => {
120
+ const level = access[item.id] || 'none';
121
+ return {
122
+ id: item.id,
123
+ name: item.name,
124
+ type: item.type,
125
+ target: item.urlMode
126
+ ? item.connectString
127
+ : (item.type === 'sqlite' ? (item.file || ':memory:') : `${item.host}:${item.port}/${item.database}`),
128
+ username: item.username || '',
129
+ level,
130
+ };
131
+ })
132
+ .filter((item) => item.level !== 'none');
133
+ })()
134
+ : [],
108
135
  };
109
136
  }
110
137
 
@@ -1183,9 +1210,9 @@ export class ApiRunnerService {
1183
1210
  }
1184
1211
  }
1185
1212
 
1186
- async dbTest(target) {
1213
+ async dbTest(target, options = {}) {
1187
1214
  if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1188
- return this.dbManager.testConnection(target);
1215
+ return this.dbManager.testConnection(target, options);
1189
1216
  }
1190
1217
 
1191
1218
  dbSave(patch) {
@@ -1203,19 +1230,30 @@ export class ApiRunnerService {
1203
1230
  return this.dbManager.query(id, sql, params, options);
1204
1231
  }
1205
1232
 
1206
- async dbDatabases(id) {
1233
+ async dbDatabases(id, options = {}) {
1234
+ if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1235
+ return this.dbManager.listDatabases(id, options);
1236
+ }
1237
+
1238
+ async dbTables(id, database = '', options = {}) {
1239
+ if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1240
+ return this.dbManager.listTables(id, database, options);
1241
+ }
1242
+
1243
+ async dbDescribeTable(id, table, options = {}) {
1207
1244
  if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1208
- return this.dbManager.listDatabases(id);
1245
+ return this.dbManager.describeTable(id, table, options);
1209
1246
  }
1210
1247
 
1211
- async dbTables(id, database = '') {
1248
+ // ---- 数据库项目授权(默认拒绝,必须显式授权) ----
1249
+ dbAccess(projectKey) {
1212
1250
  if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1213
- return this.dbManager.listTables(id, database);
1251
+ return this.dbManager.accessView(projectKey);
1214
1252
  }
1215
1253
 
1216
- async dbDescribeTable(id, table) {
1254
+ dbSetAccess(projectKey, connectionId, level) {
1217
1255
  if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
1218
- return this.dbManager.describeTable(id, table);
1256
+ return this.dbManager.setAccess(projectKey, connectionId, level);
1219
1257
  }
1220
1258
 
1221
1259
  dispose() {
package/test/client.mjs CHANGED
@@ -164,8 +164,9 @@ assert.ok(/db\/query/.test(source), 'SQL 执行应走 db/query 路由');
164
164
  assert.ok(/className: 'ar-tabs'/.test(source), '顶部应有页签容器 ar-tabs');
165
165
  assert.ok(/className: 'ar-segmented'/.test(source), '一级标题应是小胶囊容器 ar-segmented');
166
166
  assert.ok(/'ar-seg on' : 'ar-seg'/.test(source), '胶囊选中态应有独立样式');
167
- assert.ok(/label: '连接'/.test(source) && /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
168
- '数据库子页应有「连接 / SQL / 表结构」三个一级标题');
167
+ assert.ok(/label: '连接管理'/.test(source) && /label: '项目授权'/.test(source)
168
+ && /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
169
+ '数据库子页应有「连接管理 / 项目授权 / SQL / 表结构」四个一级标题');
169
170
  assert.ok(/const \[dbSection, setDbSection\]/.test(source), '数据库子页需要 dbSection 状态');
170
171
  assert.ok(/\.ar-segmented \{/.test(source), '缺少 .ar-segmented 样式');
171
172
  assert.ok(/--ar-elevated:/.test(source), '选中态需要 --ar-elevated 变量(深/浅色主题各一份)');
@@ -192,6 +193,44 @@ assert.ok(/el\.textContent !== CSS/.test(source), '样式内容变化时应替
192
193
  assert.ok(!/if \(document\.getElementById\(STYLE_ID\)\) return;/.test(source), '不能因 id 已存在就提前返回');
193
194
  assert.ok(/const STYLE_ID = '[^']*-v\d+'/.test(source), 'style id 应带版本后缀,避免被按 id 去重');
194
195
 
196
+ /**
197
+ * 数据库表单的三处修复(用户报的缺陷):
198
+ * 1. 端口输入框被写死 90px,和 host 挤一行时变形
199
+ * 2. 新增时填完没法试连
200
+ * 3. 支持 URL 直连
201
+ */
202
+ assert.ok(!/flex: '0 0 90px'/.test(source), '端口输入框不能固定像素宽(窄面板会变形)');
203
+ assert.ok(/测试中…' : '测试'/.test(source), '表单应有「测试」按钮,新增时也能试连');
204
+ assert.ok(/label: 'URL'/.test(source), '表单应支持 URL 录入方式');
205
+ assert.ok(/connectString/.test(source), '应支持 connectString(URL 模式)');
206
+ assert.ok(/item\.urlMode \? h\('span'/.test(source), '列表应标出 URL 模式的连接');
207
+ assert.ok(/replace\(\/\[\^\\d\]\/g, ''\)/.test(source), '端口输入框应只接受数字');
208
+
209
+ // 项目授权 UI:三档切换 + 安全说明
210
+ assert.ok(/setDbAccessLevel/.test(source), '应有设置授权的处理函数');
211
+ assert.ok(/label: '未授权'/.test(source) && /label: '只读'/.test(source) && /label: '读写'/.test(source),
212
+ '授权应有「未授权 / 只读 / 读写」三档');
213
+ assert.ok(/毁灭性操作/.test(source), '授权页应说明毁灭性操作会被拒绝');
214
+ assert.ok(/refreshDbAccess/.test(source), '应能刷新授权状态');
215
+
216
+ // UI 重设计:命名空间分离 + 设计 token
217
+ assert.ok(/className: 'ar-switch'/.test(source), '响应切换条应使用 ar-switch(不与 ar-seg 胶囊冲突)');
218
+ assert.ok(!/className: 'ar-seg'/.test(source), 'ar-seg 只能当胶囊按钮,不能再被当容器');
219
+ assert.ok(/--ar-surface-1/.test(source) && /--ar-surface-2/.test(source) && /--ar-surface-3/.test(source),
220
+ '应有三级中性面 token,用于分层');
221
+ assert.ok(/--ar-r-lg/.test(source), '应有大圆角 token');
222
+ assert.ok(/dsh-api-runner-style-v5/.test(source), '样式版本应为 v5');
223
+
224
+ /**
225
+ * 试连不能锁死整个面板:
226
+ * 达梦连不上要等十几秒,若共用全局 busy,期间保存/取消/编辑全被 disabled,
227
+ * 用户会以为界面卡死。试连必须用独立的 testing 状态。
228
+ */
229
+ assert.ok(/const \[dbTesting, setDbTesting\]/.test(source), '试连应有独立状态 dbTesting');
230
+ assert.ok(/disabled: testing \|\| busy === 'save'/.test(source), '测试按钮只锁自己,不锁其它');
231
+ assert.ok(/busy === 'save' \? '保存中…' : '保存'/.test(source), '保存按钮只受保存中影响');
232
+ assert.ok(!/busy === 'test'/.test(source), '不应再用全局 busy 表示测试中');
233
+
195
234
  /**
196
235
  * 回归:useEffect 的依赖数组里引用了「在它之后才 const 声明」的变量,
197
236
  * 会因暂时性死区抛 `Cannot access 'x' before initialization`,