dsh-db-tool 0.1.1 → 0.1.3

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
@@ -621,7 +621,7 @@ window.__ModuleLoader__.load({
621
621
  { className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
622
622
  c.safeUrl || (c.host + ":" + (c.port || "")),
623
623
  ) : null,
624
- testInfo[c.id] ? React.createElement("div", { className: "dbt-muted" }, testInfo[c.id]) : null,
624
+ 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
625
  ),
626
626
  // 右列:动作按钮(次要语义,danger 仅删除)
627
627
  React.createElement(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
+ }