dsh-db-tool 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -46,6 +46,23 @@ GaussDB 官方驱动未发布 npm,需先构建 vendor:`npm run build:gaussdb
46
46
  - `run_script`:node:vm 独立 context、60s 超时、无 require/process/网络/文件系统,仅注入受限 `db.{query,execute}` 句柄
47
47
  - 已知边界:对话内确认为提示级强制 + 审计兜底;DSH 无硬中断通道前,恶意对话仍可能诱导用户确认,请配合最小权限数据库账号使用
48
48
 
49
+ ## Troubleshooting
50
+
51
+ ### npm 安装插件导入失败(punycode / resolve.paths)
52
+
53
+ 症状:DSH 启动后插件加载报 `failed to import`,伴随 `TypeError: Cannot read properties of null (reading 'Symbol(Symbol.iterator)')`,栈指向 `dsh-app-boot` 的 `routeScoped`。
54
+
55
+ 根因:上游 `@deepseek-ai/dsh-app-boot` 对 `createRequire(parent).resolve.paths(name)` 直接做 `for..of`,而 Node 对 core-module 同名包(`punycode` 等)返回 `null`,hoisted profile 下凡依赖树含此类 npm 包的插件都会炸。
56
+
57
+ 一键修复(幂等,应用前自动备份为 `index.js.bak-hotfix`;`--revert` 可还原):
58
+
59
+ ```bash
60
+ npm run patch:dsh # Windows(PowerShell)
61
+ npm run patch:dsh:sh # macOS / Linux
62
+ ```
63
+
64
+ 脚本自动探测 DSH 安装根(`--dsh-root` 可显式指定);补丁文件见 `patches/dsh-app-boot-route-scoped-hotfix.patch`,仅对 `0.1.7-rc.2` 声明兼容,其他版本会警告(`--force` 覆盖)。上游 issue:<https://github.com/mengqi1436/dsh-db-tool/issues>(占位,待上游仓库开放后替换)。
65
+
49
66
  ## 测试
50
67
 
51
68
  ```bash
@@ -62,7 +79,7 @@ npx stryker run # 变异测试(范围 lib/guard + lib/manager + lib/store,
62
79
  lib/ host 插件(store / adapters×8 / guard / manager / http / index)
63
80
  client/ 侧边栏单文件产物(client.js,即源码)
64
81
  skills/ db-admin skill
65
- scripts/ GaussDB vendor 构建(sh / ps1)
82
+ scripts/ GaussDB vendor 构建、DSH dsh-app-boot 热修复(patch:dsh)
66
83
  docs/ 安装、HTTP 契约(api-contract.md)、skill 说明
67
84
  tests/ vitest(离线 mock + DBT_TEST_* 门控真机)
68
85
  vendor/ gaussdb-pg 构建产物(gitignore,不入库)
package/client/client.js CHANGED
@@ -66,6 +66,7 @@ window.__ModuleLoader__.load({
66
66
  modeRw: "读写",
67
67
  // 浏览
68
68
  noDatabases: "无可用数据库",
69
+ noSchemas: "无模式",
69
70
  noTables: "无表",
70
71
  loadFailed: "加载失败",
71
72
  structure: "结构",
@@ -150,6 +151,7 @@ window.__ModuleLoader__.load({
150
151
  modeRo: "read-only ro",
151
152
  modeRw: "read-write rw",
152
153
  noDatabases: "No databases available",
154
+ noSchemas: "No schemas",
153
155
  noTables: "No tables",
154
156
  loadFailed: "Failed to load",
155
157
  structure: "Structure",
@@ -621,7 +623,7 @@ window.__ModuleLoader__.load({
621
623
  { className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
622
624
  c.safeUrl || (c.host + ":" + (c.port || "")),
623
625
  ) : null,
624
- testInfo[c.id] ? React.createElement("div", { className: "dbt-muted" }, testInfo[c.id]) : null,
626
+ testInfo[c.id] ? React.createElement("div", { style: { fontSize: 12, color: testInfo[c.id].startsWith("✓") ? "var(--dbt-success, #30d158)" : "var(--dbt-danger, #ff453a)" } }, testInfo[c.id]) : null,
625
627
  ),
626
628
  // 右列:动作按钮(次要语义,danger 仅删除)
627
629
  React.createElement(
@@ -708,7 +710,8 @@ window.__ModuleLoader__.load({
708
710
  const [loading, setLoading] = React.useState({});
709
711
  const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
710
712
  const [dbs, setDbs] = React.useState({}); // connId -> string[]
711
- const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db>" -> TableInfo[]
713
+ const [schemasMap, setSchemasMap] = React.useState({}); // "<connId>/<db>" -> string[](PG/GaussDB 库内 schema 层)
714
+ const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db|schema>" -> TableInfo[]
712
715
  const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
713
716
  const [schema, setSchema] = React.useState([]);
714
717
  const [preview, setPreview] = React.useState(null); // QueryResult
@@ -718,7 +721,7 @@ window.__ModuleLoader__.load({
718
721
 
719
722
  // 会话项目切换 / 连接列表变化时清空树缓存,避免陈旧授权下的旧数据
720
723
  React.useEffect(() => {
721
- setOpen({}); setLoading({}); setError({}); setDbs({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
724
+ setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
722
725
  }, [projectPath]);
723
726
 
724
727
  function toggle(key, load) {
@@ -744,11 +747,23 @@ window.__ModuleLoader__.load({
744
747
  setDbs((m) => Object.assign({}, m, { [c.id]: list || [] }));
745
748
  }));
746
749
  }
750
+ // PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
751
+ const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
747
752
  function toggleDb(c, d) {
748
753
  if (!projectPath) return;
754
+ const useSchemas = !!HAS_SCHEMAS[c.kind];
755
+ const what = useSchemas ? "schemas" : "tables";
749
756
  toggle("d:" + c.id + "/" + d, () =>
750
- api("tables" + qs({ project: projectPath, connId: c.id, database: d })).then((list) => {
751
- setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d]: list || [] }));
757
+ api(what + qs({ project: projectPath, connId: c.id, database: d })).then((list) => {
758
+ const setter = useSchemas ? setSchemasMap : setTablesMap;
759
+ setter((m) => Object.assign({}, m, { [c.id + "/" + d]: list || [] }));
760
+ }));
761
+ }
762
+ function toggleSchema(c, d, s) {
763
+ if (!projectPath) return;
764
+ toggle("s:" + c.id + "/" + d + "/" + s, () =>
765
+ api("tables" + qs({ project: projectPath, connId: c.id, database: s })).then((list) => {
766
+ setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + s]: list || [] }));
752
767
  }));
753
768
  }
754
769
  // 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
@@ -795,19 +810,43 @@ window.__ModuleLoader__.load({
795
810
  if (!list) continue;
796
811
  if (list.length === 0) { treeRows.push(treerow(ck + ":e", 1, false, true, t("noDatabases"))); continue; }
797
812
  for (const d of list) {
813
+ const useSchemas = !!HAS_SCHEMAS[c.kind];
798
814
  const dk = "d:" + c.id + "/" + d;
799
815
  const dOpen = !!open[dk];
800
816
  treeRows.push(treerow(dk, 1, dOpen, false, d, () => toggleDb(c, d)));
801
817
  if (!dOpen) continue;
802
- const tkey = c.id + "/" + d;
803
- const tlist = tablesMap[tkey];
804
818
  if (loading[dk]) { treeRows.push(treerow(dk + ":l", 2, false, true, "…")); continue; }
805
819
  if (error[dk]) { treeRows.push(treerow(dk + ":x", 2, false, true, t("loadFailed") + ":" + error[dk], () => retry(dk, () => toggleDb(c, d)))); continue; }
820
+ // PG/GaussDB:库 → 模式 → 表(三层,Navicat 官方层级);schema 节点复用 open/loading/error 状态
821
+ if (useSchemas) {
822
+ const slist = schemasMap[c.id + "/" + d];
823
+ if (!slist) continue;
824
+ if (slist.length === 0) { treeRows.push(treerow(dk + ":e", 2, false, true, t("noSchemas"))); continue; }
825
+ for (const s of slist) {
826
+ const sk = "s:" + c.id + "/" + d + "/" + s;
827
+ const sOpen = !!open[sk];
828
+ treeRows.push(treerow(sk, 2, sOpen, false, s, () => toggleSchema(c, d, s)));
829
+ if (!sOpen) continue;
830
+ const tlist = tablesMap[c.id + "/" + s];
831
+ if (loading[sk]) { treeRows.push(treerow(sk + ":l", 3, false, true, "…")); continue; }
832
+ if (error[sk]) { treeRows.push(treerow(sk + ":x", 3, false, true, t("loadFailed") + ":" + error[sk], () => retry(sk, () => toggleSchema(c, d, s)))); continue; }
833
+ if (!tlist) continue;
834
+ if (tlist.length === 0) { treeRows.push(treerow(sk + ":e", 3, false, true, t("noTables"))); continue; }
835
+ for (const tb of tlist) {
836
+ const active = !!sel && sel.connId === c.id && sel.db === s && sel.table.name === tb.name;
837
+ treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
838
+ tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
839
+ () => setSel({ connId: c.id, db: s, table: tb }), active));
840
+ }
841
+ }
842
+ continue;
843
+ }
844
+ const tlist = tablesMap[c.id + "/" + d];
806
845
  if (!tlist) continue;
807
846
  if (tlist.length === 0) { treeRows.push(treerow(dk + ":e", 2, false, true, t("noTables"))); continue; }
808
847
  for (const tb of tlist) {
809
848
  const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
810
- treeRows.push(treerow("t:" + tkey + "/" + tb.name, 2, false, true,
849
+ treeRows.push(treerow("t:" + c.id + "/" + d + "/" + tb.name, 2, false, true,
811
850
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
812
851
  () => setSel({ connId: c.id, db: d, table: tb }), active));
813
852
  }
@@ -116,6 +116,14 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
116
116
  const res = await pool.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname');
117
117
  return res.rows.map((r) => String(r.datname));
118
118
  }),
119
+ // 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
120
+ // PG 连接固定单库,database 参数无法跨库,忽略;pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
121
+ listSchemas: () => humanize(`${kind} 列出模式`, async () => {
122
+ const res = await pool.query(`SELECT nspname FROM pg_catalog.pg_namespace
123
+ WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
124
+ ORDER BY nspname`);
125
+ return res.rows.map((r) => String(r.nspname));
126
+ }),
119
127
  listTables: (database) => humanize(`${kind} 列出表`, async () => {
120
128
  const schema = database ? assertIdent(database, 'schema') : defaultSchema();
121
129
  const res = await pool.query(`SELECT table_name, table_type FROM information_schema.tables
@@ -210,6 +210,9 @@ async function route(req, res, service, path, q, opts) {
210
210
  if (path === '/api/tables' && req.method === 'GET') {
211
211
  return sendOk(res, await service.tables(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
212
212
  }
213
+ if (path === '/api/schemas' && req.method === 'GET') {
214
+ return sendOk(res, await service.schemas(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
215
+ }
213
216
  if (path === '/api/schema' && req.method === 'GET') {
214
217
  return sendOk(res, await service.schema(projectOf(q), required(q, 'connId'), required(q, 'table'), q.get('database') ?? undefined));
215
218
  }
package/dist/manager.js CHANGED
@@ -146,6 +146,21 @@ export class DbToolService {
146
146
  throw this.toDriverError(e);
147
147
  }
148
148
  }
149
+ /** 库内 schema 清单(仅 PG/GaussDB 等三层语义适配器实现;ro 即可)。 */
150
+ async schemas(projectPath, connId, database) {
151
+ const { key, adapter } = await this.authorize(projectPath, connId, false);
152
+ if (!adapter.listSchemas)
153
+ return [];
154
+ try {
155
+ const result = await adapter.listSchemas(database);
156
+ this.audit(key, connId, 'schemas', database ?? 'default', 'none', false, true);
157
+ return result;
158
+ }
159
+ catch (e) {
160
+ this.audit(key, connId, 'schemas', database ?? 'default', 'none', false, false, this.errText(e));
161
+ throw this.toDriverError(e);
162
+ }
163
+ }
149
164
  async schema(projectPath, connId, table, database) {
150
165
  const t = assertNonEmpty(table, 'table');
151
166
  const { key, adapter } = await this.authorize(projectPath, connId, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,9 @@
28
28
  "typecheck": "tsc --noEmit",
29
29
  "build": "tsc -p tsconfig.build.json && node -e \"const fs=require('fs'); fs.mkdirSync('dist/script',{recursive:true}); fs.copyFileSync('lib/script/worker.cjs','dist/script/worker.cjs'); console.log('build OK')\"",
30
30
  "build:gaussdb": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-gaussdb.ps1",
31
- "build:gaussdb:sh": "bash scripts/build-gaussdb.sh"
31
+ "build:gaussdb:sh": "bash scripts/build-gaussdb.sh",
32
+ "patch:dsh": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/apply-dsh-hotfix.ps1",
33
+ "patch:dsh:sh": "bash scripts/apply-dsh-hotfix.sh"
32
34
  },
33
35
  "dsh": {
34
36
  "bundle": {
@@ -72,6 +74,7 @@
72
74
  "client",
73
75
  "skills",
74
76
  "docs",
77
+ "patches",
75
78
  "scripts",
76
79
  "README.md"
77
80
  ]
@@ -0,0 +1,26 @@
1
+ # DSH ResolutionRouter routeScoped hotfix (upstream bug: createRequire(parent).resolve.paths(name)
2
+ # returns null for core-module names like "punycode", breaking hoisted profile installs).
3
+ # Target-Version: 0.1.7-rc.2
4
+ # Apply manually: git apply -p1 -d <dsh-app-boot package root> <this file>
5
+ # Or one command: npm run patch:dsh (scripts/apply-dsh-hotfix.ps1)
6
+ # Generated from @deepseek-ai/dsh-app-boot@0.1.7-rc.2 npm tarball vs patched local copy.
7
+ diff --git a/lib/index.js b/lib/index.js
8
+ index 8f6041d..89a138e 100644
9
+ --- a/lib/index.js
10
+ +++ b/lib/index.js
11
+ @@ -1419,7 +1419,14 @@ var ResolutionRouter = class {
12
+ const target = resolution.entries.get(name);
13
+ const candidates = [];
14
+ const localSearchPaths = [];
15
+ - for (const searchPath of createRequire(parent).resolve.paths(name)) {
16
+ + /* local hotfix: resolve.paths returns null for core-module names (e.g. "punycode"),
17
+ + which made the for..of throw TypeError and broke every hoisted profile install
18
+ + whose dependency tree requires such an npm package. Fall back to the standard
19
+ + node_modules search chain so the local candidate can still be found. */
20
+ + const _hotReq = createRequire(parent);
21
+ + const _hotPaths = _hotReq.resolve.paths(name)
22
+ + ?? _hotReq("node:module")._nodeModulePaths(_hotReq("node:path").dirname(parent));
23
+ + for (const searchPath of _hotPaths) {
24
+ if (!searchPath.startsWith(layer.localPrefix)) break;
25
+ localSearchPaths.push(searchPath);
26
+ const candidate = localPackageCandidate(searchPath, name, flavor);
@@ -0,0 +1,34 @@
1
+ # Apply or revert the DSH ResolutionRouter hotfix (dsh-app-boot routeScoped resolve.paths fix).
2
+ # Thin wrapper: all logic lives in scripts/hotfix-core.mjs (cross-platform, testable).
3
+ # Usage: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/apply-dsh-hotfix.ps1
4
+ # [-DshRoot <dir>] [-PatchFile <file>] [-Force] [-Revert] [-DryRun]
5
+ param(
6
+ [string]$DshRoot,
7
+ [string]$PatchFile,
8
+ [switch]$Force,
9
+ [switch]$Revert,
10
+ [switch]$DryRun
11
+ )
12
+
13
+ $ErrorActionPreference = 'Stop'
14
+ $core = Join-Path $PSScriptRoot 'hotfix-core.mjs'
15
+ if (-not (Test-Path $core)) {
16
+ Write-Error "hotfix-core.mjs not found next to this script ($PSScriptRoot)."
17
+ exit 1
18
+ }
19
+
20
+ $node = Get-Command node -ErrorAction SilentlyContinue
21
+ if (-not $node) {
22
+ Write-Error 'node is required but was not found on PATH.'
23
+ exit 1
24
+ }
25
+
26
+ $argv = @($core)
27
+ if ($DshRoot) { $argv += @('--dsh-root', $DshRoot) }
28
+ if ($PatchFile) { $argv += @('--patch-file', $PatchFile) }
29
+ if ($Force) { $argv += '--force' }
30
+ if ($Revert) { $argv += '--revert' }
31
+ if ($DryRun) { $argv += '--dry-run' }
32
+
33
+ & $node.Source @argv
34
+ exit $LASTEXITCODE
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # Apply or revert the DSH ResolutionRouter hotfix (dsh-app-boot routeScoped resolve.paths fix).
3
+ # Thin wrapper: all logic lives in scripts/hotfix-core.mjs (cross-platform, testable).
4
+ # Usage: bash scripts/apply-dsh-hotfix.sh [--dsh-root <dir>] [--patch-file <file>] [--force] [--revert] [--dry-run]
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ CORE="$SCRIPT_DIR/hotfix-core.mjs"
9
+
10
+ if [[ ! -f "$CORE" ]]; then
11
+ echo "hotfix-core.mjs not found next to this script ($SCRIPT_DIR)." >&2
12
+ exit 1
13
+ fi
14
+
15
+ if ! command -v node >/dev/null 2>&1; then
16
+ echo "node is required but was not found on PATH." >&2
17
+ exit 1
18
+ fi
19
+
20
+ exec node "$CORE" "$@"
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 类型声明:scripts/hotfix-core.mjs(无 allowJs,为 tests/scripts/hotfix.spec.ts 提供 import 类型)。
3
+ */
4
+ export declare const TARGET_VERSION: string;
5
+ export declare const HOTFIX_MARKER: string;
6
+ export declare const BOOT_INDEX_REL: string;
7
+ export declare const BACKUP_SUFFIX: string;
8
+ export declare const ORIGINAL_LINE: string;
9
+
10
+ export declare function isApplied(content: string): boolean;
11
+ export declare function applyToContent(content: string): {
12
+ ok: boolean;
13
+ reason?: string;
14
+ content: string;
15
+ };
16
+ export declare function revertContent(content: string): {
17
+ ok: boolean;
18
+ reason?: string;
19
+ content: string;
20
+ };
21
+ export declare function parsePatchTargetVersion(patchText: string): string | undefined;
22
+ export declare function findBootIndex(root: string): string | undefined;
23
+ export declare function resolveCandidateRoots(explicitRoot?: string): string[];
24
+ export declare function resolveDshRoot(explicitRoot?: string): string | undefined;
25
+ export declare function applyToIndexFile(indexFile: string, patchFile: string): string;
26
+ export declare function run(argv?: string[]): number;
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DSH ResolutionRouter hotfix —— 核心逻辑(纯 Node、零依赖、可被测试 import)。
4
+ *
5
+ * 修复的 bug:@deepseek-ai/dsh-app-boot 的 routeScoped() 直接对
6
+ * `createRequire(parent).resolve.paths(name)` 做 for..of,而 Node 对 core-module
7
+ * 同名包(punycode 等)返回 null,导致 hoisted profile 下 npm 安装插件导入失败
8
+ * (TypeError: Cannot read properties of null)。
9
+ *
10
+ * 用法:node scripts/hotfix-core.mjs [--dsh-root <dir>] [--patch-file <file>]
11
+ * [--force] [--revert] [--dry-run]
12
+ * 退出码:0 成功/已应用跳过;1 失败;2 版本不匹配且未给 --force。
13
+ */
14
+ import { execFileSync, spawnSync } from 'node:child_process';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
19
+ /** 补丁文件里的目标版本声明行(patches/*.patch 注释头)。 */
20
+ export const TARGET_VERSION = '0.1.7-rc.2';
21
+ /** 判断目标文件是否已打补丁的标识串(hotfix 注释首行片段)。 */
22
+ export const HOTFIX_MARKER = 'local hotfix: resolve.paths returns null';
23
+ /** dsh-app-boot 内 lib/index.js 相对包根的路径。 */
24
+ export const BOOT_INDEX_REL = path.join('lib', 'index.js');
25
+ /** 应用前备份文件后缀。 */
26
+ export const BACKUP_SUFFIX = '.bak-hotfix';
27
+
28
+ /** 原始(未打补丁)单行 —— 必须与 dsh-app-boot@0.1.7-rc.2 lib/index.js 逐字节一致。 */
29
+ export const ORIGINAL_LINE =
30
+ '\t\tfor (const searchPath of createRequire(parent).resolve.paths(name)) {';
31
+
32
+ /** 热修复代码块 —— 与 patches/dsh-app-boot-route-scoped-hotfix.patch 的 + 行一致。 */
33
+ const HOTFIX_BLOCK = [
34
+ '\t\t/* local hotfix: resolve.paths returns null for core-module names (e.g. "punycode"),',
35
+ '\t\t which made the for..of throw TypeError and broke every hoisted profile install',
36
+ '\t\t whose dependency tree requires such an npm package. Fall back to the standard',
37
+ '\t\t node_modules search chain so the local candidate can still be found. */',
38
+ '\t\tconst _hotReq = createRequire(parent);',
39
+ '\t\tconst _hotPaths = _hotReq.resolve.paths(name)',
40
+ '\t\t\t?? _hotReq("node:module")._nodeModulePaths(_hotReq("node:path").dirname(parent));',
41
+ '\t\tfor (const searchPath of _hotPaths) {',
42
+ ].join('\n');
43
+
44
+ /** 目标文件是否已包含热修复。 */
45
+ export function isApplied(content) {
46
+ return content.includes(HOTFIX_MARKER);
47
+ }
48
+
49
+ /**
50
+ * 在文件内容上应用热修复(幂等:已应用时返回 already-applied)。
51
+ * @returns {{ ok: boolean, reason?: string, content: string }}
52
+ */
53
+ export function applyToContent(content) {
54
+ if (isApplied(content)) return { ok: false, reason: 'already-applied', content };
55
+ if (!content.includes(ORIGINAL_LINE)) {
56
+ return { ok: false, reason: 'target-line-not-found', content };
57
+ }
58
+ return { ok: true, content: content.replace(ORIGINAL_LINE, HOTFIX_BLOCK) };
59
+ }
60
+
61
+ /**
62
+ * 还原热修复(优先用内建块反替换;调用方在磁盘层面应优先用 .bak-hotfix 备份)。
63
+ * @returns {{ ok: boolean, reason?: string, content: string }}
64
+ */
65
+ export function revertContent(content) {
66
+ if (!isApplied(content)) return { ok: false, reason: 'not-applied', content };
67
+ if (!content.includes(HOTFIX_BLOCK)) {
68
+ return { ok: false, reason: 'hotfix-block-not-found', content };
69
+ }
70
+ return { ok: true, content: content.replace(HOTFIX_BLOCK, ORIGINAL_LINE) };
71
+ }
72
+
73
+ /** 从补丁文本解析注释头声明的目标版本(无则返回 undefined)。 */
74
+ export function parsePatchTargetVersion(patchText) {
75
+ const m = /^\s*#\s*Target-Version:\s*(\S+)/m.exec(patchText);
76
+ return m?.[1];
77
+ }
78
+
79
+ /**
80
+ * 在候选 dsh 根下定位 dsh-app-boot/lib/index.js。
81
+ * 候选解释:root=DSH 安装根(@deepseek-ai/dsh 包目录);也接受直接指向
82
+ * dsh-app-boot 包根或 node_modules/@deepseek-ai 层级。
83
+ * @returns {string | undefined} lib/index.js 绝对路径
84
+ */
85
+ export function findBootIndex(root) {
86
+ const candidates = [
87
+ path.join(root, 'node_modules', '@deepseek-ai', 'dsh-app-boot', BOOT_INDEX_REL),
88
+ path.join(root, '@deepseek-ai', 'dsh-app-boot', BOOT_INDEX_REL),
89
+ path.join(root, BOOT_INDEX_REL),
90
+ ];
91
+ for (const candidate of candidates) {
92
+ try {
93
+ if (!fs.statSync(candidate).isFile()) continue;
94
+ // 防误伤:确认包根 package.json 的 name 是 dsh-app-boot。
95
+ const pkg = JSON.parse(
96
+ fs.readFileSync(path.join(path.dirname(path.dirname(candidate)), 'package.json'), 'utf8'),
97
+ );
98
+ if (pkg.name === '@deepseek-ai/dsh-app-boot') return candidate;
99
+ } catch {
100
+ /* 候选不存在或不可读,试下一个 */
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /**
107
+ * 自动探测 DSH 安装根候选列表(dsh 包目录)。
108
+ * 顺序:--dsh-root 参数 → DSH_HOME → which/where dsh 的 shim 反推 → 常见全局路径。
109
+ * 调用方应逐个用 findBootIndex 试探(DSH_HOME 存在但不含 dsh-app-boot 时继续回退)。
110
+ */
111
+ export function resolveCandidateRoots(explicitRoot) {
112
+ const roots = [];
113
+ if (explicitRoot) {
114
+ if (fs.existsSync(explicitRoot)) roots.push(path.resolve(explicitRoot));
115
+ return roots;
116
+ }
117
+ const fromEnv = process.env.DSH_HOME;
118
+ if (fromEnv && fs.existsSync(fromEnv)) roots.push(path.resolve(fromEnv));
119
+
120
+ const shim = findDshOnPath();
121
+ if (shim) {
122
+ // <global>/dsh(.cmd) → <global>/node_modules/@deepseek-ai/dsh
123
+ const globalDir = path.dirname(path.dirname(shim));
124
+ const candidate = path.join(globalDir, 'node_modules', '@deepseek-ai', 'dsh');
125
+ if (fs.existsSync(candidate)) roots.push(candidate);
126
+ }
127
+
128
+ roots.push(...commonGlobalRoots());
129
+ return roots;
130
+ }
131
+
132
+ /** resolveCandidateRoots 的单值便捷形式(取第一个候选)。 */
133
+ export function resolveDshRoot(explicitRoot) {
134
+ return resolveCandidateRoots(explicitRoot)[0];
135
+ }
136
+
137
+ function findDshOnPath() {
138
+ const isWin = process.platform === 'win32';
139
+ try {
140
+ const out = execFileSync(isWin ? 'where.exe' : 'which', ['dsh'], {
141
+ encoding: 'utf8',
142
+ stdio: ['ignore', 'pipe', 'ignore'],
143
+ });
144
+ const first = out.split(/\r?\n/).find((line) => line.trim() !== '');
145
+ return first ? first.trim() : undefined;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ function commonGlobalRoots() {
152
+ const isWin = process.platform === 'win32';
153
+ const roots = [];
154
+ if (isWin) {
155
+ if (process.env.APPDATA) {
156
+ roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules', '@deepseek-ai', 'dsh'));
157
+ }
158
+ // nvm-windows: <drive>:\Tool\nvm\v*\node_modules\@deepseek-ai\dsh 与 C:\nvm\v*\...
159
+ for (const base of ['E:\\Tool\\nvm', 'C:\\nvm', 'D:\\nvm']) {
160
+ try {
161
+ for (const entry of fs.readdirSync(base)) {
162
+ if (/^v/.test(entry)) {
163
+ roots.push(path.join(base, entry, 'node_modules', '@deepseek-ai', 'dsh'));
164
+ }
165
+ }
166
+ } catch {
167
+ /* base 不存在 */
168
+ }
169
+ }
170
+ } else {
171
+ for (const lib of ['/usr/local/lib', '/usr/lib', path.join(process.env.HOME ?? '', '.nvm')]) {
172
+ try {
173
+ if (lib.includes('.nvm')) {
174
+ const versions = path.join(lib, 'versions', 'node');
175
+ for (const v of fs.readdirSync(versions)) {
176
+ roots.push(
177
+ path.join(versions, v, 'lib', 'node_modules', '@deepseek-ai', 'dsh'),
178
+ );
179
+ }
180
+ } else {
181
+ roots.push(path.join(lib, 'node_modules', '@deepseek-ai', 'dsh'));
182
+ }
183
+ } catch {
184
+ /* 不可读 */
185
+ }
186
+ }
187
+ }
188
+ return roots;
189
+ }
190
+
191
+ /**
192
+ * 首选 git apply(若 git 可用且补丁可干净应用);否则回退内建字符串替换。
193
+ * 两种路径结果一致(内建块与补丁 + 行逐字节相同,测试保证)。
194
+ */
195
+ export function applyToIndexFile(indexFile, patchFile) {
196
+ if (isGitApplyable(indexFile, patchFile)) {
197
+ const bootRoot = path.dirname(path.dirname(indexFile));
198
+ execFileSync('git', ['apply', '-p1', '--directory', '.', patchFile], {
199
+ cwd: bootRoot,
200
+ });
201
+ return 'git-apply';
202
+ }
203
+ const content = fs.readFileSync(indexFile, 'utf8');
204
+ const result = applyToContent(content);
205
+ if (!result.ok) throw new Error(`apply failed: ${result.reason}`);
206
+ fs.writeFileSync(indexFile, result.content, 'utf8');
207
+ return 'builtin-replace';
208
+ }
209
+
210
+ function isGitApplyable(indexFile, patchFile) {
211
+ const bootRoot = path.dirname(path.dirname(indexFile));
212
+ try {
213
+ // --directory 只接受相对路径(git 拒绝绝对目标 "invalid path"),故以包根为 cwd。
214
+ execFileSync('git', ['apply', '--check', '-p1', '--directory', '.', patchFile], {
215
+ cwd: bootRoot,
216
+ });
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /** 命令行主入口。@returns {number} 退出码 */
224
+ export function run(argv = process.argv.slice(2)) {
225
+ let dshRoot;
226
+ let patchFile = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'patches', 'dsh-app-boot-route-scoped-hotfix.patch');
227
+ let force = false;
228
+ let revert = false;
229
+ let dryRun = false;
230
+ for (let i = 0; i < argv.length; i++) {
231
+ const arg = argv[i];
232
+ if (arg === '--dsh-root') dshRoot = argv[++i];
233
+ else if (arg === '--patch-file') patchFile = argv[++i];
234
+ else if (arg === '--force') force = true;
235
+ else if (arg === '--revert') revert = true;
236
+ else if (arg === '--dry-run') dryRun = true;
237
+ else if (arg === '--help' || arg === '-h') {
238
+ console.log('usage: node scripts/hotfix-core.mjs [--dsh-root <dir>] [--patch-file <file>] [--force] [--revert] [--dry-run]');
239
+ return 0;
240
+ }
241
+ }
242
+
243
+ let indexFile;
244
+ for (const root of resolveCandidateRoots(dshRoot)) {
245
+ indexFile = findBootIndex(root);
246
+ if (indexFile) {
247
+ console.log(`[dsh-hotfix] using DSH root: ${root}`);
248
+ break;
249
+ }
250
+ }
251
+ if (!indexFile) {
252
+ console.error('[dsh-hotfix] dsh-app-boot/lib/index.js not found in any candidate root; pass --dsh-root explicitly.');
253
+ return 1;
254
+ }
255
+
256
+ const pkgPath = path.join(path.dirname(path.dirname(indexFile)), 'package.json');
257
+ let actualVersion;
258
+ try {
259
+ actualVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
260
+ } catch {
261
+ /* 读不到版本号时跳过比对 */
262
+ }
263
+ const patchText = fs.existsSync(patchFile) ? fs.readFileSync(patchFile, 'utf8') : '';
264
+ const declared = parsePatchTargetVersion(patchText) ?? TARGET_VERSION;
265
+ if (actualVersion && actualVersion !== declared && !force) {
266
+ console.warn(`[dsh-hotfix] version mismatch: installed ${actualVersion} != patch target ${declared}. Use --force to apply anyway.`);
267
+ return 2;
268
+ }
269
+
270
+ let content;
271
+ try {
272
+ content = fs.readFileSync(indexFile, 'utf8');
273
+ } catch (err) {
274
+ console.error(`[dsh-hotfix] cannot read ${indexFile}: ${err.message}`);
275
+ return 1;
276
+ }
277
+
278
+ if (revert) {
279
+ const backup = indexFile + BACKUP_SUFFIX;
280
+ if (fs.existsSync(backup)) {
281
+ if (dryRun) {
282
+ console.log(`[dsh-hotfix] dry-run: would restore ${backup} -> ${indexFile}`);
283
+ return 0;
284
+ }
285
+ fs.copyFileSync(backup, indexFile);
286
+ console.log(`[dsh-hotfix] reverted ${indexFile} from backup.`);
287
+ return 0;
288
+ }
289
+ const result = revertContent(content);
290
+ if (!result.ok) {
291
+ console.error(`[dsh-hotfix] revert failed: ${result.reason} (no backup at ${backup}).`);
292
+ return 1;
293
+ }
294
+ if (dryRun) {
295
+ console.log('[dsh-hotfix] dry-run: would revert via builtin replacement.');
296
+ return 0;
297
+ }
298
+ fs.writeFileSync(indexFile, result.content, 'utf8');
299
+ console.log(`[dsh-hotfix] reverted ${indexFile} via builtin replacement.`);
300
+ return 0;
301
+ }
302
+
303
+ if (isApplied(content)) {
304
+ console.log('[dsh-hotfix] already applied; nothing to do.');
305
+ return 0;
306
+ }
307
+ if (dryRun) {
308
+ console.log(`[dsh-hotfix] dry-run: would apply patch to ${indexFile}.`);
309
+ return 0;
310
+ }
311
+ const backup = indexFile + BACKUP_SUFFIX;
312
+ if (!fs.existsSync(backup)) fs.copyFileSync(indexFile, backup);
313
+ const method = applyToIndexFile(indexFile, patchFile);
314
+ console.log(`[dsh-hotfix] applied to ${indexFile} (via ${method}; backup at ${backup}).`);
315
+ return 0;
316
+ }
317
+
318
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
319
+ process.exitCode = run(process.argv.slice(2));
320
+ }
@@ -57,6 +57,7 @@ DatabaseManager({ action: "schema", conn_id: "c1", database: "shop", table: "use
57
57
  ```
58
58
 
59
59
  - 表列表返回 `TableInfo[]`:`{name, type?, comment?, database?}`(Redis 的 type 为 hash|list|set|zset|string|stream)。
60
+ - PostgreSQL/GaussDB 为「数据库 → 模式(schema) → 表」三层语义:`database` 参数此时应传 **schema 名**(如 `public`);不确定时先执行 `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'` 列出可用 schema。
60
61
  - 列结构返回 `ColumnInfo[]`:`{name, dataType, nullable, key?, default?, comment?}`。
61
62
 
62
63
  ### 5. preview — 预览表数据