dsh-dbhub-live 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,177 @@
1
+ // dsh-dbhub-live — browser half (hand-authored lazy-CJS client bundle).
2
+ //
3
+ // Packaging contract (see docs/cookbook/adding-a-settings-card.md and
4
+ // packages/client/modules): the Node client-modules scanner serves
5
+ // `exports["./client"]` for every Loader entry declaring `dsh.client`; the
6
+ // script must register a lazy CJS factory through `window.__ModuleLoader__.load`
7
+ // whose `id` equals the Loader entry name. The factory receives the shared
8
+ // `require` (React is a seeded baseline module) and returns the Cordis plugin
9
+ // object — `name` / `inject` / `apply`.
10
+ //
11
+ // The card renders the plugin's live status from the `dsh-dbhub-live` settings
12
+ // namespace (mirrored by the Host half) and writes the enable/disable toggle
13
+ // back through the namespace's `enabled` field. No other plugin value is
14
+ // imported: all cross-package collaboration goes through the slot + settings
15
+ // services by name.
16
+
17
+ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
18
+
19
+ var module = { exports: {} };
20
+ var exports = module.exports;
21
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
22
+ var react = require("react");
23
+
24
+ var NS = "dsh-dbhub-live";
25
+
26
+ var PHASE_META = {
27
+ initializing: { emoji: "🟡", label: "初始化中", color: "#b58900" },
28
+ running: { emoji: "🟢", label: "运行中", color: "#2f9e44" },
29
+ error: { emoji: "🔴", label: "异常", color: "#e03131" },
30
+ disabled: { emoji: "⚪", label: "已禁用", color: "#868e96" },
31
+ };
32
+
33
+ var MODE_LABEL = { lazy: "懒加载(首次调用时初始化)" };
34
+
35
+ // Normalize a settingsScope snapshot into the namespace value object.
36
+ function valueOf(snapshot) {
37
+ if (!snapshot || typeof snapshot !== "object") return {};
38
+ var value = snapshot.value;
39
+ return value && typeof value === "object" ? value : {};
40
+ }
41
+
42
+ var cardStyle = {
43
+ border: "1px solid #d0d7de",
44
+ borderRadius: "8px",
45
+ padding: "12px 14px",
46
+ fontFamily: "inherit",
47
+ fontSize: "13px",
48
+ color: "#1f2328",
49
+ background: "#ffffff",
50
+ maxWidth: "440px",
51
+ display: "flex",
52
+ flexDirection: "column",
53
+ gap: "2px",
54
+ };
55
+
56
+ var headStyle = {
57
+ display: "flex",
58
+ alignItems: "center",
59
+ justifyContent: "space-between",
60
+ fontWeight: 600,
61
+ fontSize: "14px",
62
+ paddingBottom: "6px",
63
+ borderBottom: "1px solid #eef1f4",
64
+ };
65
+
66
+ var rowStyle = {
67
+ display: "flex",
68
+ alignItems: "center",
69
+ justifyContent: "space-between",
70
+ padding: "7px 0",
71
+ };
72
+
73
+ var labelStyle = { color: "#57606a" };
74
+
75
+ var valueStyle = { fontWeight: 600, textAlign: "right", marginLeft: "12px" };
76
+
77
+ var errorStyle = {
78
+ color: "#e03131",
79
+ whiteSpace: "pre-wrap",
80
+ wordBreak: "break-all",
81
+ maxHeight: "96px",
82
+ overflow: "auto",
83
+ };
84
+
85
+ var toggleStyle = {
86
+ cursor: "pointer",
87
+ borderRadius: "6px",
88
+ border: "1px solid #d0d7de",
89
+ padding: "5px 12px",
90
+ fontSize: "13px",
91
+ color: "#1f2328",
92
+ background: "#f6f8fa",
93
+ };
94
+
95
+ var toggleOnStyle = Object.assign({}, toggleStyle, {
96
+ color: "#2f9e44",
97
+ borderColor: "#b7d8bf",
98
+ background: "#ecf7ef",
99
+ });
100
+
101
+ function StatusCard(props) {
102
+ var loaded = react.useState(function () { return valueOf(props.getSnapshot()); });
103
+ var value = loaded[0];
104
+ var setValue = loaded[1];
105
+
106
+ react.useEffect(function () {
107
+ return props.subscribe(function () { setValue(valueOf(props.getSnapshot())); });
108
+ }, []);
109
+
110
+ var enabled = value.enabled !== false;
111
+ var phase = enabled ? (value.phase || "initializing") : "disabled";
112
+ var meta = PHASE_META[phase] || PHASE_META.initializing;
113
+ var toolCount = typeof value.toolCount === "number" ? value.toolCount : 0;
114
+ var lastError = typeof value.lastError === "string" && value.lastError ? value.lastError : "";
115
+ var modeLabel = MODE_LABEL[value.mode] || (value.mode || "lazy");
116
+
117
+ var rows = [
118
+ react.createElement("div", { key: "phase", style: rowStyle },
119
+ react.createElement("span", { style: labelStyle }, "运行状态"),
120
+ react.createElement("span", { style: Object.assign({}, valueStyle, { color: meta.color }) },
121
+ meta.emoji + " " + meta.label)),
122
+ react.createElement("div", { key: "tools", style: rowStyle },
123
+ react.createElement("span", { style: labelStyle }, "已注册工具"),
124
+ react.createElement("span", { style: valueStyle }, String(toolCount) + " 个")),
125
+ react.createElement("div", { key: "mode", style: rowStyle },
126
+ react.createElement("span", { style: labelStyle }, "工作模式"),
127
+ react.createElement("span", { style: valueStyle }, modeLabel)),
128
+ ];
129
+
130
+ if (lastError) {
131
+ rows.push(
132
+ react.createElement("div", { key: "error", style: rowStyle },
133
+ react.createElement("span", { style: labelStyle }, "最近错误"),
134
+ react.createElement("span", { style: Object.assign({}, errorStyle, valueStyle) }, lastError)),
135
+ );
136
+ }
137
+
138
+ rows.push(
139
+ react.createElement("div", { key: "toggle", style: Object.assign({}, rowStyle, { paddingTop: "8px", borderTop: "1px solid #eef1f4" }) },
140
+ react.createElement("span", { style: labelStyle }, enabled ? "插件已启用" : "插件已禁用"),
141
+ react.createElement("button", {
142
+ type: "button",
143
+ style: enabled ? toggleOnStyle : toggleStyle,
144
+ onClick: function () { props.setEnabled(!enabled); },
145
+ }, enabled ? "禁用" : "启用")),
146
+ );
147
+
148
+ return react.createElement("div", { style: cardStyle },
149
+ react.createElement("div", { style: headStyle },
150
+ react.createElement("span", null, "dsh-dbhub-live"),
151
+ react.createElement("span", null, meta.emoji)),
152
+ rows);
153
+ }
154
+
155
+ function apply(ctx) {
156
+ var settingsScope = ctx.settingsScope;
157
+ var host = settingsScope.bind({ namespace: NS });
158
+ var face = {
159
+ getSnapshot: function () { return host.getSnapshot(); },
160
+ subscribe: function (fn) { return host.subscribe(fn); },
161
+ setEnabled: function (value) {
162
+ return host.set("enabled", Boolean(value)).then(function () { return undefined; }, function () { return undefined; });
163
+ },
164
+ };
165
+ ctx.slots.inject("settings.plugin.item", function () {
166
+ return ctx.slots.register({ name: "settings.plugin.item", key: NS, inject: function () { return face; } }, StatusCard);
167
+ });
168
+ }
169
+
170
+ var name = "dsh-dbhub-live";
171
+ var inject = ["slots", "settingsScope"];
172
+
173
+ exports.apply = apply;
174
+ exports.inject = inject;
175
+ exports.name = name;
176
+ return module.exports;
177
+ }});
@@ -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,366 @@
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
+ export const IDLE_MS = 10 * 60 * 1000
19
+
20
+ // Ensure the storage directory exists once at load; every writer below
21
+ // (store, runtime, toml, managed dbhub prefix) assumes it is present.
22
+ try {
23
+ mkdirSync(DATA_DIR, { recursive: true })
24
+ } catch (e) {
25
+ /* best-effort */
26
+ }
27
+
28
+ // ── persistence (singleton store/runtime, shared by every module) ─────────
29
+
30
+ function readJsonFile(path, fallback) {
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
33
+ return parsed && typeof parsed === 'object' ? parsed : fallback
34
+ } catch (e) {
35
+ return fallback
36
+ }
37
+ }
38
+
39
+ // ── upgrade / robustness ──────────────────────────────────────────────────
40
+
41
+ const warningOnce = new Set()
42
+
43
+ /** Log one warning per reason (write failures can repeat on every call). */
44
+ export function warnOnce(tag, message) {
45
+ if (warningOnce.has(tag)) return
46
+ warningOnce.add(tag)
47
+ console.warn('[dsh-dbhub-live] ' + message)
48
+ }
49
+
50
+ // One own data property at a time: a JSON key like "__proto__" must never
51
+ // mutate the object prototype during normalization.
52
+ function defineOwn(target, key, value) {
53
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
54
+ }
55
+
56
+ /**
57
+ * Normalize a credentials document (per-workspace entries + the enabled
58
+ * toggle) so legacy or hand-edited files cannot poison initialization:
59
+ * non-boolean `enabled`, non-object entries and empty-DSN entries are
60
+ * dropped; the authoritative `dsn` is trimmed. Unknown entry fields are
61
+ * preserved so a newer version's writes are never stripped by an older
62
+ * loader (forward compatibility).
63
+ * @param raw - parsed JSON document (any shape).
64
+ * @returns the normalized plain object.
65
+ */
66
+ export function normalizeStore(raw) {
67
+ const out = {}
68
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
69
+ if (typeof raw.enabled === 'boolean') defineOwn(out, 'enabled', raw.enabled)
70
+ for (const [key, entry] of Object.entries(raw)) {
71
+ if (key === 'enabled') continue
72
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
73
+ const dsn = typeof entry.dsn === 'string' ? entry.dsn.trim() : ''
74
+ if (!dsn) continue
75
+ defineOwn(out, key, { ...entry, dsn })
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** Normalize the runtime document (dbhubExe / dbhubInstallAt) the same way. */
81
+ export function normalizeRuntime(raw) {
82
+ const out = {}
83
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
84
+ for (const [key, value] of Object.entries(raw)) {
85
+ if (key === 'dbhubExe') {
86
+ if (typeof value === 'string' && value) defineOwn(out, key, value)
87
+ } else if (key === 'dbhubInstallAt') {
88
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) defineOwn(out, key, value)
89
+ } else {
90
+ // Preserve unknown fields for forward compatibility.
91
+ defineOwn(out, key, value)
92
+ }
93
+ }
94
+ return out
95
+ }
96
+
97
+ /** Recreate the storage directory after an accidental delete. */
98
+ export function ensureStorageDir() {
99
+ try {
100
+ mkdirSync(DATA_DIR, { recursive: true })
101
+ return true
102
+ } catch (e) {
103
+ warnOnce('mkdir', '无法创建配置目录 ' + DATA_DIR + '(运行目录被删除或写入被拦截):' + String((e && e.message) || e))
104
+ return false
105
+ }
106
+ }
107
+
108
+ /** Write a JSON document, recreating the directory first. Never throws. */
109
+ function writeJsonFile(path, value) {
110
+ try {
111
+ mkdirSync(dirname(path), { recursive: true })
112
+ writeFileSync(path, JSON.stringify(value, null, 2))
113
+ } catch (e) {
114
+ warnOnce('write:' + path,
115
+ '写入 ' + path + ' 失败(运行目录被删除或写入被拦截),本项仅在内存中生效:' + String((e && e.message) || e))
116
+ }
117
+ }
118
+
119
+ /** Write the generated dbhub.toml; the caller treats false as an init error. */
120
+ export function writeToml(text) {
121
+ if (!ensureStorageDir()) return false
122
+ try {
123
+ writeFileSync(TOML_PATH, text)
124
+ return true
125
+ } catch (e) {
126
+ warnOnce('write:' + TOML_PATH, '写入 dbhub.toml 失败:' + String((e && e.message) || e))
127
+ return false
128
+ }
129
+ }
130
+
131
+ // ── persistence (singleton store/runtime, shared by every module) ─────────
132
+
133
+ export function loadStore() {
134
+ const raw = readJsonFile(STORE_PATH, {})
135
+ const normalized = normalizeStore(raw)
136
+ // One-time migration: persist the cleaned document when a legacy layout was
137
+ // loaded, so the next boot starts from a normalized file.
138
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) writeJsonFile(STORE_PATH, normalized)
139
+ return normalized
140
+ }
141
+
142
+ export function saveStore(store) {
143
+ writeJsonFile(STORE_PATH, store)
144
+ }
145
+
146
+ export function loadRuntime() {
147
+ const raw = readJsonFile(RUNTIME_PATH, {})
148
+ const normalized = normalizeRuntime(raw)
149
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) writeJsonFile(RUNTIME_PATH, normalized)
150
+ return normalized
151
+ }
152
+
153
+ export function saveRuntime(runtime) {
154
+ writeJsonFile(RUNTIME_PATH, runtime)
155
+ }
156
+
157
+ export const store = loadStore()
158
+ export const runtime = loadRuntime()
159
+
160
+ // ── small utils ───────────────────────────────────────────────────────────
161
+
162
+ export function slugify(title) {
163
+ const s = String(title || '')
164
+ .toLowerCase()
165
+ .replace(/[^a-z0-9_]+/g, '-')
166
+ .replace(/^-+|-+$/g, '')
167
+ return s || 'ws'
168
+ }
169
+
170
+ export function tomlEscape(s) {
171
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
172
+ }
173
+
174
+ export function shortHash(s) {
175
+ let h = 0
176
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
177
+ return (h >>> 0).toString(36)
178
+ }
179
+
180
+ // Mask the password of a DSN for labels/results; passwords never leave the
181
+ // plugin unmasked in user-visible text.
182
+ export function maskDsn(dsn) {
183
+ try {
184
+ const u = new URL(dsn.replace(/^jdbc:/i, ''))
185
+ if (u.password) u.password = '****'
186
+ return u.href
187
+ } catch (e) {
188
+ return dsn.replace(/(:\/\/[^:/@]+:)[^@]+(@)/, '$1****$2')
189
+ }
190
+ }
191
+
192
+ export function fingerprintOf(sources) {
193
+ return sources.map((s) => s.id + '|' + s.dsn).join('\n')
194
+ }
195
+
196
+ export function generateToml(sources) {
197
+ const lines = ['# Auto-generated by dsh-dbhub-live — do not edit. Regenerated on demand.']
198
+ for (const s of sources) {
199
+ lines.push('[[sources]]')
200
+ lines.push('id = "' + tomlEscape(s.id) + '"')
201
+ lines.push('dsn = "' + tomlEscape(s.dsn) + '"')
202
+ }
203
+ return lines.join('\n') + '\n'
204
+ }
205
+
206
+ // ── env parsing ───────────────────────────────────────────────────────────
207
+
208
+ export function parseEnvFile(text) {
209
+ const map = {}
210
+ for (const raw of String(text).split(/\r?\n/)) {
211
+ const line = raw.trim()
212
+ if (!line || line.startsWith('#')) continue
213
+ const eq = line.indexOf('=')
214
+ if (eq < 0) continue
215
+ const key = line.slice(0, eq).trim()
216
+ let value = line.slice(eq + 1).trim()
217
+ if (!key) continue
218
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
219
+ value = value.slice(1, -1)
220
+ }
221
+ map[key] = value
222
+ }
223
+ return map
224
+ }
225
+
226
+ export function parseEnvOutput(text) {
227
+ const map = {}
228
+ for (const raw of String(text).split(/\r?\n/)) {
229
+ const line = raw.trim()
230
+ if (!line || line.startsWith('#')) continue
231
+ const m = line.match(/^(?:\$Env:|export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'])*'|[^ ]*)/)
232
+ if (!m) continue
233
+ let value = m[2]
234
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
235
+ value = value.slice(1, -1)
236
+ }
237
+ map[m[1]] = value
238
+ }
239
+ return map
240
+ }
241
+
242
+ // DB_* individual variables -> a DSN string (or undefined).
243
+ export function dsnFromEnv(env) {
244
+ const has = (k) => env[k] !== undefined && env[k] !== null && String(env[k]).trim() !== ''
245
+ if (!has('DB_HOST') || !has('DB_USER') || !has('DB_NAME')) return undefined
246
+ const type = (has('DB_TYPE') ? String(env.DB_TYPE).trim() : 'mysql').toLowerCase()
247
+ const host = String(env.DB_HOST).trim()
248
+ const port = has('DB_PORT') ? String(env.DB_PORT).trim() : ''
249
+ const user = String(env.DB_USER).trim()
250
+ const password = has('DB_PASSWORD') ? String(env.DB_PASSWORD).trim() : ''
251
+ const database = String(env.DB_NAME).trim()
252
+ const enc = (s) => encodeURIComponent(s)
253
+ if (type === 'sqlite') return 'sqlite:///' + host
254
+ return type + '://' + enc(user) + ':' + enc(password) + '@' + host + (port ? ':' + port : '') + '/' + enc(database)
255
+ }
256
+
257
+ export function dsnFromMap(map) {
258
+ if (map.DSN) return String(map.DSN).trim()
259
+ return dsnFromEnv(map)
260
+ }
261
+
262
+ // ── workspace discovery & DSN resolution ─────────────────────────────────
263
+
264
+ // Workspaces: live registry first, then a direct read of the durable
265
+ // workspace registry file (robust at boot, when services may not be ready).
266
+ export async function listWorkspaces(ctx) {
267
+ if (ctx) {
268
+ const registry = ctx.get('workspaceRegistry')
269
+ if (registry && typeof registry.list === 'function') {
270
+ try {
271
+ const list = await registry.list()
272
+ if (Array.isArray(list) && list.length > 0) {
273
+ return list.filter((w) => w && w.path).map((w) => ({ path: w.path, title: w.title || basename(w.path) }))
274
+ }
275
+ } catch (e) {
276
+ /* fall through */
277
+ }
278
+ }
279
+ }
280
+ try {
281
+ const wsFile = join(dshHomeDir(), 'storages', 'workspace.json')
282
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
283
+ const table = data && data.tables && data.tables.workspaces
284
+ if (table) {
285
+ const out = []
286
+ for (const id of Object.keys(table)) {
287
+ const w = table[id]
288
+ if (w && w.path) out.push({ path: w.path, title: w.title || basename(w.path) })
289
+ }
290
+ if (out.length > 0) return out
291
+ }
292
+ } catch (e) {
293
+ /* fall through */
294
+ }
295
+ try {
296
+ const cwd = process.cwd()
297
+ if (cwd) return [{ path: cwd, title: basename(cwd) }]
298
+ } catch (e) {
299
+ /* fall through */
300
+ }
301
+ return []
302
+ }
303
+
304
+ export async function runMiseEnv(subprocess, cwd, signal) {
305
+ if (!subprocess || !cwd) return undefined
306
+ let exe
307
+ try {
308
+ exe = await subprocess.resolveExecutable('mise', undefined, signal)
309
+ } catch (e) {
310
+ return undefined
311
+ }
312
+ let handle
313
+ try {
314
+ handle = subprocess.spawn({
315
+ argv: [exe, 'env'],
316
+ cwd,
317
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' },
318
+ graceMs: 5000,
319
+ signal,
320
+ })
321
+ } catch (e) {
322
+ return undefined
323
+ }
324
+ let out = ''
325
+ if (handle.stdout) {
326
+ handle.stdout.on('data', (c) => {
327
+ out = (out + String(c)).slice(-131072)
328
+ })
329
+ }
330
+ try {
331
+ const outcome = await handle.done
332
+ if (outcome.exitCode !== 0) return undefined
333
+ return parseEnvOutput(out)
334
+ } catch (e) {
335
+ return undefined
336
+ } finally {
337
+ try {
338
+ handle.terminate()
339
+ } catch (e) {
340
+ /* ignore */
341
+ }
342
+ }
343
+ }
344
+
345
+ // Resolve one workspace's DSN: persisted explicit entry first, then
346
+ // auto-discovery (mise env -> .env). Never consults the process environment.
347
+ export async function resolveWorkspaceDsn(subprocess, fs, wsPath, signal) {
348
+ const entry = store[wsPath]
349
+ if (entry && entry.dsn && typeof entry.dsn === 'string' && entry.dsn.trim()) {
350
+ return { dsn: entry.dsn.trim(), source: 'persisted(' + (entry.source || 'user') + ')' }
351
+ }
352
+ const miseMap = await runMiseEnv(subprocess, wsPath, signal)
353
+ if (miseMap) {
354
+ const dsn = dsnFromMap(miseMap)
355
+ if (dsn) return { dsn, source: '工作区 mise env' }
356
+ }
357
+ try {
358
+ const target = await fs.resolve('.env', { cwd: wsPath, signal })
359
+ const map = parseEnvFile(await fs.readText(target, signal))
360
+ const dsn = dsnFromMap(map)
361
+ if (dsn) return { dsn, source: '工作目录 .env' }
362
+ } catch (e) {
363
+ /* no .env */
364
+ }
365
+ return undefined
366
+ }