dsh-api-runner 0.2.1 → 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/README.md +115 -2
- package/client/client.js +1052 -232
- package/package.json +7 -5
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +1464 -0
- package/src/main.js +205 -5
- package/src/ops.js +97 -0
- package/test/client.mjs +141 -1
- package/test/db.mjs +512 -0
- package/test/plugin.mjs +150 -1
- package/test/security.mjs +200 -0
package/src/main.js
CHANGED
|
@@ -19,30 +19,55 @@ 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
|
-
|
|
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',
|
|
42
67
|
properties: {
|
|
43
68
|
action: {
|
|
44
69
|
type: 'string',
|
|
45
|
-
enum: ['describe', 'call', 'login', 'service', 'log', 'wait_log', 'reset_log', 'verify', 'java'],
|
|
70
|
+
enum: ['describe', 'call', 'login', 'service', 'log', 'wait_log', 'reset_log', 'verify', 'java', 'db'],
|
|
46
71
|
description: '要执行的操作',
|
|
47
72
|
},
|
|
48
73
|
api_id: { type: 'string', description: '接口 id 或 name(call、login、verify 用)' },
|
|
@@ -69,6 +94,34 @@ const PARAMETERS = {
|
|
|
69
94
|
items: { type: 'string' },
|
|
70
95
|
description: 'java_action=start_all 时只启动这些服务(省略则启动全部已保存的)',
|
|
71
96
|
},
|
|
97
|
+
db_action: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
enum: ['list_connections', 'test', 'save', 'delete', 'query', 'list_databases', 'list_tables', 'describe_table'],
|
|
100
|
+
description: '数据库动作(action=db 用):list_connections 列出连接;test 试连;save 保存连接;delete 删除连接;query 执行 SQL(只读模式默认开);list_databases/list_tables/describe_table 看元数据',
|
|
101
|
+
},
|
|
102
|
+
connection_id: { type: 'string', description: '数据库连接 id(db_action=query/list_tables/describe_table/test/delete 用)' },
|
|
103
|
+
sql: { type: 'string', description: '要执行的 SQL(db_action=query 用,只允许单条语句)' },
|
|
104
|
+
params: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
additionalProperties: true,
|
|
107
|
+
description: '接口参数(action=call/login):键会替换 URL/body 里的 {key} 占位。db_action=query 时请改用 db_params。',
|
|
108
|
+
},
|
|
109
|
+
db_params: {
|
|
110
|
+
type: 'array',
|
|
111
|
+
items: {},
|
|
112
|
+
description: 'SQL 参数化占位值(db_action=query 用),按 ?/$1/:1 顺序绑定,不要拼字符串',
|
|
113
|
+
},
|
|
114
|
+
db_readonly: {
|
|
115
|
+
type: 'boolean',
|
|
116
|
+
description: '(已废弃,保留仅为兼容)模型侧永远只读,传 false 也不会放开写操作。写数据只能由人在面板里操作。',
|
|
117
|
+
},
|
|
118
|
+
connection: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
additionalProperties: true,
|
|
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',
|
|
122
|
+
},
|
|
123
|
+
table: { type: 'string', description: '表名(db_action=describe_table 用)' },
|
|
124
|
+
database: { type: 'string', description: '库名/模式名(db_action=list_tables 用;MySQL 必填)' },
|
|
72
125
|
pid: { type: 'number', description: '要接管的 Java 进程 pid(java_action=start 用)' },
|
|
73
126
|
java_id: { type: 'string', description: '插件托管的服务名或 pid(java_action=stop/restart/log/save/unsave/start_saved 用)' },
|
|
74
127
|
port: { type: 'number', description: '服务端口(java_action=start 用;省略则从进程参数推断)' },
|
|
@@ -210,6 +263,74 @@ async function runTool(service, args, projectPath) {
|
|
|
210
263
|
}
|
|
211
264
|
return { ok: false, code: 'BAD_ACTION', message: `不支持的 java_action: ${javaAction}` };
|
|
212
265
|
}
|
|
266
|
+
case 'db': {
|
|
267
|
+
const dbAction = String(args.db_action || 'list_connections');
|
|
268
|
+
/**
|
|
269
|
+
* 项目(工作空间)key:授权是按项目走的,所有 db 动作都要带上它。
|
|
270
|
+
* runTool 的 projectPath 就是会话 cwd,用它派生 key。
|
|
271
|
+
*/
|
|
272
|
+
const projectKey = projectKeyOf(projectPath);
|
|
273
|
+
if (dbAction === 'list_connections') return service.dbConnections();
|
|
274
|
+
if (dbAction === 'list_databases') {
|
|
275
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_databases 需要 connection_id' };
|
|
276
|
+
return service.dbDatabases(String(args.connection_id), { projectKey });
|
|
277
|
+
}
|
|
278
|
+
if (dbAction === 'test') {
|
|
279
|
+
const target = args.connection || args.connection_id;
|
|
280
|
+
if (!target) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=test 需要 connection_id 或 connection 配置' };
|
|
281
|
+
return service.dbTest(args.connection ? asObject(args.connection) : String(args.connection_id));
|
|
282
|
+
}
|
|
283
|
+
if (dbAction === 'save') {
|
|
284
|
+
/**
|
|
285
|
+
* connection 可能是对象,也可能被上游序列化成 JSON 字符串
|
|
286
|
+
* (工具参数走 JSON 时常见)。两种都接住,不要只认对象。
|
|
287
|
+
*/
|
|
288
|
+
const raw = args.connection;
|
|
289
|
+
let config;
|
|
290
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
291
|
+
try {
|
|
292
|
+
config = JSON.parse(raw);
|
|
293
|
+
} catch {
|
|
294
|
+
return { ok: false, code: 'BAD_ARGUMENT', message: 'connection 不是合法 JSON' };
|
|
295
|
+
}
|
|
296
|
+
} else if (raw && typeof raw === 'object') {
|
|
297
|
+
config = raw;
|
|
298
|
+
}
|
|
299
|
+
if (!config || typeof config !== 'object') {
|
|
300
|
+
return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=save 需要 connection 配置(对象或 JSON 字符串)' };
|
|
301
|
+
}
|
|
302
|
+
return service.dbSave(asObject(config));
|
|
303
|
+
}
|
|
304
|
+
if (dbAction === 'delete') {
|
|
305
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=delete 需要 connection_id' };
|
|
306
|
+
return service.dbDelete(String(args.connection_id));
|
|
307
|
+
}
|
|
308
|
+
if (dbAction === 'query') {
|
|
309
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 connection_id' };
|
|
310
|
+
if (!args.sql) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 sql' };
|
|
311
|
+
return service.dbQuery(String(args.connection_id), String(args.sql), Array.isArray(args.db_params) ? args.db_params : [], {
|
|
312
|
+
/**
|
|
313
|
+
* 模型侧**永远只读**:不接受 args 里传的 db_readonly=false。
|
|
314
|
+
* 写操作只能由人在面板里做(面板走的是另一条带 UI 确认的路径)。
|
|
315
|
+
* 这样即使模型被诱导,也删不掉任何数据。
|
|
316
|
+
*/
|
|
317
|
+
readonly: true,
|
|
318
|
+
projectKey,
|
|
319
|
+
maxRows: args.max_lines ? Number(args.max_lines) : undefined,
|
|
320
|
+
timeoutMs,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (dbAction === 'list_tables') {
|
|
324
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_tables 需要 connection_id' };
|
|
325
|
+
return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '', { projectKey });
|
|
326
|
+
}
|
|
327
|
+
if (dbAction === 'describe_table') {
|
|
328
|
+
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 connection_id' };
|
|
329
|
+
if (!args.table) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 table' };
|
|
330
|
+
return service.dbDescribeTable(String(args.connection_id), String(args.table), { projectKey });
|
|
331
|
+
}
|
|
332
|
+
return { ok: false, code: 'BAD_ACTION', message: `不支持的 db_action: ${dbAction}` };
|
|
333
|
+
}
|
|
213
334
|
case 'service': {
|
|
214
335
|
const serviceAction = String(args.service_action || 'status');
|
|
215
336
|
if (!args.service_id && serviceAction !== 'status') {
|
|
@@ -328,13 +449,15 @@ export function apply(ctx) {
|
|
|
328
449
|
const apis = info.apis.map((api) => `- ${api.id}(${api.method} ${api.url})${api.params.length ? ` 参数: ${api.params.join(', ')}` : ''}`).join('\n');
|
|
329
450
|
const services = info.services.map((item) => `- ${item.id}(${item.mode})${item.name}`).join('\n');
|
|
330
451
|
const logs = info.logs.map((item) => `- ${item.id} → ${item.path}`).join('\n');
|
|
331
|
-
|
|
452
|
+
const databases = (info.databases || []).map((item) => `- ${item.id}(${item.type})${item.name} → ${item.target}`).join('\n');
|
|
453
|
+
if (!apis && !services && !logs && !databases) return '';
|
|
332
454
|
return [
|
|
333
455
|
'# 本地开发联调(dsh-api-runner)',
|
|
334
456
|
'可用工具 ApiRunner 已配置以下资源;改完后端代码后可用它重启服务、读日志、直接调接口验证。',
|
|
335
457
|
apis ? `\n接口:\n${apis}` : '',
|
|
336
458
|
services ? `\n服务:\n${services}` : '',
|
|
337
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 传参,别拼字符串)。` : '',
|
|
338
461
|
].join('\n');
|
|
339
462
|
} catch {
|
|
340
463
|
return '';
|
|
@@ -576,6 +699,83 @@ async function handleHttp(req, res, service, projectOf) {
|
|
|
576
699
|
if (route === 'java/start-all/status' && req.method === 'GET') {
|
|
577
700
|
return send(200, service.startBatchStatus(url.searchParams.get('batch') || ''));
|
|
578
701
|
}
|
|
702
|
+
|
|
703
|
+
// ---- 数据库:连接管理与 SQL 执行(连接是跨项目的,不按 cwd 归档) ----
|
|
704
|
+
if (route === 'db' && req.method === 'GET') {
|
|
705
|
+
return send(200, service.dbConnections());
|
|
706
|
+
}
|
|
707
|
+
if (route === 'db/test' && req.method === 'POST') {
|
|
708
|
+
const body = await readBody();
|
|
709
|
+
const target = body.connection ? asObject(body.connection) : String(body.connection_id || body.id || '');
|
|
710
|
+
if (!target || (typeof target === 'string' && target === '')) {
|
|
711
|
+
return send(400, { ok: false, code: 'MISSING_ARGUMENT', message: '需要 connection_id 或 connection 配置' });
|
|
712
|
+
}
|
|
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
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
if (route === 'db/save' && req.method === 'POST') {
|
|
719
|
+
const body = await readBody();
|
|
720
|
+
return send(200, service.dbSave(asObject(body.connection || body)));
|
|
721
|
+
}
|
|
722
|
+
if (route === 'db/delete' && req.method === 'POST') {
|
|
723
|
+
const body = await readBody();
|
|
724
|
+
return send(200, service.dbDelete(String(body.connection_id || body.id || '')));
|
|
725
|
+
}
|
|
726
|
+
if (route === 'db/query' && req.method === 'POST') {
|
|
727
|
+
const body = await readBody();
|
|
728
|
+
return send(200, await service.dbQuery(
|
|
729
|
+
String(body.connection_id || ''),
|
|
730
|
+
String(body.sql || ''),
|
|
731
|
+
Array.isArray(body.params) ? body.params : [],
|
|
732
|
+
{
|
|
733
|
+
/**
|
|
734
|
+
* 面板里的写操作需要「项目授权 = readwrite」才放行,
|
|
735
|
+
* 且 DROP/TRUNCATE/无 WHERE 的 DELETE 仍会被 DESTRUCTIVE_BLOCKED 拦住。
|
|
736
|
+
* readonly 由前端传,但真正的闸门是项目授权等级。
|
|
737
|
+
*/
|
|
738
|
+
readonly: body.readonly !== false,
|
|
739
|
+
projectKey: projectKeyOf(cwd),
|
|
740
|
+
maxRows: body.max_rows ? Number(body.max_rows) : undefined,
|
|
741
|
+
timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
|
|
742
|
+
},
|
|
743
|
+
));
|
|
744
|
+
}
|
|
745
|
+
if (route === 'db/databases' && req.method === 'GET') {
|
|
746
|
+
return send(200, await service.dbDatabases(
|
|
747
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
748
|
+
{ projectKey: projectKeyOf(cwd) },
|
|
749
|
+
));
|
|
750
|
+
}
|
|
751
|
+
if (route === 'db/tables' && req.method === 'GET') {
|
|
752
|
+
return send(200, await service.dbTables(
|
|
753
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
754
|
+
String(url.searchParams.get('database') || ''),
|
|
755
|
+
{ projectKey: projectKeyOf(cwd) },
|
|
756
|
+
));
|
|
757
|
+
}
|
|
758
|
+
if (route === 'db/table' && req.method === 'GET') {
|
|
759
|
+
return send(200, await service.dbDescribeTable(
|
|
760
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
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'),
|
|
776
|
+
));
|
|
777
|
+
}
|
|
778
|
+
|
|
579
779
|
if (route === 'settings' && req.method === 'GET') {
|
|
580
780
|
return send(200, service.settings());
|
|
581
781
|
}
|
package/src/ops.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { ConfigStore } from './config.js';
|
|
8
8
|
import { callApi, pickPath } from './api.js';
|
|
9
|
+
import { DbManager } from './db.js';
|
|
9
10
|
import { JavaRunner } from './java-runner.js';
|
|
10
11
|
import { discoverLogs, listJavaProcesses, readFileChunk, readFileTail } from './java.js';
|
|
11
12
|
import { LogReader } from './log.js';
|
|
@@ -39,6 +40,12 @@ export class ApiRunnerService {
|
|
|
39
40
|
*/
|
|
40
41
|
this.startBatches = new Map();
|
|
41
42
|
this._batchSeq = 0;
|
|
43
|
+
/**
|
|
44
|
+
* 数据库连接管理。数据库能力从原 dsh-db-tool 合并进来,
|
|
45
|
+
* 但**不依赖任何 DSH 服务**(原插件把 betterSidebar 当硬依赖,
|
|
46
|
+
* 服务没就绪就一直 pending,拖垮整个 web 启动)。
|
|
47
|
+
*/
|
|
48
|
+
this.dbManager = options.dbManager || new DbManager({ homeDir: this.homeDir, settingsStore: this.settingsStore });
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/** 面板配置转成 YAML 同构的覆盖层;没有面板存储时返回 undefined */
|
|
@@ -98,6 +105,33 @@ export class ApiRunnerService {
|
|
|
98
105
|
exists: fileExists(log.path),
|
|
99
106
|
size: fileSize(log.path),
|
|
100
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
|
+
: [],
|
|
101
135
|
};
|
|
102
136
|
}
|
|
103
137
|
|
|
@@ -1161,9 +1195,72 @@ export class ApiRunnerService {
|
|
|
1161
1195
|
return { ok: true, message: `已取消关联 pid ${pid}` };
|
|
1162
1196
|
}
|
|
1163
1197
|
|
|
1198
|
+
// ---- 数据库(从 dsh-db-tool 合并进来的能力) ----
|
|
1199
|
+
/**
|
|
1200
|
+
* 数据库连接是**跨项目**的:同一套库通常被多个项目共用,
|
|
1201
|
+
* 按项目归档反而每次换项目都要重配。所以这里不接 cwd 参数。
|
|
1202
|
+
*/
|
|
1203
|
+
dbConnections() {
|
|
1204
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1205
|
+
try {
|
|
1206
|
+
const connections = this.dbManager.listConnections();
|
|
1207
|
+
return { ok: true, connections, total: connections.length };
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
return { ok: false, code: 'DB_ERROR', message: String(error?.message || error) };
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async dbTest(target, options = {}) {
|
|
1214
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1215
|
+
return this.dbManager.testConnection(target, options);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
dbSave(patch) {
|
|
1219
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1220
|
+
return this.dbManager.saveConnection(patch);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
dbDelete(id) {
|
|
1224
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1225
|
+
return this.dbManager.deleteConnection(id);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async dbQuery(id, sql, params = [], options = {}) {
|
|
1229
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1230
|
+
return this.dbManager.query(id, sql, params, options);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
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 = {}) {
|
|
1244
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1245
|
+
return this.dbManager.describeTable(id, table, options);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ---- 数据库项目授权(默认拒绝,必须显式授权) ----
|
|
1249
|
+
dbAccess(projectKey) {
|
|
1250
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1251
|
+
return this.dbManager.accessView(projectKey);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
dbSetAccess(projectKey, connectionId, level) {
|
|
1255
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1256
|
+
return this.dbManager.setAccess(projectKey, connectionId, level);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1164
1259
|
dispose() {
|
|
1165
1260
|
this.logReader.dispose();
|
|
1166
1261
|
this.serviceManager.dispose();
|
|
1262
|
+
// 数据库连接池也要关,否则插件卸载后连接泄漏
|
|
1263
|
+
if (this.dbManager) this.dbManager.dispose();
|
|
1167
1264
|
}
|
|
1168
1265
|
}
|
|
1169
1266
|
|
package/test/client.mjs
CHANGED
|
@@ -43,8 +43,10 @@ const windowStub = {
|
|
|
43
43
|
confirm: () => true,
|
|
44
44
|
document: {
|
|
45
45
|
getElementById: () => null,
|
|
46
|
-
|
|
46
|
+
querySelectorAll: () => [],
|
|
47
|
+
createElement: () => ({ set textContent(v) {}, id: '', remove() {} }),
|
|
47
48
|
head: { appendChild: () => {} },
|
|
49
|
+
documentElement: { appendChild: () => {} },
|
|
48
50
|
},
|
|
49
51
|
};
|
|
50
52
|
// bundle 里会读 document / window,通过函数参数作用域注入
|
|
@@ -148,6 +150,144 @@ const rightPanel = byName('sidebar.right.pane.tab')[0].render({ sessionId: 'sess
|
|
|
148
150
|
assert.ok(rightPanel, '右侧栏页签正文应返回元素');
|
|
149
151
|
assert.equal(typeof rightPanel.type, 'function');
|
|
150
152
|
|
|
153
|
+
// 数据库子页:结构断言(直接扫源码,避免为渲染造一堆桩)
|
|
154
|
+
assert.ok(/id: 'db', label: '数据库'/.test(source), "面板 tabs 应包含「数据库」子页");
|
|
155
|
+
assert.ok(/tab === 'db' \? dbPane : null/.test(source), '应渲染 dbPane 分支');
|
|
156
|
+
assert.ok(/function EditDbForm/.test(source), '应有数据库连接编辑表单');
|
|
157
|
+
assert.ok(/function DbResultCard/.test(source), '应有 SQL 结果卡片');
|
|
158
|
+
assert.ok(/function TableDetail/.test(source), '应有表结构视图');
|
|
159
|
+
assert.ok(/type: 'password'/.test(source), '密码框必须是 password 类型,不能明文显示');
|
|
160
|
+
assert.ok(/只读模式(拒绝写操作)/.test(source), '应有只读模式开关');
|
|
161
|
+
assert.ok(/db\/query/.test(source), 'SQL 执行应走 db/query 路由');
|
|
162
|
+
|
|
163
|
+
// UI 层级:顶部页签用 ar-tabs,一级标题用独立胶囊 ar-segmented(参照宿主插件详情页)
|
|
164
|
+
assert.ok(/className: 'ar-tabs'/.test(source), '顶部应有页签容器 ar-tabs');
|
|
165
|
+
assert.ok(/className: 'ar-segmented'/.test(source), '一级标题应是小胶囊容器 ar-segmented');
|
|
166
|
+
assert.ok(/'ar-seg on' : 'ar-seg'/.test(source), '胶囊选中态应有独立样式');
|
|
167
|
+
assert.ok(/label: '连接管理'/.test(source) && /label: '项目授权'/.test(source)
|
|
168
|
+
&& /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
|
|
169
|
+
'数据库子页应有「连接管理 / 项目授权 / SQL / 表结构」四个一级标题');
|
|
170
|
+
assert.ok(/const \[dbSection, setDbSection\]/.test(source), '数据库子页需要 dbSection 状态');
|
|
171
|
+
assert.ok(/\.ar-segmented \{/.test(source), '缺少 .ar-segmented 样式');
|
|
172
|
+
assert.ok(/--ar-elevated:/.test(source), '选中态需要 --ar-elevated 变量(深/浅色主题各一份)');
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 布局回归:一级标题胶囊必须横向一排。
|
|
176
|
+
* 曾用 flex-wrap: wrap,结果在窄面板(右侧栏 ~300px)里被挤成竖排,
|
|
177
|
+
* 用户看到的就是「连接 / SQL / 表结构」三个上下堆叠的方块。
|
|
178
|
+
*/
|
|
179
|
+
assert.ok(/\.ar-segmented \{[^}]*flex-wrap:\s*nowrap/s.test(source), 'ar-segmented 必须禁止换行');
|
|
180
|
+
assert.ok(/\.ar-segmented \{[^}]*overflow-x:\s*auto/s.test(source), 'ar-segmented 超出时应横向滚动');
|
|
181
|
+
assert.ok(/\.ar-seg \{[^}]*flex:\s*0 0 auto/s.test(source), '胶囊不应被压缩');
|
|
182
|
+
assert.ok(/\.ar-seg \{[^}]*white-space:\s*nowrap/s.test(source), '胶囊文字不应换行');
|
|
183
|
+
assert.ok(!/\.ar-segmented \{[^}]*flex-wrap:\s*wrap/s.test(source), 'ar-segmented 不能用 flex-wrap: wrap(会竖排)');
|
|
184
|
+
assert.ok(/\.ar-tabs \{[^}]*overflow-x:\s*auto/s.test(source), '顶部页签超出时应横向滚动');
|
|
185
|
+
assert.ok(/scrollIntoView/.test(source), '选中的胶囊应自动滚入视野');
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 样式热更新回归:installStyles 不能因为「元素已存在」就跳过,
|
|
189
|
+
* 否则插件升级后页面里还是旧 CSS(胶囊退化成原生 button、竖排),刷新也无效。
|
|
190
|
+
*/
|
|
191
|
+
assert.ok(/style\[id\^="dsh-api-runner-style"\]/.test(source), '应清理历史版本的 style 元素');
|
|
192
|
+
assert.ok(/el\.textContent !== CSS/.test(source), '样式内容变化时应替换,而不是跳过');
|
|
193
|
+
assert.ok(!/if \(document\.getElementById\(STYLE_ID\)\) return;/.test(source), '不能因 id 已存在就提前返回');
|
|
194
|
+
assert.ok(/const STYLE_ID = '[^']*-v\d+'/.test(source), 'style id 应带版本后缀,避免被按 id 去重');
|
|
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
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 回归:useEffect 的依赖数组里引用了「在它之后才 const 声明」的变量,
|
|
236
|
+
* 会因暂时性死区抛 `Cannot access 'x' before initialization`,
|
|
237
|
+
* 整个 Panel 崩溃 -> 面板白屏。这个 bug 真实发生过(refreshDb)。
|
|
238
|
+
*
|
|
239
|
+
* 做法:把 Panel 函数体抽出来,逐行累积「已声明的标识符」,
|
|
240
|
+
* 遇到 `}, [...])` 形式的依赖数组就检查里面的名字是否都已声明。
|
|
241
|
+
*/
|
|
242
|
+
const panelStart = source.indexOf('function Panel(props) {');
|
|
243
|
+
assert.ok(panelStart > 0, '应能定位 Panel 函数');
|
|
244
|
+
let depth = 0;
|
|
245
|
+
let panelEnd = -1;
|
|
246
|
+
for (let i = source.indexOf('{', panelStart); i < source.length; i++) {
|
|
247
|
+
if (source[i] === '{') depth += 1;
|
|
248
|
+
else if (source[i] === '}') { depth -= 1; if (depth === 0) { panelEnd = i + 1; break; } }
|
|
249
|
+
}
|
|
250
|
+
const panelBody = source.slice(panelStart, panelEnd);
|
|
251
|
+
const panelLines = panelBody.split('\n');
|
|
252
|
+
const declaredBefore = new Set();
|
|
253
|
+
const tdzIssues = [];
|
|
254
|
+
/** 全局可用的名字,不需要在 Panel 内声明 */
|
|
255
|
+
const GLOBALS = new Set(['visible', 'props', 'React', 'h', 'window', 'document', 'setInterval', 'clearInterval', 'setTimeout', 'console', 'undefined', 'null', 'true', 'false']);
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < panelLines.length; i += 1) {
|
|
258
|
+
const line = panelLines[i];
|
|
259
|
+
// 依赖数组行:`}, [a, b, c]);` 或 `}, [a, b]);`
|
|
260
|
+
const depMatch = line.match(/\},\s*\[([^\]]*)\]\s*\)/);
|
|
261
|
+
if (depMatch) {
|
|
262
|
+
for (const raw of depMatch[1].split(',')) {
|
|
263
|
+
const name = raw.trim();
|
|
264
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
|
265
|
+
if (GLOBALS.has(name)) continue;
|
|
266
|
+
if (!declaredBefore.has(name)) {
|
|
267
|
+
tdzIssues.push(`Panel 第 ${i + 1} 行:依赖 "${name}" 在该行之前未声明(TDZ 会导致面板白屏)`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// 记录本行声明:const/let/var 单变量
|
|
272
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) declaredBefore.add(m[1]);
|
|
273
|
+
// 记录解构声明:const [a, b] = ... / const { a, b } = ...
|
|
274
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s*\[([^\]]+)\]\s*=/g)) {
|
|
275
|
+
for (const part of m[1].split(',')) {
|
|
276
|
+
const name = part.trim().split(':').pop().trim();
|
|
277
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const m of line.matchAll(/\b(?:const|let|var)\s*\{([^}]+)\}\s*=/g)) {
|
|
281
|
+
for (const part of m[1].split(',')) {
|
|
282
|
+
const name = part.trim().split(':').pop().trim();
|
|
283
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// 记录函数声明
|
|
287
|
+
for (const m of line.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)) declaredBefore.add(m[1]);
|
|
288
|
+
}
|
|
289
|
+
assert.deepEqual(tdzIssues, [], `useEffect 依赖里出现了「声明在之后」的变量(会导致面板白屏):\n${tdzIssues.join('\n')}`);
|
|
290
|
+
|
|
151
291
|
// 宿主没提供 slots 时 apply 要安全退出,不能抛
|
|
152
292
|
recorded.exports.apply({ effect: () => {} });
|
|
153
293
|
|