dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
package/lib/tools.mjs
CHANGED
|
@@ -1,828 +1,1094 @@
|
|
|
1
|
-
// dsh-dbhub-live: plugin-owned tools (never synced away by the server).
|
|
2
|
-
//
|
|
3
|
-
// Model contract (zero-knowledge): exactly FOUR constant declarations —
|
|
4
|
-
// dbhub_configure configure a workspace connection (password entered by
|
|
5
|
-
// the USER in the UI, never by the model)
|
|
6
|
-
// dbhub_list_sources enumerate every registered connection source with
|
|
7
|
-
// METADATA ONLY (type/host/port/database) — no DSN, no
|
|
8
|
-
// username, no password ever crosses to the model
|
|
9
|
-
// dbhub_execute_sql run SQL on a source (one-shot process per call)
|
|
10
|
-
// dbhub_search_objects search objects on a source (one-shot process)
|
|
11
|
-
// Every tool guards on the plugin's enable/disable toggle first, executes
|
|
12
|
-
// through runAdhoc (a fresh disposable dbhub per call), and labels results
|
|
13
|
-
// with host/port/database metadata — so execution is stateless, concurrent,
|
|
14
|
-
// fault-isolated and multi-instance safe.
|
|
15
|
-
|
|
16
|
-
import { readFileSync, statSync } from 'node:fs'
|
|
17
|
-
import {
|
|
18
|
-
store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
19
|
-
connLabel, describeConn, dsnPassword, dsnUser,
|
|
20
|
-
setWorkspaceEnv, renameWorkspaceEnv, normalizeEnvName,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// workspace
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}),
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
'
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
},
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
]
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1
|
+
// dsh-dbhub-live: plugin-owned tools (never synced away by the server).
|
|
2
|
+
//
|
|
3
|
+
// Model contract (zero-knowledge): exactly FOUR constant declarations —
|
|
4
|
+
// dbhub_configure configure a workspace connection (password entered by
|
|
5
|
+
// the USER in the UI, never by the model)
|
|
6
|
+
// dbhub_list_sources enumerate every registered connection source with
|
|
7
|
+
// METADATA ONLY (type/host/port/database) — no DSN, no
|
|
8
|
+
// username, no password ever crosses to the model
|
|
9
|
+
// dbhub_execute_sql run SQL on a source (one-shot process per call)
|
|
10
|
+
// dbhub_search_objects search objects on a source (one-shot process)
|
|
11
|
+
// Every tool guards on the plugin's enable/disable toggle first, executes
|
|
12
|
+
// through runAdhoc (a fresh disposable dbhub per call), and labels results
|
|
13
|
+
// with host/port/database metadata — so execution is stateless, concurrent,
|
|
14
|
+
// fault-isolated and multi-instance safe.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import {
|
|
18
|
+
store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
19
|
+
connLabel, describeConn, dsnPassword, dsnUser,
|
|
20
|
+
setWorkspaceEnv, setWorkspaceEnvOptions, renameWorkspaceEnv, normalizeEnvName,
|
|
21
|
+
normalizeSshOptions, sshProxyJumpHops,
|
|
22
|
+
likelyAuthOrConnError, buildDsnFromParts, decideConfigureStep, argsChangedEndpoint,
|
|
23
|
+
emptyUserDenied,
|
|
24
|
+
} from './config.mjs'
|
|
25
|
+
import {
|
|
26
|
+
coreDisposers, refreshToolCount, disabledMessage,
|
|
27
|
+
collectSources, resolveSource, sourceIdOf,
|
|
28
|
+
} from './mcp.mjs'
|
|
29
|
+
import { runAdhoc, probeConnection, readOnlyViolation } from './adhoc.mjs'
|
|
30
|
+
import { walkForCandidates, extractDsnCandidates, askUser, answerItemOf, sessionCwd } from './collect.mjs'
|
|
31
|
+
import { currentT } from './i18n.mjs'
|
|
32
|
+
import * as state from './state.mjs'
|
|
33
|
+
|
|
34
|
+
function disabledReply() {
|
|
35
|
+
return { ok: false, text: disabledMessage() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── current-workspace resolution ──────────────────────────────────────────
|
|
39
|
+
//
|
|
40
|
+
// Every workspace owns its connections: the same database configured in two
|
|
41
|
+
// workspaces is TWO sources, not one shared connection. Acting on the wrong
|
|
42
|
+
// workspace is worse than asking, so this never falls back to "some other
|
|
43
|
+
// workspace" — an unresolvable target is reported back instead.
|
|
44
|
+
|
|
45
|
+
function normalizePath(p) {
|
|
46
|
+
return String(p || '').trim().replace(/[\\/]+$/, '').toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the CURRENT session workspace: an exact path match first, then the
|
|
51
|
+
* longest registered workspace path that contains the session cwd.
|
|
52
|
+
* @returns `{ workspaces, current, cwd }` (current may be undefined).
|
|
53
|
+
*/
|
|
54
|
+
async function currentWorkspace(ctx, exec) {
|
|
55
|
+
const workspaces = await listWorkspaces(ctx)
|
|
56
|
+
const cwd = sessionCwd(exec && exec.agent)
|
|
57
|
+
if (!cwd) return { workspaces, current: undefined, cwd }
|
|
58
|
+
const target = normalizePath(cwd)
|
|
59
|
+
const exact = workspaces.find((w) => normalizePath(w.path) === target)
|
|
60
|
+
if (exact) return { workspaces, current: exact, cwd }
|
|
61
|
+
const containing = workspaces
|
|
62
|
+
.filter((w) => target.startsWith(normalizePath(w.path) + '\\') || target.startsWith(normalizePath(w.path) + '/'))
|
|
63
|
+
.sort((a, b) => b.path.length - a.path.length)[0]
|
|
64
|
+
return { workspaces, current: containing, cwd }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Model-facing line for one ambiguous source candidate (metadata only). */
|
|
68
|
+
function candidateLine(c) {
|
|
69
|
+
return currentT('result.candidateRow', { srcId: c.id, title: c.title, path: c.path, env: c.env })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Shared ambiguity reply: never guess between look-alike workspaces. */
|
|
73
|
+
function ambiguousReply(ref, ambiguous) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
text: currentT('result.ambiguousSource', {
|
|
77
|
+
src: String(ref || ''),
|
|
78
|
+
list: ambiguous.map(candidateLine).join('\n'),
|
|
79
|
+
}),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** List the workspaces so the model can name one instead of guessing. */
|
|
84
|
+
function workspaceListText(workspaces) {
|
|
85
|
+
return workspaces.map((w) => '- ' + w.title + '(' + w.path + ')').join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One shared body for the source-parameterized tools: resolve the `source`
|
|
89
|
+
// handle to a configured connection (DSN stays host-side), strip the routing
|
|
90
|
+
// arg, run a one-shot dbhub process, and attach credentials guidance when the
|
|
91
|
+
// failure looks like an auth/connect problem. Routing keeps the tool DECLARATION
|
|
92
|
+
// count constant no matter how many workspaces × environments exist.
|
|
93
|
+
async function runSourceTool(ctx, subprocess, base, args, exec) {
|
|
94
|
+
if (!state.isEnabled()) return disabledReply()
|
|
95
|
+
// The session workspace is the primary key of source resolution: a handle
|
|
96
|
+
// that matches several workspaces resolves to THIS one, and only a genuine
|
|
97
|
+
// tie is bounced back as an ambiguity.
|
|
98
|
+
const { current } = await currentWorkspace(ctx, exec)
|
|
99
|
+
const src = await resolveSource(ctx, subprocess, args.source, { preferredWsPath: current ? current.path : '' })
|
|
100
|
+
if (!src) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
text: currentT('result.noSource', { src: String((args && args.source) || '') }),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (src.ambiguous) return ambiguousReply(args && args.source, src.ambiguous)
|
|
107
|
+
const mcpArgs = {}
|
|
108
|
+
for (const key of Object.keys(args || {})) {
|
|
109
|
+
if (key !== 'source') mcpArgs[key] = args[key]
|
|
110
|
+
}
|
|
111
|
+
// The row's options ride the connection descriptor: a read-only environment
|
|
112
|
+
// runs dbhub with a generated TOML (`[[tools]] readonly`), a tunnelled one
|
|
113
|
+
// with the `ssh_*` fields. A plain row is byte-for-byte the 5.0.1 `--dsn` path.
|
|
114
|
+
const res = await runAdhoc(
|
|
115
|
+
subprocess, { dsn: src.row.dsn, ro: src.row.ro === true, ssh: src.row.ssh || null },
|
|
116
|
+
base, mcpArgs, exec,
|
|
117
|
+
currentT('label.conn', { src: src.id, dsn: connLabel(src.row.dsn) }),
|
|
118
|
+
)
|
|
119
|
+
if (!res.ok && readOnlyViolation(res.text)) {
|
|
120
|
+
// dbhub refused a statement because the environment is read-only. Point the
|
|
121
|
+
// model at the user-only switch instead of letting it retry blindly (or
|
|
122
|
+
// worse, try to lift the restriction itself).
|
|
123
|
+
res.text += '\n' + currentT('result.readOnlyHint')
|
|
124
|
+
} else if (!res.ok && likelyAuthOrConnError(res.text)) {
|
|
125
|
+
res.text += '\n' + currentT('result.authHint', { src: src.id })
|
|
126
|
+
}
|
|
127
|
+
return res
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── dbhub_configure body ──────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
// Host-side connection probe (raw ok/message, never surfaced unscrubbed).
|
|
133
|
+
// `conn` is the full descriptor `{ dsn, ro, ssh }` so a probe validates the
|
|
134
|
+
// EFFECTIVE connection (tunnel included) rather than a bare DSN.
|
|
135
|
+
async function probeResult(subprocess, conn) {
|
|
136
|
+
try {
|
|
137
|
+
const p = await probeConnection(subprocess, conn)
|
|
138
|
+
return { ok: !!p.ok, message: p.message }
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { ok: false, message: String((e && e.message) || e) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Localized "✅ reachable / ⚠️ failed" line from a probe outcome.
|
|
145
|
+
function probeSoundText(probe) {
|
|
146
|
+
return probe.ok ? currentT('result.savedProbeOk') : currentT('result.savedProbeFail', { msg: probe.message })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Host-side connection check appended to every configure result so the model
|
|
150
|
+
// knows immediately whether the saved DSN works (it never has to discover the
|
|
151
|
+
// truth by trial-and-error). Probe failures surface the scrubbed reason.
|
|
152
|
+
async function connCheckText(subprocess, conn) {
|
|
153
|
+
return probeSoundText(await probeResult(subprocess, conn))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Rename a persisted environment in place (model-callable). The connection
|
|
157
|
+
// itself does not change, so no credential is re-entered and nothing is
|
|
158
|
+
// probed: only the handle the model resolves against changes. Renaming onto an
|
|
159
|
+
// existing environment needs the user's confirmation, because it would drop
|
|
160
|
+
// that environment's connection.
|
|
161
|
+
async function runRename(userQuestions, exec, target, renameFrom, toEnv) {
|
|
162
|
+
const wsPath = target.path
|
|
163
|
+
const from = normalizeEnvName(renameFrom)
|
|
164
|
+
const to = normalizeEnvName(toEnv)
|
|
165
|
+
if (from === to) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
text: currentT('result.renameNoop', {
|
|
169
|
+
env: to,
|
|
170
|
+
srcId: sourceIdOf({ title: target.title, wsPath, env: to }),
|
|
171
|
+
}),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const persisted = listWorkspaceEnvironments(store).filter((r) => r.wsPath === wsPath)
|
|
175
|
+
const fromRow = persisted.find((r) => r.env === from)
|
|
176
|
+
if (!fromRow) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
text: currentT('result.renameMissing', {
|
|
180
|
+
env: from,
|
|
181
|
+
title: target.title,
|
|
182
|
+
list: persisted.map((r) => r.env).join(', ') || '-',
|
|
183
|
+
}),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const toRow = persisted.find((r) => r.env === to)
|
|
187
|
+
if (toRow) {
|
|
188
|
+
const ans = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
189
|
+
{
|
|
190
|
+
id: 'overwrite',
|
|
191
|
+
header: currentT('result.renameOverwriteHeader', { title: target.title }),
|
|
192
|
+
question: currentT('result.renameOverwriteQ', { from, to, conn: connLabel(toRow.dsn) }),
|
|
193
|
+
options: [{ label: currentT('result.renameOverwriteOk') }],
|
|
194
|
+
},
|
|
195
|
+
])
|
|
196
|
+
const picked = answerItemOf(ans, 'overwrite')
|
|
197
|
+
if (!(picked && picked.selected && picked.selected.length)) {
|
|
198
|
+
return { ok: false, text: currentT('result.modeCanceled') }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const renamed = renameWorkspaceEnv(wsPath, from, to)
|
|
202
|
+
if (!renamed) {
|
|
203
|
+
return { ok: false, text: currentT('result.renameMissing', { env: from, title: target.title, list: '-' }) }
|
|
204
|
+
}
|
|
205
|
+
refreshToolCount()
|
|
206
|
+
state.touch()
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
text: currentT('result.renameOk', {
|
|
210
|
+
title: target.title,
|
|
211
|
+
from: from,
|
|
212
|
+
to: renamed,
|
|
213
|
+
conn: connLabel(fromRow.dsn),
|
|
214
|
+
srcId: sourceIdOf({ title: target.title, wsPath, env: renamed }),
|
|
215
|
+
}),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Copy an already-configured connection into THIS workspace (model-callable).
|
|
220
|
+
// The DSN is read and written host-side only: the model passes a source
|
|
221
|
+
// handle, never a connection string, so the copy creates a real local
|
|
222
|
+
// connection without any credential crossing the model context. This is the
|
|
223
|
+
// sanctioned way to reuse another workspace's connection — using that
|
|
224
|
+
// workspace's source handle directly would query a different target.
|
|
225
|
+
async function runCopyFrom(ctx, subprocess, target, copyFrom, envName0) {
|
|
226
|
+
const src = await resolveSource(ctx, subprocess, copyFrom, { preferredWsPath: target.path })
|
|
227
|
+
if (!src) return { ok: false, text: currentT('result.noSource', { src: copyFrom }) }
|
|
228
|
+
if (src.ambiguous) return ambiguousReply(copyFrom, src.ambiguous)
|
|
229
|
+
if (src.row.wsPath === target.path && src.row.env === envName0) {
|
|
230
|
+
return { ok: false, text: currentT('result.copySelf', { title: target.title, env: envName0 }) }
|
|
231
|
+
}
|
|
232
|
+
const envName = setWorkspaceEnv(target.path, envName0, src.row.dsn, 'copied', {
|
|
233
|
+
// The tunnel is part of "how to reach this target", and the read-only flag is
|
|
234
|
+
// part of the safety intent the user expressed on the source row: a copy
|
|
235
|
+
// that silently dropped either would be a different connection.
|
|
236
|
+
readOnly: src.row.ro === true ? true : undefined,
|
|
237
|
+
ssh: src.row.ssh || undefined,
|
|
238
|
+
})
|
|
239
|
+
refreshToolCount()
|
|
240
|
+
state.touch()
|
|
241
|
+
const sound = await connCheckText(subprocess, { dsn: src.row.dsn, ro: src.row.ro === true, ssh: src.row.ssh || null })
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
text: currentT('result.copiedOk', {
|
|
245
|
+
title: target.title,
|
|
246
|
+
env: envName,
|
|
247
|
+
fromTitle: src.row.title,
|
|
248
|
+
fromEnv: src.row.env,
|
|
249
|
+
conn: connLabel(src.row.dsn),
|
|
250
|
+
srcId: sourceIdOf({ title: target.title, wsPath: target.path, env: envName }),
|
|
251
|
+
}) + ' ' + sound,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Persist one environment and report the resulting source handle. No resident
|
|
256
|
+
// server to sync — the next call resolves this row's DSN and spawns on demand.
|
|
257
|
+
// state.touch() republishes the settings mirror so a connection configured in
|
|
258
|
+
// the chat shows up on the card immediately. A host-side probe runs right
|
|
259
|
+
// after saving and its outcome rides the result text; pass `probe` to reuse an
|
|
260
|
+
// outcome already computed for the same DSN (avoids double-probing).
|
|
261
|
+
async function persistEnv(ctx, subprocess, ws, env, dsn, source, probe, opts) {
|
|
262
|
+
const envName = setWorkspaceEnv(ws.path, env, dsn, source, opts)
|
|
263
|
+
refreshToolCount()
|
|
264
|
+
state.touch()
|
|
265
|
+
const row = listWorkspaceEnvironments(store).find((r) => r.wsPath === ws.path && r.env === envName)
|
|
266
|
+
const conn = row ? { dsn: row.dsn, ro: row.ro, ssh: row.ssh } : { dsn: dsn, ro: false, ssh: null }
|
|
267
|
+
const sound = probe ? probeSoundText(probe) : await connCheckText(subprocess, conn)
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
text: currentT('result.savedOk', {
|
|
271
|
+
slug: ws.title,
|
|
272
|
+
env: envName,
|
|
273
|
+
srcId: sourceIdOf({ title: ws.title, wsPath: ws.path, env: envName }),
|
|
274
|
+
}) + ' ' + sound,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── environment options (read-only switch, SSH tunnel) ────────────────────
|
|
279
|
+
//
|
|
280
|
+
// These are the model-facing halves of two environment properties. The
|
|
281
|
+
// asymmetry is deliberate (D7): a model may TIGHTEN (turn read-only on, add a
|
|
282
|
+
// tunnel) but must never RELAX a user's safety switch — turning read-only off is
|
|
283
|
+
// a user-only action on the configuration page. Tunnel secrets are likewise
|
|
284
|
+
// user-only: the model can name a host/user/key path, and the password or key
|
|
285
|
+
// passphrase is collected in a UI dialog.
|
|
286
|
+
|
|
287
|
+
const SSH_ARG_KEYS = ['sshHost', 'sshPort', 'sshUser', 'sshAuthKind', 'sshKeyPath', 'sshProxyJump']
|
|
288
|
+
const CONN_ARG_KEYS = ['type', 'host', 'port', 'database', 'user']
|
|
289
|
+
|
|
290
|
+
const argText = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '')
|
|
291
|
+
|
|
292
|
+
/** Whether the call asks for tunnel configuration at all. */
|
|
293
|
+
function sshRequested(args) {
|
|
294
|
+
return !!args && (args.sshOff === true || SSH_ARG_KEYS.some((k) => argText(args[k]) !== ''))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Whether the call carries connection facts (as opposed to options only). */
|
|
298
|
+
function hasConnFacts(args) {
|
|
299
|
+
return !!args && CONN_ARG_KEYS.some((k) => argText(args[k]) !== '')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Read the user's answer out of an askUser response (custom text first, then a
|
|
303
|
+
// selected option whose label is not a "leave blank" placeholder).
|
|
304
|
+
function answerText(ans, id) {
|
|
305
|
+
const item = answerItemOf(ans, id)
|
|
306
|
+
if (!item) return ''
|
|
307
|
+
if (item.custom && String(item.custom).trim()) return String(item.custom).trim()
|
|
308
|
+
if (item.selected && item.selected.length) {
|
|
309
|
+
const s = String(item.selected[0])
|
|
310
|
+
return s.startsWith('留空') ? '' : s
|
|
311
|
+
}
|
|
312
|
+
return ''
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function answersEmpty(ans) {
|
|
316
|
+
return !ans || !Array.isArray(ans.answers) || ans.answers.length === 0
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Turn the model's SSH arguments plus a stored tunnel into a patch, asking the
|
|
321
|
+
* user (never the model) for anything secret.
|
|
322
|
+
* @returns `{ ssh }` (undefined = "not requested", null = "clear it"),
|
|
323
|
+
* `{ canceled: true }`, or `{ error: 'missingUser'|'multiHop'|'missingAuth' }`.
|
|
324
|
+
*/
|
|
325
|
+
async function resolveSshRequest(ctx, exec, existingSsh, args) {
|
|
326
|
+
if (args.sshOff === true) return { ssh: null }
|
|
327
|
+
if (!sshRequested(args)) return { ssh: undefined }
|
|
328
|
+
const host = argText(args.sshHost) || (existingSsh && existingSsh.host) || ''
|
|
329
|
+
const user = argText(args.sshUser) || (existingSsh && existingSsh.user) || ''
|
|
330
|
+
if (!host || !user) return { error: 'missingUser' }
|
|
331
|
+
const auth = args.sshAuthKind === 'password' || args.sshAuthKind === 'key'
|
|
332
|
+
? args.sshAuthKind
|
|
333
|
+
: (existingSsh && existingSsh.auth) || 'key'
|
|
334
|
+
const port = argText(args.sshPort) ? Number(argText(args.sshPort)) : (existingSsh && existingSsh.port) || 22
|
|
335
|
+
const proxyJump = argText(args.sshProxyJump) !== ''
|
|
336
|
+
? argText(args.sshProxyJump)
|
|
337
|
+
: (existingSsh && existingSsh.proxyJump) || ''
|
|
338
|
+
if (sshProxyJumpHops(proxyJump) > 1) return { error: 'multiHop' }
|
|
339
|
+
let keyPath = argText(args.sshKeyPath) || (existingSsh && existingSsh.keyPath) || ''
|
|
340
|
+
let password = (existingSsh && existingSsh.auth === 'password' && existingSsh.password) || ''
|
|
341
|
+
let passphrase = (existingSsh && existingSsh.auth === 'key' && existingSsh.passphrase) || ''
|
|
342
|
+
const keep = existingSsh && existingSsh.auth === auth
|
|
343
|
+
if (!keep) {
|
|
344
|
+
// Switching auth kind: the other kind's secret does not apply.
|
|
345
|
+
password = ''
|
|
346
|
+
passphrase = ''
|
|
347
|
+
if (auth === 'key' && argText(args.sshKeyPath) === '' && existingSsh) keyPath = ''
|
|
348
|
+
}
|
|
349
|
+
const userQuestions = ctx.get('userQuestions')
|
|
350
|
+
const connMeta = host + ':' + String(port)
|
|
351
|
+
if (auth === 'password' && !password) {
|
|
352
|
+
const ans = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
353
|
+
{
|
|
354
|
+
id: 'sshPassword',
|
|
355
|
+
header: currentT('result.askSshPwdHeader', { conn: connMeta }),
|
|
356
|
+
question: currentT('result.askSshPwdQ', { host, port: String(port) }),
|
|
357
|
+
},
|
|
358
|
+
])
|
|
359
|
+
if (answersEmpty(ans)) return { canceled: true }
|
|
360
|
+
password = answerText(ans, 'sshPassword')
|
|
361
|
+
if (!password) return { error: 'missingAuth' }
|
|
362
|
+
}
|
|
363
|
+
if (auth === 'key' && !keyPath) {
|
|
364
|
+
const ans = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
365
|
+
{
|
|
366
|
+
id: 'sshKey',
|
|
367
|
+
header: currentT('result.askSshKeyHeader', { conn: connMeta }),
|
|
368
|
+
question: currentT('result.askSshKeyQ'),
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'sshPassphrase',
|
|
372
|
+
header: currentT('result.askSshPassphraseHeader', { conn: connMeta }),
|
|
373
|
+
question: currentT('result.askSshPassphraseQ'),
|
|
374
|
+
},
|
|
375
|
+
])
|
|
376
|
+
if (answersEmpty(ans)) return { canceled: true }
|
|
377
|
+
keyPath = answerText(ans, 'sshKey')
|
|
378
|
+
if (!keyPath) return { error: 'missingAuth' }
|
|
379
|
+
passphrase = answerText(ans, 'sshPassphrase')
|
|
380
|
+
}
|
|
381
|
+
const ssh = normalizeSshOptions({
|
|
382
|
+
host,
|
|
383
|
+
port,
|
|
384
|
+
user,
|
|
385
|
+
auth,
|
|
386
|
+
keyPath: auth === 'key' ? keyPath : undefined,
|
|
387
|
+
passphrase: auth === 'key' ? passphrase : undefined,
|
|
388
|
+
password: auth === 'password' ? password : undefined,
|
|
389
|
+
proxyJump,
|
|
390
|
+
})
|
|
391
|
+
if (!ssh) return { error: 'missingAuth' }
|
|
392
|
+
return { ssh }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Localized message for one `resolveSshRequest` failure. */
|
|
396
|
+
function sshErrorText(error) {
|
|
397
|
+
if (error === 'missingUser') return currentT('result.sshMissingUser')
|
|
398
|
+
if (error === 'multiHop') return currentT('result.sshMultiHopUnsupported')
|
|
399
|
+
return currentT('result.sshMissingAuth')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Store options implied by the model's arguments (undefined = preserve). */
|
|
403
|
+
function pendingOptionsOf(args, sshPlan) {
|
|
404
|
+
const opts = {}
|
|
405
|
+
if (args.readOnly !== undefined) opts.readOnly = args.readOnly === true
|
|
406
|
+
if (sshPlan && sshPlan.ssh !== undefined) opts.ssh = sshPlan.ssh
|
|
407
|
+
return Object.keys(opts).length > 0 ? opts : undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Options-only call: the model (or the user) flipped read-only / configured a
|
|
412
|
+
* tunnel without re-describing the connection. The DSN is untouched; an
|
|
413
|
+
* auto-discovered environment is promoted to a persisted row so the option has
|
|
414
|
+
* a home (D10) — that promotion happens ONLY on this explicit action.
|
|
415
|
+
*/
|
|
416
|
+
async function runOptionsOnly(ctx, subprocess, target, envName0, srcId, args, sshPlan) {
|
|
417
|
+
const wsPath = target.path
|
|
418
|
+
let promoted = false
|
|
419
|
+
let row = listWorkspaceEnvironments(store).find((r) => r.wsPath === wsPath && r.env === envName0)
|
|
420
|
+
const opts = pendingOptionsOf(args, sshPlan)
|
|
421
|
+
if (!row) {
|
|
422
|
+
let auto
|
|
423
|
+
try {
|
|
424
|
+
const envs = await resolveWorkspaceEnvs(subprocess, ctx.get('fs'), wsPath, undefined)
|
|
425
|
+
auto = envs.find((r) => r.env === envName0)
|
|
426
|
+
} catch (e) {
|
|
427
|
+
auto = undefined
|
|
428
|
+
}
|
|
429
|
+
if (!auto) return { ok: false, text: currentT('result.needConnFirst') }
|
|
430
|
+
const name = setWorkspaceEnv(wsPath, envName0, auto.dsn, 'promoted', opts)
|
|
431
|
+
row = listWorkspaceEnvironments(store).find((r) => r.wsPath === wsPath && r.env === name)
|
|
432
|
+
promoted = true
|
|
433
|
+
} else if (opts) {
|
|
434
|
+
const name = setWorkspaceEnvOptions(wsPath, envName0, opts)
|
|
435
|
+
if (!name) return { ok: false, text: currentT('result.needConnFirst') }
|
|
436
|
+
row = listWorkspaceEnvironments(store).find((r) => r.wsPath === wsPath && r.env === name)
|
|
437
|
+
}
|
|
438
|
+
refreshToolCount()
|
|
439
|
+
state.touch()
|
|
440
|
+
const conn = { dsn: row.dsn, ro: row.ro, ssh: row.ssh }
|
|
441
|
+
const probe = await probeResult(subprocess, conn)
|
|
442
|
+
const parts = []
|
|
443
|
+
if (args.readOnly === true) parts.push(currentT('result.readOnlyOn', { env: envName0 }))
|
|
444
|
+
if (sshPlan && sshPlan.ssh === null) parts.push(currentT('result.sshCleared', { env: envName0 }))
|
|
445
|
+
else if (sshPlan && sshPlan.ssh) {
|
|
446
|
+
parts.push(currentT('result.sshSet', {
|
|
447
|
+
env: envName0,
|
|
448
|
+
host: sshPlan.ssh.host,
|
|
449
|
+
port: String(sshPlan.ssh.port),
|
|
450
|
+
user: sshPlan.ssh.user,
|
|
451
|
+
auth: sshPlan.ssh.auth,
|
|
452
|
+
}))
|
|
453
|
+
}
|
|
454
|
+
parts.push(probeSoundText(probe))
|
|
455
|
+
if (promoted) parts.push(currentT('result.promotedNotice'))
|
|
456
|
+
if (!probe.ok && likelyAuthOrConnError(probe.message)) parts.push(currentT('result.authHint', { src: srcId }))
|
|
457
|
+
return { ok: true, text: parts.join(' ') }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function runConfigure(ctx, subprocess, args, exec) {
|
|
461
|
+
if (!state.isEnabled()) return disabledReply()
|
|
462
|
+
const userQuestions = ctx.get('userQuestions')
|
|
463
|
+
const { workspaces, current } = await currentWorkspace(ctx, exec)
|
|
464
|
+
// Target workspace: an explicit argument must MATCH (path or title); a bare
|
|
465
|
+
// call targets the session workspace. There is deliberately no "first
|
|
466
|
+
// workspace" fallback — silently configuring someone else's workspace (and
|
|
467
|
+
// then querying it) is exactly the confusion this resolver exists to stop.
|
|
468
|
+
let target
|
|
469
|
+
if (typeof args.workspace === 'string' && args.workspace.trim()) {
|
|
470
|
+
const wanted = args.workspace.trim()
|
|
471
|
+
target = workspaces.find((w) => w.path === wanted || normalizePath(w.path) === normalizePath(wanted))
|
|
472
|
+
|| workspaces.find((w) => w.title === wanted)
|
|
473
|
+
if (!target) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
text: currentT('result.unknownWorkspace', { ws: wanted, list: workspaceListText(workspaces) }),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
target = current
|
|
481
|
+
if (!target) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
text: currentT('result.needWorkspace', { list: workspaceListText(workspaces) }),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const wsPath = target.path
|
|
489
|
+
const envName0 = normalizeEnvName(args.env)
|
|
490
|
+
const srcId = sourceIdOf({ title: target.title, wsPath, env: envName0 })
|
|
491
|
+
|
|
492
|
+
// Security red line: a password (or full DSN) passed by the model as a tool
|
|
493
|
+
// argument would land in the model context. Refuse loudly — the password is
|
|
494
|
+
// always entered by the user through the UI channel below.
|
|
495
|
+
if (typeof args.dsn === 'string' && String(args.dsn).trim() !== '') {
|
|
496
|
+
return { ok: false, text: currentT('result.noDsnViaModel') }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── security asymmetry (D7): the model may TIGHTEN a safety switch, never
|
|
500
|
+
// relax it. Turning read-only OFF is a user-only action on the page. This
|
|
501
|
+
// guard lives on the TOOL path only — the browser's configOp must keep
|
|
502
|
+
// full freedom, or the user could never turn it off at all. It is checked
|
|
503
|
+
// BEFORE the rename/copy dispatch so an explicit `readOnly:false` is never
|
|
504
|
+
// silently absorbed by another branch.
|
|
505
|
+
if (args.readOnly === false) {
|
|
506
|
+
return { ok: false, text: currentT('result.readOnlyUserOnly') }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── environment operations that need no connection facts ─────────────────
|
|
510
|
+
const renameFrom = typeof args.renameFrom === 'string' && args.renameFrom.trim() ? args.renameFrom.trim() : ''
|
|
511
|
+
const copyFrom = typeof args.copyFrom === 'string' && args.copyFrom.trim() ? args.copyFrom.trim() : ''
|
|
512
|
+
if (renameFrom && copyFrom) return { ok: false, text: currentT('result.argConflict') }
|
|
513
|
+
if (renameFrom) return await runRename(userQuestions, exec, target, renameFrom, envName0)
|
|
514
|
+
if (copyFrom) return await runCopyFrom(ctx, subprocess, target, copyFrom, envName0)
|
|
515
|
+
|
|
516
|
+
// ── read-only / SSH tunnel: resolve the requested options (asking the USER
|
|
517
|
+
// for any secret) before deciding which flow handles this call.
|
|
518
|
+
const existingOptRow = listWorkspaceEnvironments(store).find((r) => r.wsPath === wsPath && r.env === envName0)
|
|
519
|
+
let sshPlan = { ssh: undefined }
|
|
520
|
+
if (sshRequested(args)) {
|
|
521
|
+
sshPlan = await resolveSshRequest(ctx, exec, existingOptRow ? existingOptRow.ssh : null, args)
|
|
522
|
+
if (sshPlan.canceled) return { ok: false, text: currentT('result.modeCanceled') }
|
|
523
|
+
if (sshPlan.error) return { ok: false, text: sshErrorText(sshPlan.error) }
|
|
524
|
+
}
|
|
525
|
+
// `undefined` = "the call said nothing about the tunnel" (keep it), `null` =
|
|
526
|
+
// "remove it". The two must never be conflated by a truthiness test.
|
|
527
|
+
const planSsh = sshPlan && sshPlan.ssh !== undefined ? sshPlan.ssh : undefined
|
|
528
|
+
const wantsOptions = args.readOnly !== undefined || sshRequested(args)
|
|
529
|
+
if (wantsOptions && !hasConnFacts(args)) {
|
|
530
|
+
return await runOptionsOnly(ctx, subprocess, target, envName0, srcId, args, sshPlan)
|
|
531
|
+
}
|
|
532
|
+
// Options requested TOGETHER with connection facts: they are written by the
|
|
533
|
+
// same store write that persists the connection.
|
|
534
|
+
const pendingOpts = pendingOptionsOf(args, sshPlan)
|
|
535
|
+
|
|
536
|
+
// ── non-secret connection facts the Host can assemble (NEVER the password):
|
|
537
|
+
// model-supplied prefills, completed from an existing (auto/saved) row
|
|
538
|
+
// for this workspace × env when present.
|
|
539
|
+
const prefill = {}
|
|
540
|
+
for (const k of ['type', 'host', 'port', 'database', 'user']) {
|
|
541
|
+
if (typeof args[k] === 'string' && String(args[k]).trim() !== '') prefill[k] = String(args[k]).trim()
|
|
542
|
+
}
|
|
543
|
+
let existingRow
|
|
544
|
+
try {
|
|
545
|
+
const envs = await resolveWorkspaceEnvs(subprocess, ctx.get('fs'), wsPath, undefined)
|
|
546
|
+
existingRow = envs.find((r) => r.env === envName0) || undefined
|
|
547
|
+
} catch (e) {
|
|
548
|
+
existingRow = undefined
|
|
549
|
+
}
|
|
550
|
+
if (existingRow) {
|
|
551
|
+
const meta = describeConn(existingRow.dsn)
|
|
552
|
+
const un = dsnUser(existingRow.dsn)
|
|
553
|
+
if (!prefill.type && meta.type) prefill.type = meta.type
|
|
554
|
+
if (!prefill.host && meta.host) prefill.host = meta.host
|
|
555
|
+
if (!prefill.port && meta.port) prefill.port = meta.port
|
|
556
|
+
if (!prefill.database && meta.database) prefill.database = meta.database
|
|
557
|
+
if (!prefill.user && un) prefill.user = un
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── probe first, ask only what is actually missing ───────────────────────
|
|
561
|
+
// Two targets:
|
|
562
|
+
// - the EXISTING row (its real DSN carries the saved password) is verified
|
|
563
|
+
// when the model didn't change the endpoint;
|
|
564
|
+
// - when the model names a DIFFERENT endpoint (re-described DB), the old
|
|
565
|
+
// row is superseded: the probe-first loop re-runs against a candidate
|
|
566
|
+
// assembled from the new prefills (empty password).
|
|
567
|
+
// Whatever connects without further input is persisted directly — a dialog
|
|
568
|
+
// appears only when the probe says credentials are needed or the non-secret
|
|
569
|
+
// info is incomplete.
|
|
570
|
+
const existingMeta = existingRow ? describeConn(existingRow.dsn) : undefined
|
|
571
|
+
const endpointChanged = argsChangedEndpoint(args, existingMeta)
|
|
572
|
+
const existingTarget = existingRow && !endpointChanged ? existingRow : undefined
|
|
573
|
+
// Every probe validates the EFFECTIVE connection: a tunnelled environment
|
|
574
|
+
// must be probed through its tunnel, or the check would prove nothing.
|
|
575
|
+
const connOpts = {
|
|
576
|
+
ro: args.readOnly === true || !!(existingOptRow && existingOptRow.ro),
|
|
577
|
+
ssh: planSsh !== undefined ? planSsh : (existingOptRow ? existingOptRow.ssh : null),
|
|
578
|
+
}
|
|
579
|
+
const existingCheck = existingTarget ? await probeResult(subprocess, { dsn: existingTarget.dsn, ...connOpts }) : undefined
|
|
580
|
+
const candidateCheck = (!existingTarget && prefill.type && prefill.host && prefill.database && prefill.type.toLowerCase() !== 'sqlite')
|
|
581
|
+
? await probeResult(subprocess, { dsn: buildDsnFromParts(prefill), ...connOpts })
|
|
582
|
+
: undefined
|
|
583
|
+
const step = decideConfigureStep({ existingRow: existingTarget, existingCheck, prefill, candidateCheck })
|
|
584
|
+
|
|
585
|
+
// already-configured environment that passes the probe → zero input needed
|
|
586
|
+
if (step.kind === 'existing-ok') {
|
|
587
|
+
// …but an options request that accompanied the call must still be applied.
|
|
588
|
+
if (pendingOpts) {
|
|
589
|
+
setWorkspaceEnvOptions(wsPath, envName0, pendingOpts)
|
|
590
|
+
refreshToolCount()
|
|
591
|
+
state.touch()
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
ok: true,
|
|
595
|
+
text: currentT('result.existingOk', {
|
|
596
|
+
env: envName0,
|
|
597
|
+
conn: connLabel(existingTarget.dsn),
|
|
598
|
+
srcId,
|
|
599
|
+
}),
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// everything the model already knows connects → persist with no dialog
|
|
604
|
+
if (step.kind === 'persist-direct') {
|
|
605
|
+
const dsn = buildDsnFromParts(prefill)
|
|
606
|
+
const envName = setWorkspaceEnv(wsPath, envName0, dsn, 'user', pendingOpts)
|
|
607
|
+
refreshToolCount()
|
|
608
|
+
state.touch()
|
|
609
|
+
return {
|
|
610
|
+
ok: true,
|
|
611
|
+
text: currentT('result.autoOk', {
|
|
612
|
+
slug: target.title,
|
|
613
|
+
env: envName,
|
|
614
|
+
conn: connLabel(dsn),
|
|
615
|
+
srcId: sourceIdOf({ title: target.title, wsPath, env: envName }),
|
|
616
|
+
}),
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// only the credentials are missing/stale → ONE minimal account+password
|
|
621
|
+
// dialog (account is asked only when it is unknown; password always). The
|
|
622
|
+
// question carries the connection metadata and the probe diagnosis so the
|
|
623
|
+
// user never re-types what the model already provided; a "wrong connection"
|
|
624
|
+
// escape routes back to the full input options instead of trapping the user.
|
|
625
|
+
if (step.kind === 'ask-credentials') {
|
|
626
|
+
// The question must never re-ask what the model already provided: show the
|
|
627
|
+
// connection metadata (type://host:port/db) it applies to.
|
|
628
|
+
const connMeta = (prefill.type && prefill.host && prefill.database)
|
|
629
|
+
? connLabel(buildDsnFromParts(prefill))
|
|
630
|
+
: (existingTarget ? connLabel(existingTarget.dsn) : '')
|
|
631
|
+
const diagnosis = (existingCheck && existingCheck.message) || (candidateCheck && candidateCheck.message) || ''
|
|
632
|
+
// When the probe explicitly blames an EMPTY account ("Access denied for
|
|
633
|
+
// user ''@..."), the account is NOT optional: offering a leave-empty choice
|
|
634
|
+
// would re-create exactly the DSN that failed. The question then requires
|
|
635
|
+
// a non-empty account instead of the "use empty account" option.
|
|
636
|
+
const accountRequired = !prefill.user && step.emptyUserDenied === true
|
|
637
|
+
const questions = []
|
|
638
|
+
if (!prefill.user) {
|
|
639
|
+
questions.push({
|
|
640
|
+
id: 'account',
|
|
641
|
+
header: currentT('result.askAccountHeader', { conn: connMeta }),
|
|
642
|
+
question: accountRequired
|
|
643
|
+
? currentT('result.askAccountRequiredQ', { conn: connMeta })
|
|
644
|
+
: currentT('result.askAccountQ', { conn: connMeta }),
|
|
645
|
+
detail: diagnosis,
|
|
646
|
+
...(accountRequired ? {} : { options: [{ label: '留空(使用空账号)' }] }),
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
questions.push({
|
|
650
|
+
id: 'password',
|
|
651
|
+
header: currentT('result.askPwdHeader', { conn: connMeta }),
|
|
652
|
+
question: currentT('result.askPwdQ', { conn: connMeta }),
|
|
653
|
+
detail: prefill.user ? diagnosis : '',
|
|
654
|
+
})
|
|
655
|
+
questions.push({
|
|
656
|
+
id: 'more',
|
|
657
|
+
header: currentT('result.askCredMoreHeader'),
|
|
658
|
+
question: currentT('result.askCredMoreQ'),
|
|
659
|
+
options: [{ label: currentT('result.askCredMore'), description: currentT('result.askCredMoreDesc') }],
|
|
660
|
+
})
|
|
661
|
+
const ans2 = await askUser(userQuestions, exec.agent, exec.signal, questions)
|
|
662
|
+
const moreItem = answerItemOf(ans2, 'more')
|
|
663
|
+
const wantsOther = moreItem && moreItem.selected && moreItem.selected[0] === currentT('result.askCredMore')
|
|
664
|
+
if (!wantsOther) {
|
|
665
|
+
const accountItem = answerItemOf(ans2, 'account')
|
|
666
|
+
const pwdItem = answerItemOf(ans2, 'password')
|
|
667
|
+
// Dialog dismissed / service unavailable → treat as cancel, never as a
|
|
668
|
+
// validation error of the (unanswered) fields below.
|
|
669
|
+
if (!ans2 || !Array.isArray(ans2.answers) || ans2.answers.length === 0) {
|
|
670
|
+
return { ok: false, text: currentT('result.modeCanceled') }
|
|
671
|
+
}
|
|
672
|
+
const account = accountItem ? String(accountItem.custom || '').trim() : ''
|
|
673
|
+
const password = pwdItem ? String(pwdItem.custom || '').trim() : ''
|
|
674
|
+
if (accountRequired && !account) {
|
|
675
|
+
// Saving the empty-account DSN again would reproduce the probe failure.
|
|
676
|
+
return { ok: false, text: currentT('result.askAccountRequired') }
|
|
677
|
+
}
|
|
678
|
+
if (!password) return { ok: false, text: currentT('result.noDsn') }
|
|
679
|
+
const dsn = buildDsnFromParts({ ...prefill, user: prefill.user || account, password })
|
|
680
|
+
// Probe the NEW DSN and reuse the outcome (persistEnv probes only when
|
|
681
|
+
// no probe is passed); an auth-style failure appends the update hint so
|
|
682
|
+
// the model never has to discover it by trial-and-error.
|
|
683
|
+
const probe = await probeResult(subprocess, { dsn, ...connOpts })
|
|
684
|
+
const r = await persistEnv(ctx, subprocess, target, envName0, dsn, 'user', probe, pendingOpts)
|
|
685
|
+
if (!probe.ok && likelyAuthOrConnError(probe.message)) {
|
|
686
|
+
r.text += '\n' + currentT('result.authHint', { src: srcId })
|
|
687
|
+
}
|
|
688
|
+
return r
|
|
689
|
+
}
|
|
690
|
+
// fall through to the mode-selection dialog below (the user says the
|
|
691
|
+
// connection itself is wrong)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── fallback: info incomplete or probe failed non-auth → mode selection ──
|
|
695
|
+
// Options do NOT include a "password only" row anymore: when the probe says
|
|
696
|
+
// only credentials are missing the minimal dialog above already handles it.
|
|
697
|
+
const useExisting = [
|
|
698
|
+
{ label: currentT('result.useExisting'), description: currentT('result.useExistingDesc') },
|
|
699
|
+
]
|
|
700
|
+
const fullOptions = [
|
|
701
|
+
{ label: '输入完整 DSN', description: '如 mysql://user:pass@host:3306/db(界面输入,含密码则不再另问)' },
|
|
702
|
+
{ label: '填写分项(类型/主机/端口/账号/密码/库名)' },
|
|
703
|
+
{ label: '扫描项目配置文件(需授权,可能读取含密码的文件)' },
|
|
704
|
+
]
|
|
705
|
+
const modeOptions = existingTarget ? useExisting.concat(fullOptions) : fullOptions
|
|
706
|
+
// NB: no "取消" option rows — the question dialog already renders its own
|
|
707
|
+
// cancel button; adding one would show two cancels. Dismissing/closed asks
|
|
708
|
+
// resolve to undefined below and are treated as cancel.
|
|
709
|
+
|
|
710
|
+
const ans = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
711
|
+
{
|
|
712
|
+
id: 'mode',
|
|
713
|
+
header: '配置工作区数据库连接:' + target.title,
|
|
714
|
+
question: existingTarget
|
|
715
|
+
? currentT('result.confExistingBroken', {
|
|
716
|
+
env: envName0,
|
|
717
|
+
conn: connLabel(existingTarget.dsn),
|
|
718
|
+
msg: existingCheck ? existingCheck.message : '',
|
|
719
|
+
})
|
|
720
|
+
: step.failed
|
|
721
|
+
? currentT('result.confQ') + ' ' + currentT('result.probeFailNote', { msg: candidateCheck ? candidateCheck.message : '' })
|
|
722
|
+
: currentT('result.confQ'),
|
|
723
|
+
options: modeOptions,
|
|
724
|
+
},
|
|
725
|
+
])
|
|
726
|
+
const mode = answerItemOf(ans, 'mode')
|
|
727
|
+
const chosen = mode && mode.selected && mode.selected[0]
|
|
728
|
+
if (!chosen) return { ok: false, text: currentT('result.modeCanceled') }
|
|
729
|
+
|
|
730
|
+
if (existingTarget && chosen === currentT('result.useExisting')) {
|
|
731
|
+
// No re-entry, no config change — just confirm and let the caller proceed.
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
text: currentT('result.useExistingOk', {
|
|
735
|
+
env: envName0,
|
|
736
|
+
conn: connLabel(existingTarget.dsn),
|
|
737
|
+
srcId,
|
|
738
|
+
}),
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (chosen === '输入完整 DSN') {
|
|
743
|
+
const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
744
|
+
{
|
|
745
|
+
id: 'dsn',
|
|
746
|
+
header: '完整 DSN',
|
|
747
|
+
question: '请输入数据库连接串(若已含密码则提交后不再询问;只在界面输入,不会发送给模型)',
|
|
748
|
+
},
|
|
749
|
+
])
|
|
750
|
+
const dsn = answerItemOf(ans2, 'dsn')
|
|
751
|
+
const text = dsn && dsn.custom && dsn.custom.trim()
|
|
752
|
+
if (!text) return { ok: false, text: currentT('result.noDsn') }
|
|
753
|
+
const r = await persistEnv(ctx, subprocess, target, envName0, text, 'user', undefined, pendingOpts)
|
|
754
|
+
if (!dsnPassword(text)) r.text += ' ' + currentT('result.noPasswordInDsn')
|
|
755
|
+
return r
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (chosen === '填写分项(类型/主机/端口/账号/密码/库名)') {
|
|
759
|
+
// The field form carries every known value so the user only fills the
|
|
760
|
+
// gaps — untouched fields keep the prefill-derived value.
|
|
761
|
+
const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
762
|
+
{
|
|
763
|
+
id: 'type',
|
|
764
|
+
header: '类型',
|
|
765
|
+
question: '数据库类型',
|
|
766
|
+
options: [{ label: 'mysql' }, { label: 'postgres' }, { label: 'mariadb' }, { label: 'sqlite' }, { label: 'sqlserver' }],
|
|
767
|
+
},
|
|
768
|
+
{ id: 'host', header: '主机', question: currentT('result.askHost', { cur: prefill.host || '(未提供,默认 localhost)' }), options: [{ label: '留空(默认 localhost)' }] },
|
|
769
|
+
{ id: 'port', header: '端口', question: currentT('result.askPort', { cur: prefill.port || '(未提供,用默认端口)' }), options: [{ label: '留空' }] },
|
|
770
|
+
{ id: 'user', header: '账号', question: currentT('result.askUserField', { cur: prefill.user || '(未提供)' }), options: [{ label: '留空' }] },
|
|
771
|
+
{ id: 'password', header: '密码', question: '密码(界面输入,不会发送给模型)', options: [{ label: '留空' }] },
|
|
772
|
+
{ id: 'db', header: '库名', question: currentT('result.askDatabase', { cur: prefill.database || '(未提供)' }), options: [{ label: '留空' }] },
|
|
773
|
+
])
|
|
774
|
+
const pick = (id, fallback) => {
|
|
775
|
+
const item = answerItemOf(ans2, id)
|
|
776
|
+
if (item && item.custom && item.custom.trim()) return item.custom.trim()
|
|
777
|
+
if (item && item.selected && item.selected.length) {
|
|
778
|
+
const s = item.selected[0]
|
|
779
|
+
return s.startsWith('留空') ? '' : s
|
|
780
|
+
}
|
|
781
|
+
return fallback // untouched → keep the prefill-derived value
|
|
782
|
+
}
|
|
783
|
+
const dsn = buildDsnFromParts({
|
|
784
|
+
type: pick('type', prefill.type || 'mysql'),
|
|
785
|
+
host: pick('host', prefill.host || ''),
|
|
786
|
+
port: pick('port', prefill.port || ''),
|
|
787
|
+
user: pick('user', prefill.user || ''),
|
|
788
|
+
password: pick('password', ''),
|
|
789
|
+
database: pick('db', prefill.database || ''),
|
|
790
|
+
})
|
|
791
|
+
return persistEnv(ctx, subprocess, target, envName0, dsn, 'user', undefined, pendingOpts)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// scan path: authorization gate FIRST
|
|
795
|
+
const auth = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
796
|
+
{
|
|
797
|
+
id: 'auth',
|
|
798
|
+
header: '授权扫描',
|
|
799
|
+
question: '将扫描工作区项目中的常见数据库配置文件(.env / application*.yml / docker-compose / jdbc.properties 等)' +
|
|
800
|
+
'并读取其中的连接信息(可能包含账号密码)。此操作会读取敏感文件且消耗较多 token,是否授权?',
|
|
801
|
+
options: [
|
|
802
|
+
{ label: '授权扫描', description: '扫描后列出候选供你确认(只显示主机/端口/库,密码不显示),确认才持久化' },
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
])
|
|
806
|
+
const authChosen = answerItemOf(auth, 'auth')
|
|
807
|
+
const authOk = authChosen && authChosen.selected && authChosen.selected[0] === '授权扫描'
|
|
808
|
+
if (!authOk) return { ok: false, text: currentT('result.scanDenied') }
|
|
809
|
+
|
|
810
|
+
const budget = { count: 0 }
|
|
811
|
+
const files = walkForCandidates(wsPath, budget)
|
|
812
|
+
if (files.length === 0) return { ok: false, text: currentT('result.scanEmpty') }
|
|
813
|
+
const candidates = []
|
|
814
|
+
for (const f of files) {
|
|
815
|
+
let text
|
|
816
|
+
try {
|
|
817
|
+
const st = statSync(f)
|
|
818
|
+
if (st.size > 64 * 1024) continue
|
|
819
|
+
text = readFileSync(f, 'utf8')
|
|
820
|
+
} catch (e) {
|
|
821
|
+
continue
|
|
822
|
+
}
|
|
823
|
+
for (const c of extractDsnCandidates(f, text)) {
|
|
824
|
+
const rel = f.replace(wsPath, '.').replace(/\\/g, '/')
|
|
825
|
+
candidates.push({ label: rel + ' → ' + connLabel(c.dsn), dsn: c.dsn, via: c.via })
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (candidates.length === 0) return { ok: false, text: currentT('result.scanNone', { n: files.length }) }
|
|
829
|
+
const pick = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
830
|
+
{
|
|
831
|
+
id: 'pick',
|
|
832
|
+
header: '选择连接',
|
|
833
|
+
question: '扫描到以下候选(只显示主机/端口/库,密码不显示;选定后由插件直接读取并持久化),选择要使用的:',
|
|
834
|
+
options: candidates.map((c) => ({ label: c.label, description: '来源: ' + c.via })),
|
|
835
|
+
},
|
|
836
|
+
])
|
|
837
|
+
const picked = answerItemOf(pick, 'pick')
|
|
838
|
+
const label = picked && picked.selected && picked.selected[0]
|
|
839
|
+
if (!label) return { ok: false, text: '未选择,已取消' }
|
|
840
|
+
const match = candidates.find((c) => c.label === label)
|
|
841
|
+
if (!match) return { ok: false, text: '选择无效' }
|
|
842
|
+
const envName = setWorkspaceEnv(wsPath, envName0, match.dsn, 'collected', pendingOpts)
|
|
843
|
+
refreshToolCount()
|
|
844
|
+
state.touch()
|
|
845
|
+
const sound = await connCheckText(subprocess, {
|
|
846
|
+
dsn: match.dsn,
|
|
847
|
+
ro: args.readOnly === true,
|
|
848
|
+
ssh: planSsh !== undefined ? planSsh : null,
|
|
849
|
+
})
|
|
850
|
+
return {
|
|
851
|
+
ok: true,
|
|
852
|
+
text: currentT('result.scanPicked', {
|
|
853
|
+
env: envName,
|
|
854
|
+
srcId: sourceIdOf({ title: target.title, wsPath, env: envName }),
|
|
855
|
+
}) + ' ' + sound,
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ── tool builders ─────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
// Enumerate every registered connection source so the model can discover
|
|
862
|
+
// named environments (test/prod/...) and their source handles without guessing.
|
|
863
|
+
// Metadata only — a DSN (and its password) never crosses this surface.
|
|
864
|
+
function buildListSourcesTool(ctx, subprocess) {
|
|
865
|
+
return {
|
|
866
|
+
name: 'dbhub_list_sources',
|
|
867
|
+
description:
|
|
868
|
+
'列出当前已注册的全部数据库连接源,按【工作区】分组(每条 = 一个工作区的某个环境),只给元数据(类型/主机/端口/库名;密码与账号永不显示,也绝不能尝试获取)。' +
|
|
869
|
+
'工作区模型(重要):**每条 source 只属于一个工作区**。不同工作区即使配置了看起来相同的连接,也是**不同的目标**,绝不能当作同一个来用;返回结果会明确标出【当前工作区】。' +
|
|
870
|
+
'**优先使用当前工作区的 source**(用返回的 source 值调用 dbhub_execute_sql / dbhub_search_objects)。' +
|
|
871
|
+
'仅当当前工作区没有所需连接时才参考其他工作区:先向用户确认能否复用那个连接,确认后调用 dbhub_configure 并传 copyFrom(把该连接复制到当前工作区,连接串与密码不出宿主)——**不要直接用其他工作区的 source 值查询**,那会打到别的目标上。' +
|
|
872
|
+
'需要判断某个环境(如 demo/test/prod/线上)是否已配置时,**先调用本工具确认,不要直接向用户索要连接信息**(已配置的环境会列出;未列出的才需要 dbhub_configure 新增)。' +
|
|
873
|
+
'当同一个名字(同名工作区、或同名环境)在多个工作区都存在时,必须用本工具返回的完整 source 值指定目标,不要用简称猜测。' +
|
|
874
|
+
'注意:能列出不代表数据库当前可达;不可达/凭据有误的环境只会在该 source 的调用上返回连接错误并附修改指引。每次调用为独立一次性连接(进程级隔离,多任务并发互不影响)。无参数。',
|
|
875
|
+
parameters: {
|
|
876
|
+
type: 'object',
|
|
877
|
+
properties: {},
|
|
878
|
+
required: [],
|
|
879
|
+
},
|
|
880
|
+
timeoutMs: 30000,
|
|
881
|
+
isConcurrencySafe: () => true,
|
|
882
|
+
output: {
|
|
883
|
+
schema: {},
|
|
884
|
+
render: (_args, value) => [
|
|
885
|
+
{
|
|
886
|
+
type: 'text',
|
|
887
|
+
text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
},
|
|
891
|
+
async execute(_args, exec) {
|
|
892
|
+
if (!state.isEnabled()) return disabledReply()
|
|
893
|
+
const { rows } = await collectSources(ctx, subprocess)
|
|
894
|
+
if (rows.length === 0) {
|
|
895
|
+
return { ok: true, text: currentT('result.sourcesEmpty') }
|
|
896
|
+
}
|
|
897
|
+
const { current } = await currentWorkspace(ctx, exec)
|
|
898
|
+
// Group by workspace so the model reads the ownership FIRST: a flat list
|
|
899
|
+
// let two workspaces' look-alike connections blur into one another.
|
|
900
|
+
const groups = []
|
|
901
|
+
const byPath = new Map()
|
|
902
|
+
for (const r of rows) {
|
|
903
|
+
let g = byPath.get(r.wsPath)
|
|
904
|
+
if (!g) {
|
|
905
|
+
g = { title: r.title, path: r.wsPath, rows: [] }
|
|
906
|
+
byPath.set(r.wsPath, g)
|
|
907
|
+
groups.push(g)
|
|
908
|
+
}
|
|
909
|
+
g.rows.push(r)
|
|
910
|
+
}
|
|
911
|
+
const localGroup = current ? byPath.get(current.path) : undefined
|
|
912
|
+
const others = groups.filter((g) => !localGroup || g.path !== localGroup.path)
|
|
913
|
+
const rowLine = (r) => currentT('result.srcRow', {
|
|
914
|
+
env: r.env,
|
|
915
|
+
badge: r.persisted ? currentT('result.badgeSaved') : currentT('result.badgeAuto'),
|
|
916
|
+
ro: r.ro ? '|' + currentT('result.badgeReadOnly') : '',
|
|
917
|
+
conn: connLabel(r.dsn),
|
|
918
|
+
src: r.source || '?',
|
|
919
|
+
srcId: sourceIdOf(r),
|
|
920
|
+
})
|
|
921
|
+
const blocks = [currentT('result.sourcesIntro', { n: String(rows.length), m: String(groups.length) })]
|
|
922
|
+
if (localGroup) {
|
|
923
|
+
blocks.push(currentT('result.wsCurrentHead') + '\n' + currentT('result.wsGroup', { title: localGroup.title, path: localGroup.path }) + '\n' + localGroup.rows.map(rowLine).join('\n'))
|
|
924
|
+
} else {
|
|
925
|
+
blocks.push(currentT('result.wsNoCurrent'))
|
|
926
|
+
}
|
|
927
|
+
if (others.length > 0) {
|
|
928
|
+
blocks.push(currentT('result.wsOtherHead') + '\n' + others
|
|
929
|
+
.map((g) => currentT('result.wsGroup', { title: g.title, path: g.path }) + '\n' + g.rows.map(rowLine).join('\n'))
|
|
930
|
+
.join('\n'))
|
|
931
|
+
}
|
|
932
|
+
return { ok: true, text: blocks.join('\n\n') }
|
|
933
|
+
},
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function buildConfigureTool(ctx, subprocess) {
|
|
938
|
+
return {
|
|
939
|
+
name: 'dbhub_configure',
|
|
940
|
+
description:
|
|
941
|
+
'配置某个工作区的数据库连接(持久化)。参数:workspace(工作区路径或标题,省略 = 当前会话工作区;给错会报错并列出可用工作区,不会替你换一个)、env(环境名,默认 default,支持中文如 线上/测试,同一工作区可添加多个环境)、' +
|
|
942
|
+
'type/host/port/database/user(可选的非敏感预填信息)、renameFrom(改名)、copyFrom(从其他工作区复制连接)。' +
|
|
943
|
+
'工作区模型(重要):**每条连接只属于一个工作区**,不同工作区的相似连接是不同目标。默认只操作当前会话工作区;需要操作别的工作区时必须显式传 workspace。' +
|
|
944
|
+
'安全约定:密码或完整 DSN **不能作为本工具参数传入**(会直接拒绝)——插件会在界面弹窗让用户输入密码(不经模型)。' +
|
|
945
|
+
'改名:传 env = 新名字 + renameFrom = 旧名字,连接与凭据原样保留(可用于把 default 改成 prod,或把中文/英文环境名互改);目标环境已存在时会先请用户确认(会覆盖那条连接)。' +
|
|
946
|
+
'复制:传 copyFrom = 其他工作区的 source 值或「工作区/环境名」+ env = 本工作区要创建的环境名,插件在宿主侧把连接复制到当前工作区,连接串与密码不经模型。**其他工作区已配置的连接要复用时,一律走复制,不要直接拿别的工作区的 source 去查询。**' +
|
|
947
|
+
'交互优化:从用户的话里提炼出的类型/主机/端口/库名等非敏感信息,直接作为预填参数传入即可——插件会先在宿主侧自动试连(自动判断是否可用):' +
|
|
948
|
+
'(1)试连成功 → 直接持久化并返回 source 值,用户无需任何输入;' +
|
|
949
|
+
'(2)试连提示需要凭据 → 弹窗只询问密码(账号未知时一并询问),其余信息自动沿用,不再要求选择输入方式;' +
|
|
950
|
+
'(3)信息不完整或试连因其他原因失败 → 才弹出完整选项(输入 DSN / 填写分项 / 授权扫描项目配置文件)。' +
|
|
951
|
+
'重要:若所需环境(default/prod/dev/test…)**已经配置过**(先用 dbhub_list_sources 确认,能看到全部已注册环境与 source 值),' +
|
|
952
|
+
'**不要向用户索要连接信息,也不要重复调用本工具**——直接用 dbhub_execute_sql / dbhub_search_objects 即可;本工具对已配置环境会宿主侧自动校验,通过时直接返回确认(用户无需任何输入)。' +
|
|
953
|
+
'仅当:某环境查询报连接/凭据错误需更新、或确实需要新增一个未配置的环境时才调用本工具;要复用别的工作区的连接时先用 copyFrom。' +
|
|
954
|
+
'配置保存后插件会宿主侧自动做连接校验,结果(可连通/失败原因)随回执返回:成功了就直接用 dbhub_execute_sql 验证,失败就按原因处理——**不要重复调用本工具向用户索要连接信息**。' +
|
|
955
|
+
'当某个 source 的查询因凭据/连接错误失败时,可调用本工具更新该环境的连接(模型只提醒与引导,密码由用户输入)。' +
|
|
956
|
+
'**环境选项(只读 / SSH 隧道)**:只给选项类参数(readOnly / sshHost / sshPort / sshUser / sshAuthKind / sshKeyPath / sshProxyJump / sshOff)而不给连接信息时,只改这条已存在环境的选项,不会碰连接串;' +
|
|
957
|
+
'readOnly:true = 开启环境只读(dbhub 会拒绝写操作,返回 READONLY_VIOLATION);**readOnly:false 会被拒绝**(关闭只读只能由用户在配置页操作,模型只能开不能关)。' +
|
|
958
|
+
'SSH 隧道(1.4.0+):sshHost 请填域名或 IP(无点号的裸名会被 dbhub 当 ~/.ssh/config 别名解析),目标库地址仍取自连接串(要写"从跳板机看过去"的地址);' +
|
|
959
|
+
'sshAuthKind=key 需要 sshKeyPath(口令由用户在弹窗输入),sshAuthKind=password 的密码**只能由用户在弹窗输入**(绝不要尝试传入密码/口令);sshOff:true 解除隧道(保留连接与只读)。多跳跳板(ProxyJump 多个)不支持。' +
|
|
960
|
+
'只读/隧道在宿主侧翻译成 dbhub 的一次性 TOML 配置(文件不含任何秘密)。若该环境是自动发现、未保存的连接,设置选项时会顺带把它持久化并告知;若没有任何连接信息可用,会返回需要先提供连接。',
|
|
961
|
+
parameters: {
|
|
962
|
+
type: 'object',
|
|
963
|
+
properties: {
|
|
964
|
+
workspace: { type: 'string', description: '工作区路径或标题;省略 = 当前会话工作区(必须精确匹配,否则报错并列出可用工作区)' },
|
|
965
|
+
env: { type: 'string', description: '环境名(如 prod/dev/test/线上/测试);默认 default,多个环境各自拥有独立 source;改名时这里传新名字' },
|
|
966
|
+
renameFrom: { type: 'string', description: '把该工作区已存在的环境(旧名字)改名为 env;连接与凭据原样保留,密码不经模型' },
|
|
967
|
+
copyFrom: { type: 'string', description: '要复制的来源:其他工作区的 source 值(或「工作区标题/环境名」「source 值」);插件在宿主侧复制到本工作区的 env,连接串与密码不经模型' },
|
|
968
|
+
type: { type: 'string', enum: ['mysql', 'postgres', 'mariadb', 'sqlite', 'sqlserver'], description: '数据库类型(非敏感预填)' },
|
|
969
|
+
host: { type: 'string', description: '主机地址,可省略(默认 localhost)' },
|
|
970
|
+
port: { type: 'string', description: '端口,可省略' },
|
|
971
|
+
database: { type: 'string', description: '数据库名,可省略' },
|
|
972
|
+
user: { type: 'string', description: '用户名,可省略(非敏感;密码绝不能传)' },
|
|
973
|
+
readOnly: { type: 'boolean', description: '开启该环境的只读模式(true = dbhub 拒绝写操作)。关闭只读请传 false——会被拒绝,须由用户在配置页操作' },
|
|
974
|
+
sshHost: { type: 'string', description: 'SSH 隧道主机(域名或 IP;不要填 ~/.ssh/config 里的别名)' },
|
|
975
|
+
sshPort: { type: 'string', description: 'SSH 隧道端口,默认 22' },
|
|
976
|
+
sshUser: { type: 'string', description: 'SSH 登录用户' },
|
|
977
|
+
sshAuthKind: { type: 'string', enum: ['key', 'password'], description: 'SSH 认证方式:key(密钥)或 password(密码,密码由用户在弹窗输入)' },
|
|
978
|
+
sshKeyPath: { type: 'string', description: 'SSH 私钥文件路径(如 ~/.ssh/id_ed25519);非敏感,口令由用户输入' },
|
|
979
|
+
sshProxyJump: { type: 'string', description: '单跳 ProxyJump([user@]host[:port]);多跳不支持' },
|
|
980
|
+
sshOff: { type: 'boolean', description: '解除该环境的 SSH 隧道(保留连接与只读设置)' },
|
|
981
|
+
},
|
|
982
|
+
required: [],
|
|
983
|
+
},
|
|
984
|
+
timeoutMs: 120000,
|
|
985
|
+
output: {
|
|
986
|
+
schema: {},
|
|
987
|
+
render: (_args, value) => [
|
|
988
|
+
{
|
|
989
|
+
type: 'text',
|
|
990
|
+
text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
},
|
|
994
|
+
async execute(args, exec) {
|
|
995
|
+
return runConfigure(ctx, subprocess, args, exec)
|
|
996
|
+
},
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function registerCoreTools(ctx, subprocess) {
|
|
1001
|
+
try {
|
|
1002
|
+
const def = buildConfigureTool(ctx, subprocess)
|
|
1003
|
+
const disposer = ctx.tools.register(def)
|
|
1004
|
+
coreDisposers.set('dbhub_configure', disposer)
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
console.error('[dsh-dbhub-live] register dbhub_configure failed: ' + String((e && e.message) || e))
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
const def = buildListSourcesTool(ctx, subprocess)
|
|
1010
|
+
const disposer = ctx.tools.register(def)
|
|
1011
|
+
coreDisposers.set('dbhub_list_sources', disposer)
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
console.error('[dsh-dbhub-live] register dbhub_list_sources failed: ' + String((e && e.message) || e))
|
|
1014
|
+
}
|
|
1015
|
+
// Source tools: ONE declaration per operation, routing by the `source`
|
|
1016
|
+
// argument (constant tool count regardless of workspace × env).
|
|
1017
|
+
const sourceDefs = [
|
|
1018
|
+
{
|
|
1019
|
+
name: 'dbhub_execute_sql',
|
|
1020
|
+
description:
|
|
1021
|
+
'在指定数据源上执行 SQL。source 必填:数据源标识,先调用 dbhub_list_sources 查看全部可用值与对应环境(结果按工作区分组,标出【当前工作区】)。' +
|
|
1022
|
+
'工作区规则:每个 source 只属于一个工作区,**优先用当前工作区的 source**;仅当当前工作区没有所需连接时,才先向用户确认并调用 dbhub_configure 的 copyFrom 把其他工作区的连接复制到当前工作区,**不要直接用其他工作区的 source**(那是另一个目标)。' +
|
|
1023
|
+
'同名工作区/同名环境同时存在时,必须使用 dbhub_list_sources 返回的完整 source 值。' +
|
|
1024
|
+
'sql:要执行的 SQL(多语句用 ; 分隔)。' +
|
|
1025
|
+
'每次调用为独立一次性连接(进程级隔离:多任务并发互不影响;连接信息含密码由插件在宿主侧解析,结果只标注主机/端口/库)。' +
|
|
1026
|
+
'环境可能被用户设为**只读**(dbhub 拒绝写操作并返回 READONLY_VIOLATION):命中时回执会附引导,关闭只读只能由用户在配置页操作。' +
|
|
1027
|
+
'某环境数据库不可达或凭据有误时,仅该次调用返回错误并附「更新凭据」指引(用 dbhub_configure,密码由用户输入)。' +
|
|
1028
|
+
'**数据库访问唯一途径**:本插件已配置的连接一律走 dbhub_* 工具——不要用 psql/mysql/sqlite3 等命令行客户端、脚本、驱动或其它数据库插件直连这些库,' +
|
|
1029
|
+
'也不要为了连库去宿主机上寻找或调用这类可执行文件;只读环境被拒(READONLY_VIOLATION)后同样不得换途径完成写入。' +
|
|
1030
|
+
'本插件不支持或没有配置过的库,用用户安装的其它数据库插件即可。',
|
|
1031
|
+
parameters: {
|
|
1032
|
+
type: 'object',
|
|
1033
|
+
properties: {
|
|
1034
|
+
source: { type: 'string', description: '数据源标识(来自 dbhub_list_sources),按环境选择测试/生产' },
|
|
1035
|
+
sql: { type: 'string', description: '要执行的 SQL(多语句用 ; 分隔)' },
|
|
1036
|
+
},
|
|
1037
|
+
required: ['source', 'sql'],
|
|
1038
|
+
},
|
|
1039
|
+
timeoutMs: 60000,
|
|
1040
|
+
output: {
|
|
1041
|
+
schema: {},
|
|
1042
|
+
render: (_args, value) => [
|
|
1043
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
1044
|
+
],
|
|
1045
|
+
},
|
|
1046
|
+
async execute(args, exec) {
|
|
1047
|
+
return runSourceTool(ctx, subprocess, 'execute_sql', args, exec)
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
name: 'dbhub_search_objects',
|
|
1052
|
+
description:
|
|
1053
|
+
'在指定数据源上搜索数据库对象(表/视图/列/索引等)。source 必填(见 dbhub_list_sources;每条 source 只属于一个工作区,优先用当前工作区的,不要拿其他工作区的 source 当同一个用)。' +
|
|
1054
|
+
'注意:dbhub 的 search_objects 仅对 sqlite 开放(🔒),MySQL/PostgreSQL 等请用 dbhub_execute_sql 直接查(如 SHOW TABLES)。' +
|
|
1055
|
+
'每次调用为独立一次性连接(进程级隔离,多任务并发互不影响)。',
|
|
1056
|
+
parameters: {
|
|
1057
|
+
type: 'object',
|
|
1058
|
+
properties: {
|
|
1059
|
+
source: { type: 'string', description: '数据源标识(来自 dbhub_list_sources)' },
|
|
1060
|
+
object_type: { type: 'string', enum: ['schema', 'table', 'view', 'column', 'procedure', 'function', 'index'], description: '对象类型' },
|
|
1061
|
+
pattern: { type: 'string', description: 'LIKE 模式' },
|
|
1062
|
+
schema: { type: 'string', description: '限定 schema' },
|
|
1063
|
+
table: { type: 'string', description: '限定表(需 schema)' },
|
|
1064
|
+
detail_level: { type: 'string', enum: ['names', 'summary', 'full'], description: '详细程度' },
|
|
1065
|
+
limit: { type: 'integer', description: '最大结果数' },
|
|
1066
|
+
},
|
|
1067
|
+
required: ['source', 'object_type'],
|
|
1068
|
+
},
|
|
1069
|
+
timeoutMs: 60000,
|
|
1070
|
+
output: {
|
|
1071
|
+
schema: {},
|
|
1072
|
+
render: (_args, value) => [
|
|
1073
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
1074
|
+
],
|
|
1075
|
+
},
|
|
1076
|
+
async execute(args, exec) {
|
|
1077
|
+
const mcp = {}
|
|
1078
|
+
for (const k of ['object_type', 'pattern', 'schema', 'table', 'detail_level', 'limit']) {
|
|
1079
|
+
if (args[k] !== undefined && args[k] !== null) mcp[k] = args[k]
|
|
1080
|
+
}
|
|
1081
|
+
return runSourceTool(ctx, subprocess, 'search_objects', { source: args.source, ...mcp }, exec)
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
]
|
|
1085
|
+
for (const def of sourceDefs) {
|
|
1086
|
+
try {
|
|
1087
|
+
const disposer = ctx.tools.register(def)
|
|
1088
|
+
coreDisposers.set(def.name, disposer)
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
console.error('[dsh-dbhub-live] register ' + def.name + ' failed: ' + String((e && e.message) || e))
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
refreshToolCount()
|
|
828
1094
|
}
|