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.
- package/AGENTS.md +97 -0
- package/LICENSE +21 -21
- package/README.en.md +162 -0
- package/README.md +162 -110
- package/cordis.patch.yml +8 -8
- package/doc/REQUIREMENTS.md +65 -0
- package/lib/adhoc.mjs +97 -0
- package/lib/client.js +604 -0
- package/lib/collect.mjs +118 -0
- package/lib/config.mjs +492 -0
- package/lib/i18n.mjs +139 -0
- package/lib/index.mjs +345 -1417
- package/lib/mcp.mjs +462 -0
- package/lib/options.mjs +94 -0
- package/lib/runtime.mjs +255 -0
- package/lib/state.mjs +108 -0
- package/lib/tools.mjs +462 -0
- package/package.json +61 -39
- package/test/adhoc.test.mjs +29 -0
- package/test/client-format.test.mjs +88 -0
- package/test/i18n.test.mjs +27 -0
- package/test/init.test.mjs +163 -0
- package/test/options.test.mjs +41 -0
- package/test/state.test.mjs +85 -0
- package/test/util.test.mjs +96 -0
package/lib/collect.mjs
ADDED
|
@@ -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
|
+
}
|