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/AGENTS.md +86 -0
- package/LICENSE +21 -21
- package/README.md +134 -110
- package/cordis.patch.yml +8 -8
- package/doc/REQUIREMENTS.md +45 -0
- package/lib/adhoc.mjs +81 -0
- package/lib/client.js +177 -0
- package/lib/collect.mjs +118 -0
- package/lib/config.mjs +366 -0
- package/lib/index.mjs +217 -1417
- package/lib/mcp.mjs +476 -0
- package/lib/runtime.mjs +257 -0
- package/lib/state.mjs +108 -0
- package/lib/tools.mjs +304 -0
- package/package.json +60 -39
- package/test/client-format.test.mjs +40 -0
- package/test/init.test.mjs +106 -0
- package/test/state.test.mjs +85 -0
- package/test/util.test.mjs +96 -0
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
|
+
}});
|
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,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
|
+
}
|