dsh-wechat-remote 1.7.5 → 1.7.6

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
@@ -1,6 +1,6 @@
1
- # Agent远程管理助手 · DSH 插件安装器
2
-
3
- 当前安装器版本为 1.7.4,安装的正式插件仍为 1.7.2。已正常使用插件 1.7.2 的用户,无需因此重新安装。
1
+ # Agent远程管理助手 · DSH 插件安装器
2
+
3
+ 当前版本为 1.7.6,安装同版本正式插件。改善全局安装及 npx 启动的识别,修复 ARM64 设备的一键更新误拦截。
4
4
 
5
5
  首次安装和升级旧插件使用同一个命令:
6
6
 
@@ -8,16 +8,18 @@
8
8
  npx -y dsh-wechat-remote@latest
9
9
  ```
10
10
 
11
- 需已安装 DSH,使用 Node.js 22.13 或更高版本。安装工具由包依赖自动提供,无需另装 pnpm 或 Git。
11
+ 请先按原来的方式启动 DSH WebUI,再在同一个系统用户下另开终端运行本命令。无需改成全局安装 DSH;使用 `npx @deepseek-ai/dsh web` 的用户也使用同一条安装命令。需要 Node.js 22.13 或更高版本,无需另装 pnpm 或 Git。
12
+
13
+ 安装器安装正式插件,保留原配置、配对和会话,不会升级 DSH 本体。请先结束运行中的任务;安装完成后自动重启原 DSH 并尝试打开 WebUI。
14
+
15
+ DSH 的新版本、预览版本或未登记的主机架构不会仅因缺少验证记录而被拒绝安装。安装器仍检查必要运行能力、安装包完整性和已知不兼容问题,并保留失败恢复所需的备份;无法确认恢复结果时会提示保留数据。这不代表所有宿主组合均已通过功能验证。
16
+
17
+ 支持已验证的 DSH `0.1.1-rc.2`、`0.1.2-rc.1`、`0.1.5-rc.1`,Windows / macOS Intel / Linux x64。普通 Node 启动、独立的 macOS launchd 和 Linux 用户级 systemd 服务可自动恢复;无法确认启动方式时不自动停止主机。
12
18
 
13
- 安装器安装正式插件,保留原配置、配对和会话,不会升级 DSH 本体。请先结束运行中的任务;安装完成后自动重启原 DSH 并尝试打开 WebUI。
14
-
15
- DSH 的新版本、预览版本或未登记的主机架构不会仅因缺少验证记录而被拒绝安装。安装器仍检查必要运行能力、安装包完整性和已知不兼容问题,并保留失败恢复所需的备份;无法确认恢复结果时会提示保留数据。这不代表所有宿主组合均已通过功能验证。
19
+ 另已在 ARM64 Linux 电视盒子上验证 DSH `0.1.5-rc.1` 的首次安装、重复执行及重装。1.7.4 改善较慢主机启动时被误判为安装超时的问题;安装过程中请等待完成,勿重复执行命令。
16
20
 
17
- 支持已验证的 DSH `0.1.1-rc.2`、`0.1.2-rc.1`、`0.1.5-rc.1`,Windows / macOS Intel / Linux x64。普通 Node 启动、独立的 macOS launchd 和 Linux 用户级 systemd 服务可自动恢复;无法确认启动方式时不自动停止主机。
18
-
19
- 另已在 ARM64 Linux 电视盒子上验证 DSH `0.1.5-rc.1` 的首次安装、重复执行及重装。1.7.4 改善较慢主机启动时被误判为安装超时的问题;安装过程中请等待完成,勿重复执行命令。
21
+ 旧插件的一键更新如被版本或架构判断拦截,可用上述安装命令升级一次。从插件 1.7.6 起,未登记的 CPU 架构或 DSH 版本不再影响更新入口;执行前仍会检查实际安装与重启条件,无需因此降低 DSH 版本或删除节点。
20
22
 
21
- 旧插件 1.7.1 在 DSH 0.1.5 上无法一键更新时,可用本安装命令过渡一次。从插件 1.7.2 起,后续已验证的兼容更新不再受旧的 DSH 版本列表限制;不需要因此降低 DSH 版本或删除节点。
23
+ 默认使用 `web` 配置和原来的 DSH 数据目录。自定义数据目录时,在新终端设置相同的 `DSH_HOME`,或附加 `--home "数据目录"`;使用其他配置时附加 `--profile 配置名称`。WSL、容器和其他系统用户内的 DSH,需要在对应环境中执行安装,不能跨环境替换。
22
24
 
23
- 使用其他配置时附加 `--profile 配置名称`。安装损坏需要重装时附加 `--repair`;不会自动降级。
25
+ DSH 未启动且找到多个安装版本时,会要求选择,不会随意选一份。也可用 `--dsh-cli "DSH的lib/bin.js路径"` 明确指定。安装损坏需要重装时附加 `--repair`;不会自动降级。
package/assets/plugin.tgz CHANGED
Binary file
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "1.7.5",
2
+ "version": "1.7.6",
3
3
  "catalog": {
4
4
  "schemaVersion": 1,
5
- "revision": "installer-1.7.5",
6
- "issuedAt": 1789362769360,
7
- "expiresAt": 1791781969360,
5
+ "revision": "installer-1.7.6",
6
+ "issuedAt": 1789480914884,
7
+ "expiresAt": 1791900114884,
8
8
  "releases": [
9
9
  {
10
- "version": "1.7.5",
10
+ "version": "1.7.6",
11
11
  "channel": "stable",
12
12
  "dsh": [
13
13
  "0.1.1-rc.2",
@@ -20,13 +20,50 @@
20
20
  "linux"
21
21
  ],
22
22
  "architectures": [
23
- "x64"
23
+ "x64",
24
+ "arm64"
24
25
  ],
25
26
  "asset": {
26
- "url": "https://github.com/martinbear1/dsh-wechat-remote/releases/download/v1.7.5/harness-remote-dsh-wechat-remote-1.7.5.tgz",
27
- "sha256": "d5545651e80242d1782fb04e86aece5d0460db80bdf1cec7688429e2abaf9e6e",
28
- "bytes": 9539981
29
- }
27
+ "url": "https://github.com/martinbear1/dsh-wechat-remote/releases/download/v1.7.6/harness-remote-dsh-wechat-remote-1.7.6.tgz",
28
+ "sha256": "ddd14140e93eaf1704dcca7380b1f99d8243089c2be337eaf94d2e87de4a0ff5",
29
+ "bytes": 9549941
30
+ },
31
+ "targets": [
32
+ {
33
+ "platform": "windows",
34
+ "arch": "x64",
35
+ "dsh": [
36
+ "0.1.1-rc.2",
37
+ "0.1.2-rc.1",
38
+ "0.1.5-rc.1"
39
+ ]
40
+ },
41
+ {
42
+ "platform": "macos",
43
+ "arch": "x64",
44
+ "dsh": [
45
+ "0.1.1-rc.2",
46
+ "0.1.2-rc.1",
47
+ "0.1.5-rc.1"
48
+ ]
49
+ },
50
+ {
51
+ "platform": "linux",
52
+ "arch": "x64",
53
+ "dsh": [
54
+ "0.1.1-rc.2",
55
+ "0.1.2-rc.1",
56
+ "0.1.5-rc.1"
57
+ ]
58
+ },
59
+ {
60
+ "platform": "linux",
61
+ "arch": "arm64",
62
+ "dsh": [
63
+ "0.1.5-rc.1"
64
+ ]
65
+ }
66
+ ]
30
67
  }
31
68
  ],
32
69
  "blocked": [],
@@ -0,0 +1,165 @@
1
+ /** Read-only discovery. A candidate is NOT the installation target until the
2
+ * native, token-bound host handshake confirms its home/profile/CLI/PID.
3
+ * Never run an unvalidated shell shim, fetch a different DSH, or scan a disk. */
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import os from 'node:os'
7
+ import { execFile } from 'node:child_process'
8
+ import { promisify } from 'node:util'
9
+ import { createInterface } from 'node:readline/promises'
10
+
11
+ const execute = promisify(execFile)
12
+ const PACKAGE = '@deepseek-ai/dsh'
13
+ const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '')
14
+
15
+ export function validateDshCli(filename) {
16
+ const cli = fs.realpathSync(path.resolve(filename))
17
+ const root = path.resolve(cli, '../..')
18
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
19
+ const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.dsh
20
+ if (manifest.name !== PACKAGE || path.basename(cli) !== 'bin.js' || !fs.statSync(cli).isFile()
21
+ || typeof bin !== 'string' || fs.realpathSync(path.resolve(root, bin)) !== cli) {
22
+ throw new Error('指定路径不是有效的 DSH 启动程序,未修改安装。')
23
+ }
24
+ return { cli, version: String(manifest.version || '') }
25
+ }
26
+
27
+ export function resolveHome(value, fallback = os.homedir()) {
28
+ const raw = String(value || '')
29
+ const configured = raw.trim() ? raw : ''
30
+ return path.resolve(configured === '~' ? fallback : /^~[/\\]/.test(configured)
31
+ ? path.join(fallback, configured.slice(2)) : configured || path.join(fallback, '.dsh'))
32
+ }
33
+
34
+ /** npm's own config resolves .npmrc, custom cache/prefix and Node managers.
35
+ * Query individual public values; never read or log registry credentials. */
36
+ async function npmLocation(key, env, cwd) {
37
+ let npm = env.npm_execpath
38
+ if (!npm || !fs.existsSync(npm)) {
39
+ const roots = [path.dirname(process.execPath), ...(env.PATH || '').split(path.delimiter)]
40
+ npm = roots.flatMap(dir => [path.join(dir, 'node_modules/npm/bin/npm-cli.js'),
41
+ path.join(dir, '../lib/node_modules/npm/bin/npm-cli.js')]).find(file => fs.existsSync(file))
42
+ }
43
+ if (!npm) return ''
44
+ try {
45
+ const { stdout } = await execute(process.execPath, [npm, 'config', 'get', key], {
46
+ cwd, env, windowsHide: true, timeout: 10000, maxBuffer: 16384,
47
+ })
48
+ const value = stdout.trim()
49
+ return value && !/[\r\n\0]/.test(value) && path.isAbsolute(value) ? value : ''
50
+ } catch { return '' }
51
+ }
52
+
53
+ export async function discoverDsh({ env = process.env, cwd = process.cwd(), cache, prefix } = {}) {
54
+ const found = new Map()
55
+ const add = (filename, source) => {
56
+ try {
57
+ const item = validateDshCli(filename)
58
+ if (!found.has(item.cli)) found.set(item.cli, { ...item, source })
59
+ } catch { /* A missing/broken cache entry is not a usable DSH. */ }
60
+ }
61
+ for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
62
+ add(path.join(dir, 'dsh'), 'PATH') // POSIX symlink, never execute a Windows shim.
63
+ add(path.join(dir, 'node_modules/@deepseek-ai/dsh/lib/bin.js'), 'PATH')
64
+ add(path.join(dir, '../lib/node_modules/@deepseek-ai/dsh/lib/bin.js'), 'PATH')
65
+ add(path.join(dir, '../@deepseek-ai/dsh/lib/bin.js'), 'PATH') // npm .bin, including Windows.
66
+ }
67
+ for (let dir = path.resolve(cwd);;) {
68
+ add(path.join(dir, 'node_modules/@deepseek-ai/dsh/lib/bin.js'), 'project')
69
+ add(path.join(dir, 'apps/cli/lib/bin.js'), 'source')
70
+ const parent = path.dirname(dir)
71
+ if (parent === dir) break
72
+ dir = parent
73
+ }
74
+ const locations = await Promise.all([
75
+ cache !== undefined ? cache : env.npm_config_cache || npmLocation('cache', env, cwd),
76
+ prefix !== undefined ? prefix : npmLocation('prefix', env, cwd),
77
+ ])
78
+ if (locations[1]) {
79
+ add(path.join(locations[1], 'node_modules/@deepseek-ai/dsh/lib/bin.js'), 'npm-global')
80
+ add(path.join(locations[1], 'lib/node_modules/@deepseek-ai/dsh/lib/bin.js'), 'npm-global')
81
+ }
82
+ const cacheRoot = locations[0]
83
+ if (cacheRoot) {
84
+ try {
85
+ const entries = fs.readdirSync(path.join(cacheRoot, '_npx'), { withFileTypes: true })
86
+ if (entries.length > 512) throw new Error('npx 缓存较多,请先启动要使用的 DSH,再运行安装命令。')
87
+ for (const entry of entries) if (/^[a-f0-9]+$/i.test(entry.name) && entry.isDirectory()) {
88
+ add(path.join(cacheRoot, '_npx', entry.name, 'node_modules/@deepseek-ai/dsh/lib/bin.js'), 'npx')
89
+ }
90
+ } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error }
91
+ }
92
+ return [...found.values()]
93
+ }
94
+
95
+ export async function chooseDsh(options = {}) {
96
+ if (options.cli) return validateDshCli(options.cli).cli
97
+ const candidates = await discoverDsh(options)
98
+ if (!candidates.length) throw new Error('未找到 DSH。请先按原来的方式启动 DSH WebUI,再运行本命令。')
99
+ if (candidates.length === 1) return candidates[0].cli
100
+ // Multiple installed/cache versions are not evidence of the user's choice.
101
+ // A live host normally bypasses this fallback via its native handshake.
102
+ const prompt = options.prompt || (process.stdin.isTTY && process.stdout.isTTY ? async message => {
103
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
104
+ try { return await rl.question(message) } finally { rl.close() }
105
+ } : undefined)
106
+ if (!prompt) throw new Error('找到多份 DSH。请先启动要使用的 DSH,或用 --dsh-cli 指定其 lib/bin.js;未修改插件。')
107
+ const list = candidates.map((item, index) => `${index + 1}. DSH ${clean(item.version)} — ${clean(item.cli)}`).join('\n')
108
+ const answer = String(await prompt(`找到多份 DSH,请选择要使用的一份(直接回车取消):\n${list}\n编号:`)).trim()
109
+ const index = Number(answer) - 1
110
+ if (!/^\d+$/.test(answer) || !Number.isInteger(index) || !candidates[index]) throw new Error('已取消安装,未修改插件。')
111
+ return candidates[index].cli
112
+ }
113
+
114
+ // POSIX npm/npx normally starts through a bin symlink, not lib/bin.js.
115
+ // This is a conservative stop signal only; target authority is the handshake.
116
+ export function looksLikeDshProcess(command) {
117
+ return /(?:[/\\](?:dsh|apps[/\\]cli)[/\\]lib[/\\]bin\.js|[/\\](?:\.bin|bin)[/\\]dsh)(?:["'\s]|$)/i.test(command || '')
118
+ }
119
+
120
+ /** Conservatively prevent a second host while a live profile reloads on a
121
+ * non-default port. We use process listings only as a stop signal, never as
122
+ * authority to install, stop a PID, or select another user's data directory. */
123
+ export async function mayHaveRunningDsh() {
124
+ try {
125
+ if (process.platform === 'win32') {
126
+ const command = "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='node'\" | Select-Object -ExpandProperty CommandLine | ConvertTo-Json -Compress"
127
+ const { stdout } = await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
128
+ windowsHide: true, timeout: 10000, maxBuffer: 1024 * 1024,
129
+ })
130
+ const data = stdout.trim() ? JSON.parse(stdout) : []
131
+ return [data].flat().some(looksLikeDshProcess)
132
+ }
133
+ if (process.platform === 'linux') {
134
+ for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) {
135
+ try {
136
+ const args = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')
137
+ if (args.some(looksLikeDshProcess)) return true
138
+ } catch { /* Exited or inaccessible process. */ }
139
+ }
140
+ return false
141
+ }
142
+ const { stdout } = await execute('/bin/ps', ['-axo', 'command='], { timeout: 10000, maxBuffer: 2 * 1024 * 1024 })
143
+ return looksLikeDshProcess(stdout)
144
+ } catch { return true } // Unknown process ownership must never authorize an extra host.
145
+ }
146
+
147
+ export function assertInstallTarget(host, { home, profile, cli, pid }) {
148
+ if (host.home !== home || host.profile !== profile || !Number.isInteger(host.pid) || host.pid < 1
149
+ || host.pid !== pid || (cli && host.cli !== validateDshCli(cli).cli)
150
+ || host.cli !== validateDshCli(host.cli).cli) {
151
+ throw new Error('当前 DSH 身份与安装目标不一致,未修改安装。')
152
+ }
153
+ }
154
+
155
+ /** Preserve arguments but use the validated real entry point for restart.
156
+ * On macOS/Linux argv[0] can be npm's .bin/dsh symlink while host.cli is its
157
+ * canonical target. The worker requires a canonical CLI, as the WebUI updater
158
+ * already does. Never accept a different executable hidden in argv. */
159
+ export function installHostArgv(host) {
160
+ if (!Array.isArray(host.argv) || !host.argv.length || !host.argv.every(arg => typeof arg === 'string')
161
+ || validateDshCli(host.argv[0]).cli !== host.cli) {
162
+ throw new Error('DSH 启动参数与安装目标不一致,未修改安装。')
163
+ }
164
+ return [host.cli, ...host.argv.slice(1)]
165
+ }
@@ -51,14 +51,16 @@ export async function waitForJson(filename, accept, timeout = 20000) {
51
51
  try { const value = JSON.parse(fs.readFileSync(filename, 'utf8')); if (accept(value)) return value } catch {}
52
52
  await new Promise(resolve => setTimeout(resolve, 100))
53
53
  }
54
- throw new Error('DSH 没有完成安装握手;原插件未替换。请确认 DSH WebUI 正在运行且允许原生插件热加载。')
54
+ const error = new Error('DSH 没有完成安装握手;原插件未替换。请确认 DSH WebUI 正在运行且允许原生插件热加载。')
55
+ error.code = 'DSH_HANDSHAKE_TIMEOUT'
56
+ throw error
55
57
  }
56
58
 
57
59
  /** One bounded native handshake, including a possible offline host start.
58
60
  * A listening WebUI is not proof that the asynchronous profile reload finished.
59
61
  * Keep the same control entry/token while waiting; never restart a live host. */
60
62
  export async function waitForInstallControl(filename, {
61
- accept, ensureRunning, onWaiting = () => {}, timeoutMs = 180000, initialWaitMs = 5000,
63
+ accept, ensureRunning, onWaiting = () => {}, timeoutMs = 180000, initialWaitMs = 5000,
62
64
  }) {
63
65
  const deadline = Date.now() + timeoutMs
64
66
  try { return await waitForJson(filename, accept, Math.min(initialWaitMs, timeoutMs)) }
@@ -1,33 +1,36 @@
1
- import { validateCatalog, compareVersions, trustedReleaseAsset } from '../lib/update-policy.js'
2
-
3
- /** Explicit CLI installation is not an automatic-update recommendation.
4
- * Tested DSH/platform/architecture lists describe evidence, not admission.
5
- * Keep trusted artifacts, explicit withdrawals and the no-downgrade floor.
6
- */
7
- export function selectInstallTarget(pinned, remote, current, now = Date.now()) {
8
- const local = validateCatalog(pinned.catalog)
9
- const bundled = local.releases.find(r => r.version === pinned.version && r.channel === 'stable')
10
- if (!bundled || !trustedReleaseAsset(bundled.asset, bundled.version)) {
11
- throw new Error('安装包信息不完整,请重新获取官方安装器。')
12
- }
13
- let online
14
- try {
15
- const candidate = validateCatalog(remote)
16
- if (candidate.issuedAt <= now + 300000 && candidate.expiresAt > now) online = candidate
17
- } catch { /* The authenticated npm package remains usable without the catalog. */ }
18
-
19
- // An installer never silently substitutes an older plugin than its own bundle.
20
- // Keep the bundled bytes/hash for the same version even if remote metadata differs.
21
- const candidates = [bundled, ...(online?.releases || []).filter(r => r.channel === 'stable'
22
- && compareVersions(r.version, bundled.version) > 0 && trustedReleaseAsset(r.asset, r.version))]
23
- .sort((a, b) => compareVersions(b.version, a.version))
24
- const rules = [...local.blocked, ...(online?.blocked || [])]
25
- const blocked = release => rules.find(b => b.pluginVersion === release.version
26
- && (!b.dsh || b.dsh.includes(current.agentVersion))
27
- && (!b.platforms || b.platforms.includes(current.platform)))
28
- const release = candidates.find(r => !blocked(r))
29
- if (!release) {
30
- throw new Error(`当前插件存在已知不兼容问题:${blocked(bundled).reason};未替换现有插件。`)
31
- }
32
- return release
33
- }
1
+ import { validateCatalog, compareVersions, trustedReleaseAsset } from '../lib/update-policy.js'
2
+
3
+ /** Explicit CLI installation is not an automatic-update recommendation.
4
+ * Tested DSH/platform/architecture lists describe evidence, not admission.
5
+ * Keep trusted artifacts, explicit withdrawals and the no-downgrade floor.
6
+ */
7
+ export function selectInstallTarget(pinned, remote, current, now = Date.now()) {
8
+ const local = validateCatalog(pinned.catalog)
9
+ // Choosing an explicit prerelease installer authorizes its exact embedded
10
+ // preview, not arbitrary remote previews or a change to stable recommendations.
11
+ const channel = pinned.version.includes('-') ? 'preview' : 'stable'
12
+ const bundled = local.releases.find(r => r.version === pinned.version && r.channel === channel)
13
+ if (!bundled || !trustedReleaseAsset(bundled.asset, bundled.version)) {
14
+ throw new Error('安装包信息不完整,请重新获取官方安装器。')
15
+ }
16
+ let online
17
+ try {
18
+ const candidate = validateCatalog(remote)
19
+ if (candidate.issuedAt <= now + 300000 && candidate.expiresAt > now) online = candidate
20
+ } catch { /* The authenticated npm package remains usable without the catalog. */ }
21
+
22
+ // An installer never silently substitutes an older plugin than its own bundle.
23
+ // Keep the bundled bytes/hash for the same version even if remote metadata differs.
24
+ const candidates = [bundled, ...(online?.releases || []).filter(r => r.channel === 'stable'
25
+ && compareVersions(r.version, bundled.version) > 0 && trustedReleaseAsset(r.asset, r.version))]
26
+ .sort((a, b) => compareVersions(b.version, a.version))
27
+ const rules = [...local.blocked, ...(online?.blocked || [])]
28
+ const blocked = release => rules.find(b => b.pluginVersion === release.version
29
+ && (!b.dsh || b.dsh.includes(current.agentVersion))
30
+ && (!b.platforms || b.platforms.includes(current.platform)))
31
+ const release = candidates.find(r => !blocked(r))
32
+ if (!release) {
33
+ throw new Error(`当前插件存在已知不兼容问题:${blocked(bundled).reason};未替换现有插件。`)
34
+ }
35
+ return release
36
+ }
package/bin/setup.mjs CHANGED
@@ -1,37 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
- import os from 'node:os'
5
4
  import net from 'node:net'
6
5
  import { spawn } from 'node:child_process'
7
6
  import { randomBytes, createHash } from 'node:crypto'
8
7
  import { fileURLToPath } from 'node:url'
9
8
  import { attachControl, waitForJson, waitForInstallControl } from './native-control.mjs'
10
9
  import { resolveInstallRuntime, verifyInstallRuntime } from '../lib/install-runtime.js'
11
- import { compareVersions } from '../lib/update-policy.js'
12
- import { selectInstallTarget } from './release-selection.mjs'
10
+ import { compareVersions } from '../lib/update-policy.js'
11
+ import { selectInstallTarget } from './release-selection.mjs'
13
12
  import { boundedFetch, downloadRelease, auditArchive } from '../lib/update-download.js'
14
13
  import { writePrivateJsonAtomic } from '../lib/secure-file.js'
15
14
  import { control, validateJob, releaseOwnedUpdateLock } from '../lib/update-worker.js'
15
+ import { chooseDsh, validateDshCli, resolveHome, mayHaveRunningDsh, assertInstallTarget, installHostArgv } from './dsh-discovery.mjs'
16
16
 
17
17
  const root = fileURLToPath(new URL('../', import.meta.url))
18
18
  const packageName = '@harness-remote/dsh-wechat-remote'
19
19
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
20
- export function findDsh() {
21
- const candidates = []
22
- for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
23
- candidates.push(path.join(dir, 'node_modules/@deepseek-ai/dsh/lib/bin.js'), path.join(dir, '../lib/node_modules/@deepseek-ai/dsh/lib/bin.js'))
24
- try { candidates.push(fs.realpathSync(path.join(dir, 'dsh'))) } catch {}
25
- }
26
- for (const candidate of candidates) {
27
- try {
28
- const cli = fs.realpathSync(candidate)
29
- const manifest = JSON.parse(fs.readFileSync(path.resolve(cli, '../../package.json'), 'utf8'))
30
- if (manifest.name === '@deepseek-ai/dsh' && path.basename(cli) === 'bin.js') return cli
31
- } catch {}
32
- }
33
- throw new Error('未找到已安装的 DSH。请先安装并启动 DeepSeek Harness。')
34
- }
35
20
  function portBusy(port) {
36
21
  return new Promise(resolve => {
37
22
  const socket = net.connect({ host: '127.0.0.1', port })
@@ -39,16 +24,16 @@ function portBusy(port) {
39
24
  socket.setTimeout(500, () => end(false)); socket.once('connect', () => end(true)); socket.once('error', () => end(false))
40
25
  })
41
26
  }
42
- export async function selectRelease(host, assetsRoot = path.join(root, 'assets'), repair = false,
43
- fetchCatalog = () => boundedFetch('https://relay.xyxfood.xyz/v1/update-policy', 256 * 1024)) {
27
+ export async function selectRelease(host, assetsRoot = path.join(root, 'assets'), repair = false,
28
+ fetchCatalog = () => boundedFetch('https://relay.xyxfood.xyz/v1/update-policy', 256 * 1024)) {
44
29
  const current = { agentKind: 'dsh', agentVersion: host.dshVersion, pluginVersion: host.pluginVersion,
45
30
  platform: { win32: 'windows', darwin: 'macos', linux: 'linux' }[host.platform], arch: host.arch }
46
31
  const pinned = JSON.parse(fs.readFileSync(path.join(assetsRoot, 'release.json'), 'utf8'))
47
- let remote
48
- try {
49
- remote = JSON.parse((await fetchCatalog()).toString('utf8'))
50
- } catch { /* Authenticated npm package retains its bundled release. */ }
51
- const release = selectInstallTarget(pinned, remote, current)
32
+ let remote
33
+ try {
34
+ remote = JSON.parse((await fetchCatalog()).toString('utf8'))
35
+ } catch { /* Authenticated npm package retains its bundled release. */ }
36
+ const release = selectInstallTarget(pinned, remote, current)
52
37
  if (host.pluginVersion !== '0.0.0' && (compareVersions(host.pluginVersion, release.version) > 0
53
38
  || (compareVersions(host.pluginVersion, release.version) === 0 && !repair))) return { release, archive: null }
54
39
  let archive
@@ -57,10 +42,11 @@ export async function selectRelease(host, assetsRoot = path.join(root, 'assets')
57
42
  auditArchive(archive, release)
58
43
  return { release, archive }
59
44
  }
60
- export async function install({ profileName = 'web', cli = findDsh(), assetsRoot, open = true, repair = false } = {}) {
45
+ export async function install({ profileName = 'web', cli, home: configuredHome, assetsRoot, open = true, repair = false } = {}) {
61
46
  if (!/^[A-Za-z0-9_-]{1,80}$/.test(profileName)) throw new Error('无效的 profile 名称。')
47
+ if (cli) cli = validateDshCli(cli).cli
62
48
  const runtime = resolveInstallRuntime(root); await verifyInstallRuntime(runtime)
63
- const home = path.resolve(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'))
49
+ const home = resolveHome(configuredHome || process.env.DSH_HOME)
64
50
  fs.mkdirSync(home, { recursive: true, mode: 0o700 })
65
51
  if (fs.realpathSync(home) !== home) throw new Error('DSH 数据目录是链接,暂不自动替换安装。')
66
52
  const profile = path.join(home, 'profiles', profileName), id = randomBytes(16).toString('hex')
@@ -72,15 +58,17 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
72
58
  try {
73
59
  // Initialize missing profiles through DSH itself, never synthesize a bundle list.
74
60
  if (!fs.existsSync(path.join(profile, 'package.json'))) {
61
+ if (await mayHaveRunningDsh()) throw new Error('当前目录下没有此 DSH 配置,但检测到 DSH 正在运行。请使用相同的 DSH_HOME/--home 和 --profile,未修改正在运行的实例。')
62
+ cli ||= await chooseDsh()
75
63
  const log = fs.openSync(path.join(directory, 'startup.log'), 'a', 0o600)
76
64
  launched = spawn(process.execPath, [cli, '--profile', profileName, '--no-open'], {
77
- cwd: process.cwd(), env: process.env, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
65
+ cwd: process.cwd(), env: { ...process.env, DSH_HOME: home }, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
78
66
  fs.closeSync(log); launched.on('error', () => {}); launched.unref()
79
- const initializedBy = Date.now() + 180000
80
- while (!fs.existsSync(path.join(profile, 'package.json')) && Date.now() < initializedBy) {
81
- if (launched.exitCode !== null || launched.signalCode !== null) break
82
- await sleep(100)
83
- }
67
+ const initializedBy = Date.now() + 180000
68
+ while (!fs.existsSync(path.join(profile, 'package.json')) && Date.now() < initializedBy) {
69
+ if (launched.exitCode !== null || launched.signalCode !== null) break
70
+ await sleep(100)
71
+ }
84
72
  if (!fs.existsSync(path.join(profile, 'package.json'))) throw new Error('DSH 未能初始化 profile;详情见本机安装记录。')
85
73
  await sleep(2000)
86
74
  }
@@ -95,17 +83,23 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
95
83
  accept: v => Number.isInteger(v.pid) && v.pid > 0 && (!launched || v.pid === launched.pid),
96
84
  onWaiting: () => console.log('正在等待 DSH 完成安装准备,请勿重复执行命令…'),
97
85
  ensureRunning: async () => {
98
- if (launched || await portBusy(Number(process.env.DSH_PORT || 3080))) return
86
+ if (launched || await portBusy(Number(process.env.DSH_PORT || 3080)) || await mayHaveRunningDsh()) return
87
+ cli ||= await chooseDsh()
99
88
  const log = fs.openSync(path.join(directory, 'startup.log'), 'a', 0o600)
100
89
  launched = spawn(process.execPath, [cli, '--profile', profileName, '--no-open'], {
101
- cwd: process.cwd(), env: process.env, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
90
+ cwd: process.cwd(), env: { ...process.env, DSH_HOME: home }, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
102
91
  fs.closeSync(log); launched.on('error', () => {}); launched.unref()
103
92
  },
104
93
  })
94
+ if (!/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(ref.origin) || Number(new URL(ref.origin).port) > 65535) {
95
+ ref = undefined
96
+ throw new Error('DSH 安装握手地址无效,未修改插件。')
97
+ }
105
98
  writePrivateJsonAtomic(path.join(directory, 'handshake.json'), {
106
99
  state: 'ready', elapsedMs: Date.now() - handshakeStarted, startedHost: Boolean(launched),
107
100
  })
108
- } catch {
101
+ } catch (error) {
102
+ if (error.code !== 'DSH_HANDSHAKE_TIMEOUT') throw error
109
103
  writePrivateJsonAtomic(path.join(directory, 'handshake.json'), {
110
104
  state: 'timeout', elapsedMs: Date.now() - handshakeStarted, startedHost: Boolean(launched),
111
105
  })
@@ -113,13 +107,16 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
113
107
  }
114
108
  job = { controlOrigin: ref.origin, statusToken: token }
115
109
  const host = await control(job, 'describe')
116
- if (host.home !== home || host.profile !== profile || host.cli !== fs.realpathSync(cli) || (launched && host.pid !== launched.pid)) throw new Error('当前 DSH 身份与安装目标不一致,未修改安装。')
110
+ // The authenticated live host is authoritative. A global install or cached
111
+ // version found on PATH must not override the actual npx/local/source host.
112
+ // Explicit choices and installer-started hosts still require an exact match.
113
+ assertInstallTarget(host, { home, profile, cli, pid: launched?.pid || ref.pid })
117
114
  remove(); remove = undefined
118
115
  await sleep(600)
119
116
  const selected = await selectRelease(host, assetsRoot, repair)
120
117
  if (!selected.archive) { console.log(`插件 ${host.pluginVersion} 无需更新。`); return { version: host.pluginVersion, changed: false } }
121
118
  console.log(`正在安装插件 ${selected.release.version},保留原配对与会话…`)
122
- job = { ...host, id, directory, parentPid: host.pid, pnpm: runtime.cli, targetVersion: selected.release.version,
119
+ job = { ...host, argv: installHostArgv(host), id, directory, parentPid: host.pid, pnpm: runtime.cli, targetVersion: selected.release.version,
123
120
  previousVersion: host.pluginVersion, statusToken: token, controlOrigin: ref.origin }
124
121
  validateJob(job)
125
122
  fs.writeFileSync(path.join(directory, 'release.tgz'), selected.archive, { mode: 0o600, flag: 'wx' })
@@ -156,10 +153,21 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
156
153
  if (locked && !authorized) releaseOwnedUpdateLock(lockFile, id)
157
154
  }
158
155
  }
156
+ export function parseArguments(args) {
157
+ const options = {}, seen = new Set()
158
+ const names = { '--profile': 'profileName', '--dsh-cli': 'cli', '--home': 'home' }
159
+ for (let i = 0; i < args.length; i++) {
160
+ const flag = args[i]
161
+ if (seen.has(flag)) throw new Error('参数重复。使用 --help 查看用法。')
162
+ seen.add(flag)
163
+ if (flag === '--repair') options.repair = true
164
+ else if (names[flag] && args[i + 1] && !args[i + 1].startsWith('--')) options[names[flag]] = args[++i]
165
+ else throw new Error('不支持的参数。使用 --help 查看用法。')
166
+ }
167
+ return options
168
+ }
159
169
  if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
160
- const args = process.argv.slice(2), repair = args.includes('--repair')
161
- if (repair) args.splice(args.indexOf('--repair'), 1)
162
- if (args.includes('--help') || args.includes('-h')) console.log('安装或升级 DSH 微信连接插件:npx -y dsh-wechat-remote@latest\n可选:--profile <名称>(默认 web);--repair(重新安装当前兼容版本,不降级)')
163
- else if (args.length && !(args.length === 2 && args[0] === '--profile')) { console.error('不支持的参数。使用 --help 查看用法。'); process.exitCode = 1 }
164
- else install({ profileName: args[1] || 'web', repair }).catch(error => { console.error(error.message); process.exitCode = 1 })
170
+ const args = process.argv.slice(2)
171
+ if (args.includes('--help') || args.includes('-h')) console.log('安装或升级 DSH 微信连接插件:npx -y dsh-wechat-remote@latest\n请先按原来的方式启动 DSH WebUI,支持全局安装与 npx 启动。\n可选:--profile <名称>(默认 web);--home <DSH 数据目录>;--dsh-cli <DSH 的 lib/bin.js>;--repair(重新安装,不降级)')
172
+ else Promise.resolve().then(() => install(parseArguments(args))).catch(error => { console.error(error.message); process.exitCode = 1 })
165
173
  }
@@ -41,8 +41,17 @@ function validateCatalog(value) {
41
41
  if (!c || c.schemaVersion !== 1 || typeof c.revision !== "string" || !/^[\w.-]{1,80}$/.test(c.revision) || !Number.isSafeInteger(c.issuedAt) || !Number.isSafeInteger(c.expiresAt) || c.expiresAt <= c.issuedAt || c.expiresAt - c.issuedAt > 32 * 864e5 || !Array.isArray(c.releases) || c.releases.length > 100 || !Array.isArray(c.blocked) || c.blocked.length > 100 || !strings(c.retiredDsh)) throw new Error("Invalid update catalog");
42
42
  const versions = /* @__PURE__ */ new Set();
43
43
  for (const r of c.releases) {
44
- if (!r || !validVersion(r.version) || versions.has(r.version) || !["stable", "preview"].includes(r.channel) || r.channel === "stable" && versionPattern.exec(r.version)[4] || !strings(r.dsh) || !r.dsh.length || !r.dsh.every(validVersion) || !strings(r.platforms, 3) || !r.platforms.length || !r.platforms.every((p) => ["windows", "macos", "linux"].includes(p)) || !strings(r.architectures, 4) || !r.architectures.length || !r.architectures.every((a) => ["x64", "arm64"].includes(a)) || r.asset && !trustedReleaseAsset(r.asset, r.version)) throw new Error("Invalid release entry");
44
+ if (!r || !validVersion(r.version) || versions.has(r.version) || !["stable", "preview"].includes(r.channel) || r.channel === "stable" && versionPattern.exec(r.version)[4] || !strings(r.dsh) || !r.dsh.length || !r.dsh.every(validVersion) || !strings(r.platforms, 3) || !r.platforms.length || !r.platforms.every((p) => ["windows", "macos", "linux"].includes(p)) || !strings(r.architectures, 32) || !r.architectures.length || !r.architectures.every((a) => /^[a-z0-9_-]{1,32}$/.test(a)) || r.asset && !trustedReleaseAsset(r.asset, r.version)) throw new Error("Invalid release entry");
45
45
  versions.add(r.version);
46
+ if (r.targets !== void 0) {
47
+ if (!Array.isArray(r.targets) || !r.targets.length || r.targets.length > 12) throw new Error("Invalid release targets");
48
+ const seen = /* @__PURE__ */ new Set();
49
+ for (const target of r.targets) {
50
+ const key = `${target?.platform}:${target?.arch}`;
51
+ if (!target || !r.platforms.includes(target.platform) || !r.architectures.includes(target.arch) || !strings(target.dsh) || !target.dsh.length || !target.dsh.every((v) => r.dsh.includes(v)) || seen.has(key)) throw new Error("Invalid release target");
52
+ seen.add(key);
53
+ }
54
+ }
46
55
  }
47
56
  for (const b of c.blocked) if (!b || !validVersion(b.pluginVersion) || typeof b.reason !== "string" || !b.reason || b.reason.length > 240 || b.dsh && !strings(b.dsh) || b.platforms && !strings(b.platforms, 3)) throw new Error("Invalid blocked entry");
48
57
  if (!c.retiredDsh.every(validVersion)) throw new Error("Invalid retired DSH");
@@ -50,7 +59,7 @@ function validateCatalog(value) {
50
59
  return c;
51
60
  }
52
61
  function releaseMatches(r, current) {
53
- return r.dsh.includes(current.agentVersion) && r.platforms.includes(current.platform) && (!current.arch || r.architectures.includes(current.arch));
62
+ return r.dsh.includes(current.agentVersion) && r.platforms.includes(current.platform) && Boolean(current.arch && r.architectures.includes(current.arch)) && (!r.targets || r.targets.some((t) => t.platform === current.platform && (!current.arch || t.arch === current.arch) && t.dsh.includes(current.agentVersion)));
54
63
  }
55
64
  function assessUpdate(raw, current, now = Date.now(), preview = false) {
56
65
  const base = {
@@ -79,23 +88,21 @@ function assessUpdate(raw, current, now = Date.now(), preview = false) {
79
88
  const blocked = (version) => c.blocked.find((b) => b.pluginVersion === version && (!b.dsh || b.dsh.includes(current.agentVersion)) && (!b.platforms || b.platforms.includes(current.platform)));
80
89
  const own = c.releases.find((r) => r.version === current.pluginVersion);
81
90
  const compatible = Boolean(current.arch && own && releaseMatches(own, current) && !blocked(own.version));
82
- const target = c.releases.filter((r) => (r.channel === "stable" || preview) && releaseMatches(r, current) && !blocked(r.version) && compareVersions(r.version, current.pluginVersion) > 0).sort((a, b) => compareVersions(b.version, a.version))[0];
91
+ if (c.retiredDsh.includes(current.agentVersion)) return {
92
+ ...base,
93
+ severity: "required",
94
+ component: "agent",
95
+ code: "agent-retired",
96
+ label: "\u9700\u8981\u66F4\u65B0 DSH",
97
+ message: "\u6B64 DSH \u7248\u672C\u5DF2\u505C\u6B62\u652F\u6301\u3002\u8BF7\u5728\u4E3B\u673A\u4E0A\u6309\u53D1\u5E03\u8BF4\u660E\u5347\u7EA7 DSH\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u4FEE\u6539 DSH\u3002",
98
+ releaseUrl: `${RELEASE_REPOSITORY}/releases`
99
+ };
100
+ const target = c.releases.filter((r) => (r.channel === "stable" || preview) && !blocked(r.version) && compareVersions(r.version, current.pluginVersion) > 0).sort((a, b) => compareVersions(b.version, a.version))[0];
83
101
  const issue = blocked(current.pluginVersion);
84
102
  if (target) {
85
103
  const manual = c.manualUpgradePlugins?.includes(current.pluginVersion) ? {
86
104
  manualUpdate: `\u6B64\u65E7\u63D2\u4EF6\u6CA1\u6709\u68C0\u67E5\u66F4\u65B0\u548C\u4E00\u952E\u66F4\u65B0\u529F\u80FD\u3002\u9996\u6B21\u9700\u5728\u8282\u70B9\u6240\u5728\u7535\u8111\uFF0C\u6309\u76EE\u6807\u7248\u672C ${target.version} \u7684\u53D1\u5E03\u8BF4\u660E\u624B\u5DE5\u5347\u7EA7\u63D2\u4EF6\uFF0C\u5E76\u4F7F\u7528\u539F\u6765\u7684 DSH_HOME \u548C profile \u91CD\u542F\u3002\u4E0D\u8981\u5220\u9664\u8282\u70B9\u6216\u91CD\u65B0\u914D\u5BF9\uFF1B\u5347\u7EA7\u540E\u624D\u53EF\u4F7F\u7528\u4E00\u952E\u66F4\u65B0\u3002`
87
105
  } : {};
88
- if (!current.arch) return {
89
- ...base,
90
- ...manual,
91
- severity: "recommended",
92
- component: "plugin",
93
- code: "plugin-check-host",
94
- label: "\u63D2\u4EF6\u53EF\u66F4\u65B0",
95
- message: `\u63D2\u4EF6 ${target.version} \u7684\u7248\u672C\u6761\u4EF6\u4E0E\u5F53\u524D DSH \u5339\u914D\uFF0C\u4F46\u65E7\u8282\u70B9\u672A\u4E0A\u62A5\u4E3B\u673A\u67B6\u6784\u3002\u8BF7\u5230\u6B64\u4E3B\u673A WebUI \u68C0\u67E5\u786E\u8BA4\u540E\u66F4\u65B0\u63D2\u4EF6\uFF1B\u65E0\u9700\u5148\u66F4\u65B0 DSH\u3002`,
96
- targetVersion: target.version,
97
- releaseUrl: `${RELEASE_REPOSITORY}/releases/tag/v${target.version}`
98
- };
99
106
  const required = Boolean(issue);
100
107
  return {
101
108
  ...base,
@@ -104,7 +111,7 @@ function assessUpdate(raw, current, now = Date.now(), preview = false) {
104
111
  component: "plugin",
105
112
  code: required ? "plugin-required" : "plugin-available",
106
113
  label: required ? "\u5FC5\u987B\u66F4\u65B0\u63D2\u4EF6" : "\u63D2\u4EF6\u53EF\u66F4\u65B0",
107
- message: (issue?.reason || (required ? "\u5F53\u524D\u63D2\u4EF6\u4E0D\u5728\u6B64 DSH / \u4E3B\u673A\u7EC4\u5408\u7684\u652F\u6301\u8303\u56F4\u5185\u3002" : compatible ? "\u5F53\u524D\u7EC4\u5408\u53D7\u652F\u6301\u3002" : "\u5F53\u524D\u63D2\u4EF6\u7EC4\u5408\u5C1A\u672A\u9A8C\u8BC1\u3002")) + `\u63D2\u4EF6 ${target.version} \u5DF2\u652F\u6301\u5F53\u524D DSH\uFF1B\u4EC5\u66F4\u65B0\u63D2\u4EF6\uFF0C\u65E0\u9700\u66F4\u65B0 DSH\u3002`,
114
+ message: (issue ? issue.reason + "\u3002" : "") + `\u53EF\u66F4\u65B0\u81F3\u63D2\u4EF6 ${target.version}\uFF1B\u66F4\u65B0\u524D\u4F1A\u68C0\u67E5\u5F53\u524D\u4E3B\u673A\u7684\u5B89\u88C5\u4E0E\u91CD\u542F\u6761\u4EF6\uFF0C\u4E0D\u4F1A\u66F4\u6362 DSH \u672C\u4F53\u3002`,
108
115
  targetVersion: target.version,
109
116
  releaseUrl: `${RELEASE_REPOSITORY}/releases/tag/v${target.version}`
110
117
  };
@@ -119,20 +126,12 @@ function assessUpdate(raw, current, now = Date.now(), preview = false) {
119
126
  releaseUrl: `${RELEASE_REPOSITORY}/releases`
120
127
  };
121
128
  if (compatible) return { ...base, severity: "none", code: "compatible", label: "\u6682\u65E0\u53EF\u7528\u66F4\u65B0", message: "\u5F53\u524D\u7EC4\u5408\u5DF2\u9A8C\u8BC1\uFF0C\u6682\u672A\u53D1\u73B0\u9002\u7528\u4E8E\u6B64 DSH \u548C\u4E3B\u673A\u7684\u66F4\u65B0\u3002" };
122
- if (c.retiredDsh.includes(current.agentVersion)) return {
123
- ...base,
124
- severity: "required",
125
- component: "agent",
126
- code: "agent-retired",
127
- label: "\u9700\u8981\u66F4\u65B0 DSH",
128
- message: "\u6B64 DSH \u7248\u672C\u5DF2\u505C\u6B62\u652F\u6301\u3002\u8BF7\u5728\u4E3B\u673A\u4E0A\u6309\u517C\u5BB9\u53D1\u5E03\u8BF4\u660E\u5347\u7EA7 DSH \u548C\u8FDE\u63A5\u63D2\u4EF6\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u4FEE\u6539 DSH\u3002",
129
- releaseUrl: `${RELEASE_REPOSITORY}/releases`
130
- };
131
129
  return {
132
130
  ...base,
133
- code: "unverified",
134
- label: "\u517C\u5BB9\u6027\u5F85\u786E\u8BA4",
135
- message: "\u6CA1\u6709\u8DB3\u591F\u7684\u517C\u5BB9\u8BC1\u636E\u6216\u5339\u914D\u7684\u6B63\u5F0F\u66F4\u65B0\u3002\u8FD9\u4E0D\u7B49\u4E8E\u8282\u70B9\u79BB\u7EBF\uFF0C\u4E5F\u4E0D\u8868\u793A\u5FC5\u987B\u5347\u7EA7\u6216\u964D\u7EA7 DSH\u3002",
131
+ severity: "none",
132
+ code: "no-update",
133
+ label: "\u6682\u65E0\u53EF\u7528\u66F4\u65B0",
134
+ message: "\u5F53\u524D\u901A\u9053\u6682\u65E0\u66F4\u9AD8\u7248\u672C\uFF0C\u4E0D\u5F71\u54CD\u7EE7\u7EED\u4F7F\u7528\u3002",
136
135
  releaseUrl: `${RELEASE_REPOSITORY}/releases`
137
136
  };
138
137
  }
@@ -416,7 +416,7 @@ async function stopOriginal(job) {
416
416
  }
417
417
  throw new Error("\u539F DSH \u5C1A\u672A\u7ED3\u675F\uFF0C\u672A\u66FF\u6362\u63D2\u4EF6");
418
418
  }
419
- async function stopRestarted(child, timeoutMs = 3e4) {
419
+ async function stopRestarted(child, timeoutMs = 3e4, forceTimeoutMs = 5e3) {
420
420
  if (child.exitCode !== null || child.signalCode !== null) return;
421
421
  child.kill("SIGTERM");
422
422
  const deadline = Date.now() + timeoutMs;
@@ -425,8 +425,54 @@ async function stopRestarted(child, timeoutMs = 3e4) {
425
425
  await wait(Math.min(100, Math.max(1, deadline - Date.now())));
426
426
  }
427
427
  if (child.exitCode !== null || child.signalCode !== null) return;
428
+ child.kill("SIGKILL");
429
+ const forcedDeadline = Date.now() + forceTimeoutMs;
430
+ while (Date.now() < forcedDeadline) {
431
+ if (child.exitCode !== null || child.signalCode !== null) return;
432
+ await wait(Math.min(100, Math.max(1, forcedDeadline - Date.now())));
433
+ }
434
+ if (child.exitCode !== null || child.signalCode !== null) return;
428
435
  throw new Error("\u66F4\u65B0\u540E\u7684 DSH \u672A\u6309\u65F6\u505C\u6B62");
429
436
  }
437
+ function captureCandidateLock(home, child) {
438
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
439
+ const filename = path3.join(home, ".credentials.yaml.lock");
440
+ try {
441
+ const info = fs3.lstatSync(filename);
442
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 32) return;
443
+ const value = fs3.readFileSync(filename, "utf8");
444
+ let owned = value === `${child.pid}
445
+ `;
446
+ if (!owned && value === "" && process.platform === "linux") {
447
+ const directory = `/proc/${child.pid}/fd`;
448
+ owned = fs3.readdirSync(directory).some((fd) => {
449
+ try {
450
+ const file = path3.join(directory, fd);
451
+ if (fs3.readlinkSync(file) !== filename) return false;
452
+ const opened = fs3.statSync(file);
453
+ return opened.dev === info.dev && opened.ino === info.ino;
454
+ } catch {
455
+ return false;
456
+ }
457
+ });
458
+ }
459
+ if (owned) return { filename, dev: info.dev, ino: info.ino, pid: child.pid };
460
+ } catch {
461
+ }
462
+ }
463
+ function retireCandidateLock(lock, child, directory) {
464
+ if (!lock || child.pid !== lock.pid || child.exitCode === null && child.signalCode === null) return;
465
+ try {
466
+ const info = fs3.lstatSync(lock.filename);
467
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.dev !== lock.dev || info.ino !== lock.ino || info.size > 32) return;
468
+ const value = fs3.readFileSync(lock.filename, "utf8");
469
+ if (value !== "" && value !== `${lock.pid}
470
+ `) return;
471
+ const saved = path3.join(directory, "candidate-credentials-lock.before-rollback");
472
+ if (!fs3.existsSync(saved)) fs3.renameSync(lock.filename, saved);
473
+ } catch {
474
+ }
475
+ }
430
476
  async function stopChild(pid) {
431
477
  process.kill(pid, "SIGTERM");
432
478
  for (let i = 0; i < 100; i++) {
@@ -524,8 +570,11 @@ async function executeUpdate(job, progress, quiesce) {
524
570
  await stopOriginal(job);
525
571
  stopped = true;
526
572
  }
527
- if (newChild) await stopRestarted(newChild);
528
- else if (swapped && job.manager && job.manager.kind !== "process") stopManagedHost(job.manager);
573
+ if (newChild) {
574
+ const ownedLock = captureCandidateLock(job.home, newChild);
575
+ await stopRestarted(newChild);
576
+ retireCandidateLock(ownedLock, newChild, job.directory);
577
+ } else if (swapped && job.manager && job.manager.kind !== "process") stopManagedHost(job.manager);
529
578
  if (swapped) {
530
579
  fs3.renameSync(job.profile, path3.join(job.directory, "profile-failed"));
531
580
  fs3.renameSync(previous, job.profile);
@@ -679,11 +728,13 @@ if (process.argv[1] && path3.resolve(process.argv[1]) === fileURLToPath(import.m
679
728
  });
680
729
  }
681
730
  export {
731
+ captureCandidateLock,
682
732
  control,
683
733
  executeUpdate,
684
734
  healthy,
685
735
  migrateLegacyGrantOwner,
686
736
  releaseOwnedUpdateLock,
737
+ retireCandidateLock,
687
738
  stopRestarted,
688
739
  validateJob
689
740
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wechat-remote",
3
- "version": "1.7.5",
3
+ "version": "1.7.6",
4
4
  "private": false,
5
5
  "description": "Agent远程管理助手:DSH 微信连接插件的一键安装与升级",
6
6
  "type": "module",