dsh-dbhub-live 2.0.0 → 3.1.2

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.
@@ -0,0 +1,118 @@
1
+ // dsh-dbhub-live: authorized collection of connection candidates.
2
+ //
3
+ // Scanning reads files that may contain passwords and costs tokens, so it is
4
+ // never silent: the caller must go through `askUser` authorization first, and
5
+ // every surfaced candidate is password-masked.
6
+
7
+ import { readdirSync, statSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { parseEnvFile, dsnFromEnv, dsnFromMap } from './config.mjs'
10
+
11
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'target', 'dist', 'build', '.idea', '.vscode', 'venv', '.venv', '__pycache__', '.dsh', '.mise', '.opencode', '.codex'])
12
+ const FILE_PATTERNS = [/\.env([.\w-]*)?$/, /application[-.\w]*\.(yml|yaml|properties)$/, /docker-compose[-.\w]*\.(yml|yaml)$/, /dbconfig\.properties$/, /jdbc\.properties$/, /database\.properties$/, /bootstrap[-.\w]*\.(yml|yaml)$/]
13
+
14
+ export function walkForCandidates(root, budget) {
15
+ const found = []
16
+ const walk = (dir, depth) => {
17
+ if (depth > 2 || found.length >= 30 || budget.count >= 200) return
18
+ let entries
19
+ try {
20
+ entries = readdirSync(dir, { withFileTypes: true })
21
+ } catch (e) {
22
+ return
23
+ }
24
+ for (const en of entries) {
25
+ if (budget.count >= 200) return
26
+ budget.count++
27
+ if (SKIP_DIRS.has(en.name)) continue
28
+ const full = join(dir, en.name)
29
+ let isFile = en.isFile()
30
+ if (!isFile && en.isSymbolicLink()) {
31
+ try {
32
+ isFile = statSync(full).isFile()
33
+ } catch (e) {
34
+ continue
35
+ }
36
+ }
37
+ if (isFile) {
38
+ if (FILE_PATTERNS.some((p) => p.test(en.name))) found.push(full)
39
+ } else if (en.isDirectory() || en.isSymbolicLink()) {
40
+ walk(full, depth + 1)
41
+ }
42
+ }
43
+ }
44
+ walk(root, 0)
45
+ return found
46
+ }
47
+
48
+ export function extractDsnCandidates(filePath, text) {
49
+ const out = []
50
+ const push = (dsn, via) => {
51
+ let d = dsn
52
+ if (d && typeof d === 'string') d = d.trim().replace(/^postgresql:\/\//i, 'postgres://')
53
+ if (d && /^[a-z]+:\/\//i.test(d) && !out.some((o) => o.dsn === d)) {
54
+ out.push({ dsn: d, via })
55
+ }
56
+ }
57
+ for (const m of String(text).matchAll(/(?:jdbc:mysql|jdbc:postgresql|jdbc:sqlserver|jdbc:mariadb)[^"'\s,;)]*/gi)) {
58
+ push(m[0].replace(/^jdbc:/i, ''), 'jdbc url')
59
+ }
60
+ for (const m of String(text).matchAll(/url\s*[:=]\s*["']?(jdbc:[^"'\s]+)["']?/gi)) {
61
+ push(m[1].replace(/^jdbc:/i, ''), 'url=')
62
+ }
63
+ for (const m of String(text).matchAll(/datasource\.url\s*[:=]\s*["']?([^"'\s]+)["']?/gi)) {
64
+ push(m[1], 'datasource.url')
65
+ }
66
+ for (const m of String(text).matchAll(/\b(mysql|postgres|postgresql|mariadb|sqlserver|sqlite)(?:\+ssl)?:\/\/[^"'\s,;)]+/gi)) {
67
+ push(m[0], 'dsn')
68
+ }
69
+ const map = parseEnvFile(text)
70
+ const grouped = dsnFromMap(map)
71
+ if (grouped) push(grouped, 'env 变量组')
72
+ const mysqlGroup = {
73
+ DB_TYPE: map.MYSQL_TYPE || map.DB_TYPE,
74
+ DB_HOST: map.MYSQL_HOST || map.PGHOST || map.DB_HOST,
75
+ DB_PORT: map.MYSQL_PORT || map.PGPORT || map.DB_PORT,
76
+ DB_USER: map.MYSQL_USER || map.PGUSER || map.DB_USER,
77
+ DB_PASSWORD: map.MYSQL_PASSWORD || map.PGPASSWORD || map.DB_PASSWORD,
78
+ DB_NAME: map.MYSQL_DATABASE || map.PGDATABASE || map.DB_NAME,
79
+ }
80
+ const g2 = dsnFromEnv(mysqlGroup)
81
+ if (g2) push(g2, 'MYSQL_*/PG* 变量组')
82
+ return out.slice(0, 6)
83
+ }
84
+
85
+ // Ask the user a question through the interaction service; undefined when it
86
+ // is unavailable or the user cancels.
87
+ export async function askUser(userQuestions, agent, signal, questions) {
88
+ if (!userQuestions || typeof userQuestions.ask !== 'function') return undefined
89
+ try {
90
+ return await userQuestions.ask({
91
+ questions,
92
+ ...(agent ? { agent } : {}),
93
+ ...(signal ? { signal } : {}),
94
+ })
95
+ } catch (e) {
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ export function answerItemOf(ans, id) {
101
+ if (!ans || !Array.isArray(ans.answers)) return undefined
102
+ return ans.answers.find((a) => a && a.id === id)
103
+ }
104
+
105
+ // Resolve the current session working directory from the executing agent.
106
+ export function sessionCwd(agent) {
107
+ try {
108
+ const s = agent && agent.session
109
+ if (!s) return undefined
110
+ const h = s.header
111
+ if (h && typeof h.cwd === 'string' && h.cwd) return h.cwd
112
+ if (typeof s.cwd === 'string' && s.cwd) return s.cwd
113
+ if (s.meta && typeof s.meta.cwd === 'string' && s.meta.cwd) return s.meta.cwd
114
+ } catch (e) {
115
+ /* fall through */
116
+ }
117
+ return undefined
118
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,492 @@
1
+ // dsh-dbhub-live: paths, persistence, small utilities and workspace DSN
2
+ // resolution. Pure module — every function takes the services it needs as
3
+ // arguments, so this file never imports a Cordis service.
4
+
5
+ import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'node:fs'
6
+ import { join, basename, dirname } from 'node:path'
7
+
8
+ export function dshHomeDir() {
9
+ if (process.env.DSH_HOME) return process.env.DSH_HOME
10
+ return join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh')
11
+ }
12
+
13
+ export const DATA_DIR = join(dshHomeDir(), 'storages', 'dsh-dbhub-live')
14
+ export const STORE_PATH = join(DATA_DIR, 'credentials.json')
15
+ export const TOML_PATH = join(DATA_DIR, 'dbhub.toml')
16
+ export const RUNTIME_DIR = join(DATA_DIR, 'dbhub-runtime')
17
+ export const RUNTIME_PATH = join(DATA_DIR, 'runtime.json')
18
+
19
+ // Ensure the storage directory exists once at load; every writer below
20
+ // (store, runtime, toml, managed dbhub prefix) assumes it is present.
21
+ try {
22
+ mkdirSync(DATA_DIR, { recursive: true })
23
+ } catch (e) {
24
+ /* best-effort */
25
+ }
26
+
27
+ // ── persistence (singleton store/runtime, shared by every module) ─────────
28
+
29
+ function readJsonFile(path, fallback) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
32
+ return parsed && typeof parsed === 'object' ? parsed : fallback
33
+ } catch (e) {
34
+ return fallback
35
+ }
36
+ }
37
+
38
+ // ── upgrade / robustness ──────────────────────────────────────────────────
39
+
40
+ const warningOnce = new Set()
41
+
42
+ /** Log one warning per reason (write failures can repeat on every call). */
43
+ export function warnOnce(tag, message) {
44
+ if (warningOnce.has(tag)) return
45
+ warningOnce.add(tag)
46
+ console.warn('[dsh-dbhub-live] ' + message)
47
+ }
48
+
49
+ // One own data property at a time: a JSON key like "__proto__" must never
50
+ // mutate the object prototype during normalization.
51
+ function defineOwn(target, key, value) {
52
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
53
+ }
54
+
55
+ /**
56
+ * Normalize a credentials document (per-workspace entries + the enabled
57
+ * toggle) so legacy or hand-edited files cannot poison initialization.
58
+ * Store model (v2): each workspace entry is `{ environments: { <env>: {
59
+ * dsn, source?, updatedAt? } } }`; a v1 entry `{ dsn, source?, updatedAt? }`
60
+ * is migrated into `environments.default` automatically. Non-boolean
61
+ * `enabled`, non-object entries, empty-DSN entries and empty environment maps
62
+ * are dropped; DSNs are trimmed. Unknown fields are preserved so a newer
63
+ * version's writes are never stripped by an older loader (forward
64
+ * compatibility).
65
+ * @param raw - parsed JSON document (any shape).
66
+ * @returns the normalized plain object.
67
+ */
68
+ export function normalizeStore(raw) {
69
+ const out = {}
70
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
71
+ if (typeof raw.enabled === 'boolean') defineOwn(out, 'enabled', raw.enabled)
72
+
73
+ const normalizeEnv = (env) => {
74
+ const clean = {}
75
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return
76
+ const dsn = typeof env.dsn === 'string' ? env.dsn.trim() : ''
77
+ if (!dsn) return
78
+ defineOwn(clean, 'dsn', dsn)
79
+ if (typeof env.source === 'string' && env.source) defineOwn(clean, 'source', env.source)
80
+ if (typeof env.updatedAt === 'number' && Number.isFinite(env.updatedAt)) {
81
+ defineOwn(clean, 'updatedAt', env.updatedAt)
82
+ }
83
+ for (const [k, v] of Object.entries(env)) {
84
+ if (k === 'dsn' || k === 'source' || k === 'updatedAt') continue
85
+ defineOwn(clean, k, v) // forward-compatible unknown env fields
86
+ }
87
+ return clean
88
+ }
89
+
90
+ for (const [key, entry] of Object.entries(raw)) {
91
+ if (key === 'enabled') continue
92
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
93
+ if (Object.prototype.hasOwnProperty.call(entry, 'environments')) {
94
+ // v2: per-environment map
95
+ const environments = {}
96
+ if (entry.environments && typeof entry.environments === 'object' && !Array.isArray(entry.environments)) {
97
+ for (const [env, envEntry] of Object.entries(entry.environments)) {
98
+ const clean = normalizeEnv(envEntry)
99
+ if (clean) defineOwn(environments, env, clean)
100
+ }
101
+ }
102
+ if (Object.keys(environments).length > 0) defineOwn(out, key, { environments })
103
+ } else {
104
+ // v1 migration: a bare dsn entry becomes the 'default' environment
105
+ const clean = normalizeEnv(entry)
106
+ if (clean) defineOwn(out, key, { environments: { default: clean } })
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ /**
113
+ * List every persisted workspace environment as flat rows for the card.
114
+ * @param store - the (normalized) credentials store.
115
+ * @returns rows [{ wsPath, env, dsn, source?, updatedAt?, persisted: true }].
116
+ */
117
+ export function listWorkspaceEnvironments(store) {
118
+ const rows = []
119
+ for (const [wsPath, entry] of Object.entries(store || {})) {
120
+ if (wsPath === 'enabled') continue
121
+ if (!entry || typeof entry !== 'object' || !entry.environments || typeof entry.environments !== 'object') continue
122
+ for (const [env, envEntry] of Object.entries(entry.environments)) {
123
+ if (!envEntry || typeof envEntry.dsn !== 'string' || !envEntry.dsn.trim()) continue
124
+ rows.push({
125
+ wsPath,
126
+ env,
127
+ dsn: envEntry.dsn.trim(),
128
+ ...(typeof envEntry.source === 'string' && envEntry.source ? { source: envEntry.source } : {}),
129
+ ...(typeof envEntry.updatedAt === 'number' && Number.isFinite(envEntry.updatedAt) ? { updatedAt: envEntry.updatedAt } : {}),
130
+ persisted: true,
131
+ })
132
+ }
133
+ }
134
+ return rows
135
+ }
136
+
137
+ /**
138
+ * Persist one workspace environment (creates or overwrites). The workspace
139
+ * entry becomes `{ environments: { <env>: { dsn, source, updatedAt } } }`.
140
+ * @returns the stored environment name ('default' when blank).
141
+ */
142
+ export function setWorkspaceEnv(wsPath, env, dsn, source) {
143
+ const entry = store[wsPath] && typeof store[wsPath] === 'object' && !Array.isArray(store[wsPath])
144
+ ? store[wsPath]
145
+ : {}
146
+ const environments = entry.environments && typeof entry.environments === 'object' && !Array.isArray(entry.environments)
147
+ ? { ...entry.environments }
148
+ : {}
149
+ const name = env === undefined || env === null || String(env).trim() === ''
150
+ ? 'default'
151
+ : String(env).trim()
152
+ const cleanDsn = String(dsn).trim()
153
+ if (!cleanDsn) throw new TypeError('DSN 不能为空')
154
+ environments[name] = { dsn: cleanDsn, source: String(source || 'user'), updatedAt: Date.now() }
155
+ store[wsPath] = { environments }
156
+ saveStore(store)
157
+ return name
158
+ }
159
+
160
+ /**
161
+ * Remove one persisted workspace environment; when it was the last one the
162
+ * whole workspace entry is dropped. Removing an auto-derived 'default' (which
163
+ * is never persisted) is a no-op.
164
+ * @returns whether anything was removed.
165
+ */
166
+ export function removeWorkspaceEnv(wsPath, env) {
167
+ const entry = store[wsPath]
168
+ if (!entry || typeof entry !== 'object' || !entry.environments || typeof entry.environments !== 'object') return false
169
+ const name = env === undefined || env === null || String(env).trim() === '' ? 'default' : String(env).trim()
170
+ if (!Object.prototype.hasOwnProperty.call(entry.environments, name)) return false
171
+ const environments = { ...entry.environments }
172
+ delete environments[name]
173
+ if (Object.keys(environments).length === 0) delete store[wsPath]
174
+ else store[wsPath] = { environments }
175
+ saveStore(store)
176
+ return true
177
+ }
178
+
179
+ /** Normalize the runtime document (dbhubExe / dbhubInstallAt) the same way. */
180
+ export function normalizeRuntime(raw) {
181
+ const out = {}
182
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
183
+ for (const [key, value] of Object.entries(raw)) {
184
+ if (key === 'dbhubExe') {
185
+ if (typeof value === 'string' && value) defineOwn(out, key, value)
186
+ } else if (key === 'dbhubInstallAt') {
187
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) defineOwn(out, key, value)
188
+ } else {
189
+ // Preserve unknown fields for forward compatibility.
190
+ defineOwn(out, key, value)
191
+ }
192
+ }
193
+ return out
194
+ }
195
+
196
+ /** Recreate the storage directory after an accidental delete. */
197
+ export function ensureStorageDir() {
198
+ try {
199
+ mkdirSync(DATA_DIR, { recursive: true })
200
+ return true
201
+ } catch (e) {
202
+ warnOnce('mkdir', '无法创建配置目录 ' + DATA_DIR + '(运行目录被删除或写入被拦截):' + String((e && e.message) || e))
203
+ return false
204
+ }
205
+ }
206
+
207
+ /** Write a JSON document, recreating the directory first. Never throws. */
208
+ function writeJsonFile(path, value) {
209
+ try {
210
+ mkdirSync(dirname(path), { recursive: true })
211
+ writeFileSync(path, JSON.stringify(value, null, 2))
212
+ } catch (e) {
213
+ warnOnce('write:' + path,
214
+ '写入 ' + path + ' 失败(运行目录被删除或写入被拦截),本项仅在内存中生效:' + String((e && e.message) || e))
215
+ }
216
+ }
217
+
218
+ /** Write the generated dbhub.toml; the caller treats false as an init error. */
219
+ export function writeToml(text) {
220
+ if (!ensureStorageDir()) return false
221
+ try {
222
+ writeFileSync(TOML_PATH, text)
223
+ return true
224
+ } catch (e) {
225
+ warnOnce('write:' + TOML_PATH, '写入 dbhub.toml 失败:' + String((e && e.message) || e))
226
+ return false
227
+ }
228
+ }
229
+
230
+ // ── persistence (singleton store/runtime, shared by every module) ─────────
231
+
232
+ export function loadStore() {
233
+ const raw = readJsonFile(STORE_PATH, {})
234
+ const normalized = normalizeStore(raw)
235
+ // One-time migration: persist the cleaned document when a legacy layout was
236
+ // loaded, so the next boot starts from a normalized file.
237
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) writeJsonFile(STORE_PATH, normalized)
238
+ return normalized
239
+ }
240
+
241
+ export function saveStore(store) {
242
+ writeJsonFile(STORE_PATH, store)
243
+ }
244
+
245
+ export function loadRuntime() {
246
+ const raw = readJsonFile(RUNTIME_PATH, {})
247
+ const normalized = normalizeRuntime(raw)
248
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) writeJsonFile(RUNTIME_PATH, normalized)
249
+ return normalized
250
+ }
251
+
252
+ export function saveRuntime(runtime) {
253
+ writeJsonFile(RUNTIME_PATH, runtime)
254
+ }
255
+
256
+ export const store = loadStore()
257
+ export const runtime = loadRuntime()
258
+
259
+ // ── small utils ───────────────────────────────────────────────────────────
260
+
261
+ export function slugify(title) {
262
+ const s = String(title || '')
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9_]+/g, '-')
265
+ .replace(/^-+|-+$/g, '')
266
+ return s || 'ws'
267
+ }
268
+
269
+ export function tomlEscape(s) {
270
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
271
+ }
272
+
273
+ export function shortHash(s) {
274
+ let h = 0
275
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
276
+ return (h >>> 0).toString(36)
277
+ }
278
+
279
+ // Mask the password of a DSN for labels/results; passwords never leave the
280
+ // plugin unmasked in user-visible text.
281
+ export function maskDsn(dsn) {
282
+ try {
283
+ const u = new URL(dsn.replace(/^jdbc:/i, ''))
284
+ if (u.password) u.password = '****'
285
+ return u.href
286
+ } catch (e) {
287
+ return dsn.replace(/(:\/\/[^:/@]+:)[^@]+(@)/, '$1****$2')
288
+ }
289
+ }
290
+
291
+ export function fingerprintOf(sources) {
292
+ return sources.map((s) => s.id + '|' + s.dsn).join('\n')
293
+ }
294
+
295
+ export function generateToml(sources) {
296
+ const lines = ['# Auto-generated by dsh-dbhub-live — do not edit. Regenerated on demand.']
297
+ for (const s of sources) {
298
+ lines.push('[[sources]]')
299
+ lines.push('id = "' + tomlEscape(s.id) + '"')
300
+ lines.push('dsn = "' + tomlEscape(s.dsn) + '"')
301
+ }
302
+ return lines.join('\n') + '\n'
303
+ }
304
+
305
+ // ── env parsing ───────────────────────────────────────────────────────────
306
+
307
+ export function parseEnvFile(text) {
308
+ const map = {}
309
+ for (const raw of String(text).split(/\r?\n/)) {
310
+ const line = raw.trim()
311
+ if (!line || line.startsWith('#')) continue
312
+ const eq = line.indexOf('=')
313
+ if (eq < 0) continue
314
+ const key = line.slice(0, eq).trim()
315
+ let value = line.slice(eq + 1).trim()
316
+ if (!key) continue
317
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
318
+ value = value.slice(1, -1)
319
+ }
320
+ map[key] = value
321
+ }
322
+ return map
323
+ }
324
+
325
+ export function parseEnvOutput(text) {
326
+ const map = {}
327
+ for (const raw of String(text).split(/\r?\n/)) {
328
+ const line = raw.trim()
329
+ if (!line || line.startsWith('#')) continue
330
+ const m = line.match(/^(?:\$Env:|export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'])*'|[^ ]*)/)
331
+ if (!m) continue
332
+ let value = m[2]
333
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
334
+ value = value.slice(1, -1)
335
+ }
336
+ map[m[1]] = value
337
+ }
338
+ return map
339
+ }
340
+
341
+ // DB_* individual variables -> a DSN string (or undefined).
342
+ export function dsnFromEnv(env) {
343
+ const has = (k) => env[k] !== undefined && env[k] !== null && String(env[k]).trim() !== ''
344
+ if (!has('DB_HOST') || !has('DB_USER') || !has('DB_NAME')) return undefined
345
+ const type = (has('DB_TYPE') ? String(env.DB_TYPE).trim() : 'mysql').toLowerCase()
346
+ const host = String(env.DB_HOST).trim()
347
+ const port = has('DB_PORT') ? String(env.DB_PORT).trim() : ''
348
+ const user = String(env.DB_USER).trim()
349
+ const password = has('DB_PASSWORD') ? String(env.DB_PASSWORD).trim() : ''
350
+ const database = String(env.DB_NAME).trim()
351
+ const enc = (s) => encodeURIComponent(s)
352
+ if (type === 'sqlite') return 'sqlite:///' + host
353
+ return type + '://' + enc(user) + ':' + enc(password) + '@' + host + (port ? ':' + port : '') + '/' + enc(database)
354
+ }
355
+
356
+ export function dsnFromMap(map) {
357
+ if (map.DSN) return String(map.DSN).trim()
358
+ return dsnFromEnv(map)
359
+ }
360
+
361
+ // ── workspace discovery & DSN resolution ─────────────────────────────────
362
+
363
+ // Workspaces: live registry first, then a direct read of the durable
364
+ // workspace registry file (robust at boot, when services may not be ready).
365
+ export async function listWorkspaces(ctx) {
366
+ if (ctx) {
367
+ const registry = ctx.get('workspaceRegistry')
368
+ if (registry && typeof registry.list === 'function') {
369
+ try {
370
+ const list = await registry.list()
371
+ if (Array.isArray(list) && list.length > 0) {
372
+ return list.filter((w) => w && w.path).map((w) => ({ path: w.path, title: w.title || basename(w.path) }))
373
+ }
374
+ } catch (e) {
375
+ /* fall through */
376
+ }
377
+ }
378
+ }
379
+ try {
380
+ const wsFile = join(dshHomeDir(), 'storages', 'workspace.json')
381
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
382
+ const table = data && data.tables && data.tables.workspaces
383
+ if (table) {
384
+ const out = []
385
+ for (const id of Object.keys(table)) {
386
+ const w = table[id]
387
+ if (w && w.path) out.push({ path: w.path, title: w.title || basename(w.path) })
388
+ }
389
+ if (out.length > 0) return out
390
+ }
391
+ } catch (e) {
392
+ /* fall through */
393
+ }
394
+ try {
395
+ const cwd = process.cwd()
396
+ if (cwd) return [{ path: cwd, title: basename(cwd) }]
397
+ } catch (e) {
398
+ /* fall through */
399
+ }
400
+ return []
401
+ }
402
+
403
+ export async function runMiseEnv(subprocess, cwd, signal) {
404
+ if (!subprocess || !cwd) return undefined
405
+ let exe
406
+ try {
407
+ exe = await subprocess.resolveExecutable('mise', undefined, signal)
408
+ } catch (e) {
409
+ return undefined
410
+ }
411
+ let handle
412
+ try {
413
+ handle = subprocess.spawn({
414
+ argv: [exe, 'env'],
415
+ cwd,
416
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' },
417
+ graceMs: 5000,
418
+ signal,
419
+ })
420
+ } catch (e) {
421
+ return undefined
422
+ }
423
+ let out = ''
424
+ if (handle.stdout) {
425
+ handle.stdout.on('data', (c) => {
426
+ out = (out + String(c)).slice(-131072)
427
+ })
428
+ }
429
+ try {
430
+ const outcome = await handle.done
431
+ if (outcome.exitCode !== 0) return undefined
432
+ return parseEnvOutput(out)
433
+ } catch (e) {
434
+ return undefined
435
+ } finally {
436
+ try {
437
+ handle.terminate()
438
+ } catch (e) {
439
+ /* ignore */
440
+ }
441
+ }
442
+ }
443
+
444
+ // Auto-discovery (mise env -> .env) supplies the workspace's 'default'
445
+ // environment only. Never consults the process environment.
446
+ async function resolveAutoDsn(subprocess, fs, wsPath, signal) {
447
+ const miseMap = await runMiseEnv(subprocess, wsPath, signal)
448
+ if (miseMap) {
449
+ const dsn = dsnFromMap(miseMap)
450
+ if (dsn) return { dsn, source: '工作区 mise env' }
451
+ }
452
+ try {
453
+ const target = await fs.resolve('.env', { cwd: wsPath, signal })
454
+ const map = parseEnvFile(await fs.readText(target, signal))
455
+ const dsn = dsnFromMap(map)
456
+ if (dsn) return { dsn, source: '工作目录 .env' }
457
+ } catch (e) {
458
+ /* no .env */
459
+ }
460
+ return undefined
461
+ }
462
+
463
+ /**
464
+ * Resolve one workspace's connection rows, one per environment: every
465
+ * persisted environment (v2 `environments.<env>`) plus, when no persisted
466
+ * `default` overrides it, the auto-discovered 'default'. Auto-discovery is
467
+ * never persisted — it is re-evaluated at every resolve (the card marks it
468
+ * as such).
469
+ * @returns rows [{ env, dsn, source }].
470
+ */
471
+ export async function resolveWorkspaceEnvs(subprocess, fs, wsPath, signal) {
472
+ const entry = store[wsPath]
473
+ const persisted = entry && entry.environments && typeof entry.environments === 'object'
474
+ ? entry.environments
475
+ : {}
476
+ const out = []
477
+ for (const [env, envEntry] of Object.entries(persisted)) {
478
+ if (envEntry && typeof envEntry.dsn === 'string' && envEntry.dsn.trim()) {
479
+ out.push({ env, dsn: envEntry.dsn.trim(), source: 'persisted(' + (envEntry.source || 'user') + ')' })
480
+ }
481
+ }
482
+ if (!Object.prototype.hasOwnProperty.call(persisted, 'default')) {
483
+ const auto = await resolveAutoDsn(subprocess, fs, wsPath, signal)
484
+ if (auto) out.push({ env: 'default', dsn: auto.dsn, source: auto.source })
485
+ }
486
+ return out
487
+ }
488
+
489
+ /** Environment slug for tool/source naming; 'default' stays bare. */
490
+ export function envSlug(env) {
491
+ return env === 'default' ? '' : slugify(env) || 'env'
492
+ }