dsh-wechat-remote 1.7.5 → 1.7.8

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/bin/setup.mjs CHANGED
@@ -1,37 +1,23 @@
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
- import { resolveInstallRuntime, verifyInstallRuntime } from '../lib/install-runtime.js'
11
- import { compareVersions } from '../lib/update-policy.js'
12
- import { selectInstallTarget } from './release-selection.mjs'
9
+ import { resolveInstallRuntime, verifyInstallRuntime } from '../lib/install-runtime.js'
10
+ import { installProfile, backupProfile, NativeInstallError } from '../lib/install-profile.js'
11
+ import { compareVersions } from '../lib/update-policy.js'
12
+ import { selectInstallTarget } from './release-selection.mjs'
13
13
  import { boundedFetch, downloadRelease, auditArchive } from '../lib/update-download.js'
14
14
  import { writePrivateJsonAtomic } from '../lib/secure-file.js'
15
15
  import { control, validateJob, releaseOwnedUpdateLock } from '../lib/update-worker.js'
16
+ import { chooseDsh, validateDshCli, resolveHome, mayHaveRunningDsh, assertInstallTarget, installHostArgv } from './dsh-discovery.mjs'
16
17
 
17
18
  const root = fileURLToPath(new URL('../', import.meta.url))
18
19
  const packageName = '@harness-remote/dsh-wechat-remote'
19
20
  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
21
  function portBusy(port) {
36
22
  return new Promise(resolve => {
37
23
  const socket = net.connect({ host: '127.0.0.1', port })
@@ -40,27 +26,76 @@ function portBusy(port) {
40
26
  })
41
27
  }
42
28
  export async function selectRelease(host, assetsRoot = path.join(root, 'assets'), repair = false,
43
- fetchCatalog = () => boundedFetch('https://relay.xyxfood.xyz/v1/update-policy', 256 * 1024)) {
29
+ fetchCatalog = () => boundedFetch('https://relay.xyxfood.xyz/v1/update-policy', 256 * 1024)) {
44
30
  const current = { agentKind: 'dsh', agentVersion: host.dshVersion, pluginVersion: host.pluginVersion,
45
31
  platform: { win32: 'windows', darwin: 'macos', linux: 'linux' }[host.platform], arch: host.arch }
46
32
  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)
33
+ let remote
34
+ try {
35
+ remote = JSON.parse((await fetchCatalog()).toString('utf8'))
36
+ } catch { /* Authenticated npm package retains its bundled release. */ }
37
+ const release = selectInstallTarget(pinned, remote, current)
52
38
  if (host.pluginVersion !== '0.0.0' && (compareVersions(host.pluginVersion, release.version) > 0
53
39
  || (compareVersions(host.pluginVersion, release.version) === 0 && !repair))) return { release, archive: null }
54
40
  let archive
55
41
  if (release.version === pinned.version) archive = fs.readFileSync(path.join(assetsRoot, 'plugin.tgz'))
56
42
  else archive = await downloadRelease(release)
57
43
  auditArchive(archive, release)
58
- return { release, archive }
59
- }
60
- export async function install({ profileName = 'web', cli = findDsh(), assetsRoot, open = true, repair = false } = {}) {
44
+ return { release, archive }
45
+ }
46
+
47
+ /** With no running host, native plugin add needs neither HMR nor healthy third-
48
+ * party plugins. Reuse the same installation core; only live hosts need disposal. */
49
+ async function installStopped({ cli, home, profile, profileName, directory, id, runtime, assetsRoot, repair, open }) {
50
+ const dsh = validateDshCli(cli)
51
+ let version = '0.0.0'
52
+ try { version = JSON.parse(fs.readFileSync(path.join(profile, 'node_modules', packageName, 'package.json'), 'utf8')).version } catch {}
53
+ const selected = await selectRelease({ dshVersion: dsh.version, pluginVersion: version, platform: process.platform, arch: process.arch }, assetsRoot, repair)
54
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 })
55
+ if (fs.realpathSync(profile) !== profile) throw new Error('无法确认 DSH 配置目录的实际位置。')
56
+ const lock = path.join(profile, '.harness-remote-update.lock')
57
+ const fd = fs.openSync(lock, 'wx', 0o600); fs.writeFileSync(fd, id); fs.closeSync(fd)
58
+ let modified = false, keepLock = false
59
+ try {
60
+ // Recheck immediately before writing: a host may have started during npm
61
+ // discovery/download. Never mutate its dependencies underneath that process.
62
+ if (await mayHaveRunningDsh()) throw new Error('DSH 已开始运行,请重新执行安装命令以连接该实例。')
63
+ if (selected.archive) {
64
+ fs.writeFileSync(path.join(directory, 'release.tgz'), selected.archive, { mode: 0o600, flag: 'wx' })
65
+ backupProfile(profile, path.join(directory, 'profile-before'))
66
+ console.log(`正在通过 DSH 原生方式安装插件 ${selected.release.version}…`)
67
+ modified = true
68
+ await installProfile({ profile, directory, cli: dsh.cli, targetVersion: selected.release.version, runtime })
69
+ }
70
+ } catch (error) {
71
+ keepLock = error instanceof NativeInstallError && error.mayStillBeRunning
72
+ if (modified && !keepLock) {
73
+ fs.renameSync(profile, path.join(directory, 'profile-failed'))
74
+ fs.renameSync(path.join(directory, 'profile-before'), profile)
75
+ }
76
+ throw error
77
+ } finally { if (!keepLock) releaseOwnedUpdateLock(lock, id) }
78
+ // Use DSH's own browser opening behavior. Startup failure does not undo an
79
+ // otherwise successful native add; an unrelated plugin can fail to boot.
80
+ const logFile = path.join(directory, 'startup.log'), log = fs.openSync(logFile, 'a', 0o600)
81
+ const env = { ...process.env, DSH_HOME: home }
82
+ delete env.HARNESS_REMOTE_UPDATE_JOB
83
+ const child = spawn(process.execPath, [dsh.cli, '--profile', profileName, ...(open ? [] : ['--no-open'])],
84
+ { cwd: process.cwd(), env, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
85
+ fs.closeSync(log)
86
+ let launchError
87
+ child.on('error', error => { launchError = error }); child.unref()
88
+ await sleep(1200)
89
+ const starting = !launchError && child.pid && child.exitCode === null && child.signalCode === null
90
+ const installedVersion = selected.archive ? selected.release.version : version
91
+ console.log(`插件 ${installedVersion} 已安装。${starting ? '已启动 DSH,请等待 WebUI 就绪。' : `DSH 未能启动,请查看:${logFile}`}`)
92
+ return { version: installedVersion, changed: Boolean(selected.archive), starting: Boolean(starting) }
93
+ }
94
+ export async function install({ profileName = 'web', cli, home: configuredHome, assetsRoot, open = true, repair = false } = {}) {
61
95
  if (!/^[A-Za-z0-9_-]{1,80}$/.test(profileName)) throw new Error('无效的 profile 名称。')
96
+ if (cli) cli = validateDshCli(cli).cli
62
97
  const runtime = resolveInstallRuntime(root); await verifyInstallRuntime(runtime)
63
- const home = path.resolve(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'))
98
+ const home = resolveHome(configuredHome || process.env.DSH_HOME)
64
99
  fs.mkdirSync(home, { recursive: true, mode: 0o700 })
65
100
  if (fs.realpathSync(home) !== home) throw new Error('DSH 数据目录是链接,暂不自动替换安装。')
66
101
  const profile = path.join(home, 'profiles', profileName), id = randomBytes(16).toString('hex')
@@ -68,21 +103,16 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
68
103
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
69
104
  let remove, ref, job, locked = false, authorized = false, launched
70
105
  const lockFile = path.join(profile, '.harness-remote-update.lock')
71
- console.log('正在检查 DSH 与插件…')
72
- try {
73
- // Initialize missing profiles through DSH itself, never synthesize a bundle list.
74
- if (!fs.existsSync(path.join(profile, 'package.json'))) {
75
- const log = fs.openSync(path.join(directory, 'startup.log'), 'a', 0o600)
76
- launched = spawn(process.execPath, [cli, '--profile', profileName, '--no-open'], {
77
- cwd: process.cwd(), env: process.env, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
78
- 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
- }
84
- if (!fs.existsSync(path.join(profile, 'package.json'))) throw new Error('DSH 未能初始化 profile;详情见本机安装记录。')
85
- await sleep(2000)
106
+ console.log('正在检查 DSH 与插件…')
107
+ try {
108
+ if (!await mayHaveRunningDsh()) {
109
+ cli ||= await chooseDsh()
110
+ return await installStopped({ cli, home, profile, profileName, directory, id, runtime, assetsRoot, repair, open })
111
+ }
112
+ // A live host must identify the same home/profile; never synthesize a second
113
+ // profile just because this terminal inherited different environment values.
114
+ if (!fs.existsSync(path.join(profile, 'package.json'))) {
115
+ throw new Error('当前目录下没有此 DSH 配置,但检测到 DSH 正在运行。请使用相同的 DSH_HOME/--home 和 --profile,未修改正在运行的实例。')
86
116
  }
87
117
  const fd = fs.openSync(lockFile, 'wx', 0o600); fs.writeFileSync(fd, id); fs.closeSync(fd); locked = true
88
118
  const helper = path.join(directory, 'install-control.js')
@@ -95,17 +125,23 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
95
125
  accept: v => Number.isInteger(v.pid) && v.pid > 0 && (!launched || v.pid === launched.pid),
96
126
  onWaiting: () => console.log('正在等待 DSH 完成安装准备,请勿重复执行命令…'),
97
127
  ensureRunning: async () => {
98
- if (launched || await portBusy(Number(process.env.DSH_PORT || 3080))) return
128
+ if (launched || await portBusy(Number(process.env.DSH_PORT || 3080)) || await mayHaveRunningDsh()) return
129
+ cli ||= await chooseDsh()
99
130
  const log = fs.openSync(path.join(directory, 'startup.log'), 'a', 0o600)
100
131
  launched = spawn(process.execPath, [cli, '--profile', profileName, '--no-open'], {
101
- cwd: process.cwd(), env: process.env, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
132
+ cwd: process.cwd(), env: { ...process.env, DSH_HOME: home }, detached: true, windowsHide: true, stdio: ['ignore', log, log] })
102
133
  fs.closeSync(log); launched.on('error', () => {}); launched.unref()
103
134
  },
104
135
  })
136
+ if (!/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(ref.origin) || Number(new URL(ref.origin).port) > 65535) {
137
+ ref = undefined
138
+ throw new Error('DSH 安装握手地址无效,未修改插件。')
139
+ }
105
140
  writePrivateJsonAtomic(path.join(directory, 'handshake.json'), {
106
141
  state: 'ready', elapsedMs: Date.now() - handshakeStarted, startedHost: Boolean(launched),
107
142
  })
108
- } catch {
143
+ } catch (error) {
144
+ if (error.code !== 'DSH_HANDSHAKE_TIMEOUT') throw error
109
145
  writePrivateJsonAtomic(path.join(directory, 'handshake.json'), {
110
146
  state: 'timeout', elapsedMs: Date.now() - handshakeStarted, startedHost: Boolean(launched),
111
147
  })
@@ -113,17 +149,34 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
113
149
  }
114
150
  job = { controlOrigin: ref.origin, statusToken: token }
115
151
  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 身份与安装目标不一致,未修改安装。')
152
+ // The authenticated live host is authoritative. A global install or cached
153
+ // version found on PATH must not override the actual npx/local/source host.
154
+ // Explicit choices and installer-started hosts still require an exact match.
155
+ assertInstallTarget(host, { home, profile, cli, pid: launched?.pid || ref.pid })
117
156
  remove(); remove = undefined
118
157
  await sleep(600)
119
- const selected = await selectRelease(host, assetsRoot, repair)
158
+ let selected = await selectRelease(host, assetsRoot, repair)
159
+ if (!selected.archive && host.pluginVersion === selected.release.version) {
160
+ // A version string on disk is not proof that a prior restart completed.
161
+ // Re-running the normal command must also repair a stuck installed copy.
162
+ let ready = false
163
+ try {
164
+ const response = await fetch(`http://127.0.0.1:${host.localPort}/gate/status`, { signal: AbortSignal.timeout(5000), redirect: 'error' })
165
+ await response.arrayBuffer(); ready = response.ok
166
+ } catch {}
167
+ if (!ready) {
168
+ console.log('检测到已安装插件尚未就绪,正在重新安装修复…')
169
+ selected = await selectRelease(host, assetsRoot, true)
170
+ }
171
+ }
120
172
  if (!selected.archive) { console.log(`插件 ${host.pluginVersion} 无需更新。`); return { version: host.pluginVersion, changed: false } }
121
173
  console.log(`正在安装插件 ${selected.release.version},保留原配对与会话…`)
122
- job = { ...host, id, directory, parentPid: host.pid, pnpm: runtime.cli, targetVersion: selected.release.version,
174
+ job = { ...host, argv: installHostArgv(host), id, directory, parentPid: host.pid, pnpm: runtime.cli, targetVersion: selected.release.version,
123
175
  previousVersion: host.pluginVersion, statusToken: token, controlOrigin: ref.origin }
124
176
  validateJob(job)
125
177
  fs.writeFileSync(path.join(directory, 'release.tgz'), selected.archive, { mode: 0o600, flag: 'wx' })
126
- fs.copyFileSync(path.join(root, 'lib/update-worker.js'), path.join(directory, 'update-worker.js'))
178
+ fs.copyFileSync(path.join(root, 'lib/update-worker.js'), path.join(directory, 'update-worker.js'))
179
+ if (host.pluginVersion === '0.0.0') fs.copyFileSync(path.join(root, 'lib/native-recovery.js'), path.join(directory, 'native-recovery.js'))
127
180
  writePrivateJsonAtomic(path.join(directory, 'job.json'), job)
128
181
  await control(job, 'launch')
129
182
  const ready = await waitForJson(path.join(directory, 'worker-ready.json'), v => v.id === id)
@@ -131,7 +184,9 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
131
184
  writePrivateJsonAtomic(path.join(home, 'harness-remote-updates', `profile-${scope}.json`), { jobId: id, statusOrigin: ready.origin, statusToken: token })
132
185
  writePrivateJsonAtomic(path.join(directory, 'authorized.json'), { id }); authorized = true
133
186
  let previous = '', result
134
- const until = Date.now() + 600000
187
+ // Native download/install has its own bounded deadline; allow its restart
188
+ // and possible rollback to finish before reporting an unknown outcome.
189
+ const until = Date.now() + 1200000
135
190
  while (Date.now() < until) {
136
191
  try { result = JSON.parse(fs.readFileSync(path.join(directory, 'result.json'), 'utf8')) } catch {}
137
192
  if (result?.phase !== previous && result?.phase) { console.log(`${result.progress}% ${result.message}`); previous = result.phase }
@@ -150,16 +205,29 @@ export async function install({ profileName = 'web', cli = findDsh(), assetsRoot
150
205
  } catch { console.log('插件已更新;请返回原 DSH WebUI 查看。') }
151
206
  }
152
207
  return { version: selected.release.version, changed: true }
153
- } finally {
154
- remove?.()
155
- if (ref && !authorized) { try { await control({ controlOrigin: ref.origin, statusToken: token }, 'close') } catch {} }
156
- if (locked && !authorized) releaseOwnedUpdateLock(lockFile, id)
208
+ } finally {
209
+ try { remove?.() }
210
+ finally {
211
+ if (ref && !authorized) { try { await control({ controlOrigin: ref.origin, statusToken: token }, 'close') } catch {} }
212
+ if (locked && !authorized) releaseOwnedUpdateLock(lockFile, id)
213
+ }
214
+ }
215
+ }
216
+ export function parseArguments(args) {
217
+ const options = {}, seen = new Set()
218
+ const names = { '--profile': 'profileName', '--dsh-cli': 'cli', '--home': 'home' }
219
+ for (let i = 0; i < args.length; i++) {
220
+ const flag = args[i]
221
+ if (seen.has(flag)) throw new Error('参数重复。使用 --help 查看用法。')
222
+ seen.add(flag)
223
+ if (flag === '--repair') options.repair = true
224
+ else if (names[flag] && args[i + 1] && !args[i + 1].startsWith('--')) options[names[flag]] = args[++i]
225
+ else throw new Error('不支持的参数。使用 --help 查看用法。')
157
226
  }
227
+ return options
158
228
  }
159
229
  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 })
230
+ const args = process.argv.slice(2)
231
+ if (args.includes('--help') || args.includes('-h')) console.log('安装或升级 DSH 微信连接插件:npx -y dsh-wechat-remote@latest\n支持全局安装与 npx 使用的 DSH,已启动或关闭均可;请在相同系统账号下执行。Windows 可使用 npx.cmd。\n可选:--profile <名称>(默认 web);--home <DSH 数据目录>;--dsh-cli <DSH 的 lib/bin.js>;--repair(重新安装,不降级)')
232
+ else Promise.resolve().then(() => install(parseArguments(args))).catch(error => { console.error(error.message); process.exitCode = 1 })
165
233
  }
@@ -1151,7 +1151,7 @@ async function createInstallControl(context, config) {
1151
1151
  const scope = resolveAgentProfileScope("", process.argv, home), profile = path5.join(home, "profiles", scope);
1152
1152
  const cli = fs2.realpathSync(process.argv[1]);
1153
1153
  const manifest = JSON.parse(fs2.readFileSync(path5.resolve(cli, "../../package.json"), "utf8"));
1154
- if (manifest.name !== "@deepseek-ai/dsh" || process.execArgv.length || !process.argv.includes("web")) throw new Error("\u6B64 DSH \u542F\u52A8\u65B9\u5F0F\u5C1A\u4E0D\u652F\u6301\u81EA\u52A8\u66F4\u65B0\u3002");
1154
+ if (manifest.name !== "@deepseek-ai/dsh" || process.execArgv.length) throw new Error("\u6B64 DSH \u542F\u52A8\u65B9\u5F0F\u5C1A\u4E0D\u652F\u6301\u81EA\u52A8\u66F4\u65B0\u3002");
1155
1155
  const manager = currentHostManager(), webPort = resolveDshWebRuntime(ctx, process.env).port;
1156
1156
  const ports = deriveGatePorts(scope, loadAgentDescriptor().agentInstanceId);
1157
1157
  const nativeExit = ctx.get("appExit");
@@ -3,127 +3,70 @@
3
3
  // src/install-profile.ts
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { spawn } from "node:child_process";
7
- import { createHash } from "node:crypto";
8
-
9
- // src/secure-file.ts
10
- import { execFileSync } from "node:child_process";
11
- import {
12
- chmodSync,
13
- closeSync,
14
- existsSync,
15
- fsyncSync,
16
- mkdirSync,
17
- openSync,
18
- readFileSync,
19
- renameSync,
20
- rmSync,
21
- writeFileSync
22
- } from "node:fs";
23
- import { dirname } from "node:path";
24
- import { userInfo } from "node:os";
25
- import { randomBytes } from "node:crypto";
26
- function tightenPrivateFile(file) {
27
- try {
28
- chmodSync(file, 384);
29
- } catch {
30
- }
31
- if (process.platform !== "win32") return;
32
- try {
33
- execFileSync("icacls", [file, "/inheritance:r", "/grant:r", `${userInfo().username}:F`], {
34
- timeout: 5e3,
35
- windowsHide: true,
36
- stdio: "ignore"
37
- });
38
- } catch {
39
- }
40
- }
41
- function writePrivateJsonAtomic(file, value) {
42
- const parent = dirname(file);
43
- mkdirSync(parent, { recursive: true, mode: 448 });
44
- const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
45
- try {
46
- writeFileSync(temporary, `${JSON.stringify(value, null, 2)}
47
- `, {
48
- encoding: "utf8",
49
- mode: 384,
50
- flag: "wx"
51
- });
52
- const fd = openSync(temporary, "r+");
53
- try {
54
- try {
55
- fsyncSync(fd);
56
- } catch {
57
- }
58
- } finally {
59
- closeSync(fd);
60
- }
61
- tightenPrivateFile(temporary);
62
- renameSync(temporary, file);
63
- tightenPrivateFile(file);
64
- } finally {
65
- if (existsSync(temporary)) {
66
- try {
67
- rmSync(temporary, { force: true });
68
- } catch {
69
- }
70
- }
71
- }
72
- }
73
-
74
- // src/install-runtime.ts
75
- var INSTALL_PNPM_VERSION = "11.22.0";
76
-
77
- // src/install-profile.ts
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { spawn, execFile } from "node:child_process";
78
8
  var PLUGIN_PACKAGE = "@harness-remote/dsh-wechat-remote";
79
9
  function safeProfileName(value) {
80
10
  return /^[A-Za-z0-9_-]{1,80}$/.test(value);
81
11
  }
82
- var hash = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
83
- function assertRelocatableProfile(root) {
84
- const walk = (directory) => {
85
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
86
- const file = path.join(directory, entry.name);
87
- if (entry.isSymbolicLink()) {
88
- const relative = path.relative(root, fs.realpathSync(file));
89
- if (path.isAbsolute(fs.readlinkSync(file)) || !relative || relative.startsWith("..") || path.isAbsolute(relative)) {
90
- throw new Error("\u63D2\u4EF6\u4F9D\u8D56\u5305\u542B\u4E0D\u53EF\u8FC1\u79FB\u7684\u94FE\u63A5\uFF0C\u672A\u4FEE\u6539\u5F53\u524D\u5B89\u88C5\u3002");
91
- }
92
- } else if (entry.isDirectory()) walk(file);
12
+ function backupProfile(profile, backup) {
13
+ if (fs.existsSync(backup)) throw new Error("\u672C\u6B21\u5B89\u88C5\u5907\u4EFD\u5DF2\u5B58\u5728\uFF0C\u672A\u8986\u76D6\u3002");
14
+ fs.cpSync(profile, backup, {
15
+ recursive: true,
16
+ dereference: false,
17
+ verbatimSymlinks: true,
18
+ mode: fs.constants.COPYFILE_FICLONE,
19
+ filter(source, target) {
20
+ if (!fs.lstatSync(source).isSymbolicLink()) return true;
21
+ const type = process.platform === "win32" ? fs.statSync(source, { throwIfNoEntry: false })?.isDirectory() ? "junction" : "file" : void 0;
22
+ fs.symlinkSync(fs.readlinkSync(source), target, type);
23
+ return false;
93
24
  }
94
- };
95
- walk(root);
25
+ });
96
26
  }
97
27
  function installToolPath(directory, runtime) {
98
28
  const bin = path.join(directory, "tool-bin");
99
29
  fs.mkdirSync(bin, { mode: 448 });
100
- const runner = path.join(bin, "pnpm-run.cjs");
101
- fs.writeFileSync(runner, `require('node:child_process').spawnSync(${JSON.stringify(runtime.executable)},[${JSON.stringify(runtime.cli)},...process.argv.slice(2)],{stdio:'inherit',shell:false,windowsHide:true}).status===0?process.exit(0):process.exit(1)
102
- `, { mode: 384 });
103
30
  if (process.platform === "win32") {
104
- if (/["\r\n]/.test(runtime.executable)) throw new Error("Node \u5B89\u88C5\u8DEF\u5F84\u4E0D\u53D7\u652F\u6301\u3002");
105
- fs.writeFileSync(path.join(bin, "pnpm.cmd"), `@echo off\r
106
- "${runtime.executable.replace(/%/g, "%%")}" "%~dp0pnpm-run.cjs" %*\r
107
- `, { mode: 448 });
31
+ if (/["\r\n]/.test(runtime.executable + runtime.cli)) throw new Error("\u5B89\u88C5\u5DE5\u5177\u8DEF\u5F84\u5305\u542B\u65E0\u6548\u5B57\u7B26\u3002");
32
+ fs.writeFileSync(path.join(bin, "pnpm.cmd"), '@echo off\r\nsetlocal DisableDelayedExpansion\r\n"%HARNESS_INSTALL_NODE%" "%HARNESS_INSTALL_PNPM%" %*\r\n', { mode: 448 });
108
33
  } else {
109
- const quote = (s) => "'" + s.replace(/'/g, `'"'"'`) + "'";
110
- fs.writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh
111
- exec ${quote(runtime.executable)} ${quote(runner)} "$@"
112
- `, { mode: 448 });
34
+ fs.writeFileSync(path.join(bin, "pnpm"), '#!/bin/sh\nexec "$HARNESS_INSTALL_NODE" "$HARNESS_INSTALL_PNPM" "$@"\n', { mode: 448 });
113
35
  }
114
36
  return bin;
115
37
  }
116
- function runNativePlugin(cli, profile, home, toolPath, runtime, logFile) {
117
- if (!safeProfileName(profile)) throw new Error("\u65E0\u6548\u7684 DSH profile \u540D\u79F0\u3002");
38
+ var NativeInstallError = class extends Error {
39
+ constructor(message, mayStillBeRunning = false) {
40
+ super(message);
41
+ this.mayStillBeRunning = mayStillBeRunning;
42
+ this.name = "NativeInstallError";
43
+ }
44
+ };
45
+ function runNativePlugin(cli, profile, home, toolPath, runtime, logFile, archiveName, timeoutMs = 6e5, operation = "add") {
46
+ if (!safeProfileName(profile) || !/^harness-remote-[\w.+-]+\.tgz$/.test(archiveName)) throw new Error("\u65E0\u6548\u7684\u5B89\u88C5\u76EE\u6807\u3002");
118
47
  return new Promise((resolve, reject) => {
119
48
  const log = fs.openSync(logFile, "a", 384);
49
+ const env = { ...process.env };
50
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path");
51
+ const inheritedPath = pathKey ? env[pathKey] : "";
52
+ if (process.platform === "win32") {
53
+ for (const key of Object.keys(env)) if (key.toLowerCase() === "path") delete env[key];
54
+ }
55
+ Object.assign(env, {
56
+ DSH_HOME: home,
57
+ PATH: toolPath + path.delimiter + (inheritedPath || ""),
58
+ HARNESS_INSTALL_NODE: runtime.executable,
59
+ HARNESS_INSTALL_PNPM: runtime.cli,
60
+ CI: "true",
61
+ COREPACK_ENABLE_AUTO_PIN: "0",
62
+ npm_config_manage_package_manager_versions: "false"
63
+ });
120
64
  const child = spawn(runtime.executable, [
121
65
  cli,
122
66
  "plugin",
123
67
  "--profile",
124
68
  profile,
125
- "add",
126
- "file:harness-remote-update.tgz",
69
+ ...operation === "install" ? ["install"] : ["add", `file:${archiveName}`],
127
70
  "--ignore-scripts",
128
71
  "--config.frozen-lockfile=false",
129
72
  "--prefer-offline",
@@ -133,78 +76,89 @@ function runNativePlugin(cli, profile, home, toolPath, runtime, logFile) {
133
76
  cwd: home,
134
77
  shell: false,
135
78
  windowsHide: true,
79
+ detached: process.platform !== "win32",
136
80
  stdio: ["ignore", log, log],
137
- env: {
138
- ...process.env,
139
- DSH_HOME: home,
140
- PATH: toolPath + path.delimiter + (process.env.PATH || ""),
141
- CI: "true",
142
- COREPACK_ENABLE_AUTO_PIN: "0",
143
- npm_config_manage_package_manager_versions: "false"
144
- }
81
+ env
145
82
  });
146
83
  fs.closeSync(log);
147
- const timer = setTimeout(() => {
148
- child.kill();
149
- reject(new Error("\u4E0B\u8F7D\u6216\u5B89\u88C5\u8D85\u65F6\uFF0C\u539F\u63D2\u4EF6\u672A\u66FF\u6362\u3002"));
150
- }, 24e4);
151
- child.once("error", () => {
84
+ let finished = false, terminating = false, closed = false, terminationTimer;
85
+ const fail = (message, uncertain = false) => finish(new NativeInstallError(`${message} \u5B89\u88C5\u65E5\u5FD7\uFF1A${logFile}`, uncertain));
86
+ const finish = (error) => {
87
+ if (finished) return;
88
+ finished = true;
152
89
  clearTimeout(timer);
153
- reject(new Error("\u65E0\u6CD5\u542F\u52A8 DSH \u539F\u751F\u5B89\u88C5\u7A0B\u5E8F\u3002"));
154
- });
155
- child.once("close", (code) => {
156
- clearTimeout(timer);
157
- code === 0 ? resolve() : reject(new Error("\u5B89\u88C5\u672A\u5B8C\u6210\uFF0C\u539F\u63D2\u4EF6\u672A\u66FF\u6362\uFF1B\u8BE6\u60C5\u5DF2\u4FDD\u7559\u5728\u672C\u673A\u5B89\u88C5\u65E5\u5FD7\u3002"));
90
+ clearTimeout(terminationTimer);
91
+ error ? reject(error) : resolve();
92
+ };
93
+ const timer = setTimeout(() => {
94
+ if (closed) return;
95
+ terminating = true;
96
+ terminationTimer = setTimeout(() => fail("\u5B89\u88C5\u8D85\u65F6\uFF0C\u5C1A\u4E0D\u80FD\u786E\u8BA4\u5B89\u88C5\u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF1B\u672A\u81EA\u52A8\u56DE\u9000\uFF0C\u8BF7\u52FF\u91CD\u590D\u5B89\u88C5\u3002", true), 15e3);
97
+ if (process.platform === "win32") {
98
+ if (!child.pid) return fail("\u5B89\u88C5\u7A0B\u5E8F\u672A\u542F\u52A8\u3002");
99
+ execFile(
100
+ path.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"),
101
+ ["/PID", String(child.pid), "/T", "/F"],
102
+ { windowsHide: true, timeout: 1e4 },
103
+ (error) => {
104
+ if (error) fail("\u5B89\u88C5\u8D85\u65F6\uFF0C\u65E0\u6CD5\u786E\u8BA4\u5B89\u88C5\u8FDB\u7A0B\u6811\u5DF2\u9000\u51FA\uFF1B\u672A\u81EA\u52A8\u56DE\u9000\uFF0C\u8BF7\u52FF\u91CD\u590D\u5B89\u88C5\u3002", true);
105
+ else if (closed) fail("\u4E0B\u8F7D\u6216\u5B89\u88C5\u8D85\u65F6\uFF0C\u5B89\u88C5\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002");
106
+ else child.once("close", () => fail("\u4E0B\u8F7D\u6216\u5B89\u88C5\u8D85\u65F6\uFF0C\u5B89\u88C5\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002"));
107
+ }
108
+ );
109
+ } else {
110
+ try {
111
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
112
+ } catch (error) {
113
+ if (error.code !== "ESRCH") return fail("\u65E0\u6CD5\u505C\u6B62\u8D85\u65F6\u7684\u5B89\u88C5\u8FDB\u7A0B\uFF1B\u672A\u81EA\u52A8\u56DE\u9000\u3002", true);
114
+ }
115
+ if (closed) fail("\u4E0B\u8F7D\u6216\u5B89\u88C5\u8D85\u65F6\uFF0C\u5B89\u88C5\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002");
116
+ else child.once("close", () => fail("\u4E0B\u8F7D\u6216\u5B89\u88C5\u8D85\u65F6\uFF0C\u5B89\u88C5\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002"));
117
+ }
118
+ }, timeoutMs);
119
+ child.once("error", (error) => fail(`\u65E0\u6CD5\u542F\u52A8 DSH \u539F\u751F\u5B89\u88C5\u7A0B\u5E8F\uFF08${error.code || error.name}\uFF09\u3002`));
120
+ child.once("close", (code, signal) => {
121
+ closed = true;
122
+ if (terminating) return;
123
+ if (code === 0) finish();
124
+ else fail(`DSH \u539F\u751F\u5B89\u88C5\u672A\u5B8C\u6210\uFF08${signal ? `\u4FE1\u53F7 ${signal}` : `\u9000\u51FA\u7801 ${code}`}\uFF09\u3002`);
158
125
  });
159
126
  });
160
127
  }
161
- async function stageProfile(job) {
162
- const scope = path.basename(job.profile);
163
- if (!safeProfileName(scope) || !fs.statSync(job.directory).isDirectory()) throw new Error("\u5B89\u88C5\u76EE\u6807\u4E0D\u660E\u786E\u3002");
164
- const stagingHome = path.join(job.directory, "staging-home"), staged = path.join(stagingHome, "profiles", scope);
165
- fs.mkdirSync(staged, { recursive: true, mode: 448 });
166
- if (fs.existsSync(job.profile)) {
167
- if (fs.lstatSync(job.profile).isSymbolicLink() || fs.realpathSync(job.profile) !== path.resolve(job.profile)) throw new Error("\u4E0D\u652F\u6301\u81EA\u52A8\u66FF\u6362\u94FE\u63A5\u5F62\u5F0F\u7684 profile\u3002");
168
- fs.cpSync(job.profile, staged, {
169
- recursive: true,
170
- dereference: false,
171
- filter: (p) => !["node_modules", ".harness-remote-update.lock"].includes(path.basename(p))
172
- });
173
- }
174
- let before = null;
175
- const filename = path.join(staged, "package.json");
176
- if (fs.existsSync(filename)) {
177
- before = JSON.parse(fs.readFileSync(filename, "utf8"));
178
- for (const [name, spec] of Object.entries(before.dependencies || {})) {
179
- if (name !== PLUGIN_PACKAGE && !/^[~^]?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(String(spec))) throw new Error("\u5176\u4ED6\u63D2\u4EF6\u4F7F\u7528\u4E86\u672C\u5730\u94FE\u63A5\u6216\u7279\u6B8A\u6765\u6E90\uFF0C\u672A\u4FEE\u6539\u5F53\u524D\u5B89\u88C5\u3002");
180
- }
181
- for (const e of fs.readdirSync(staged, { withFileTypes: true })) if (e.isSymbolicLink()) throw new Error("profile \u914D\u7F6E\u5305\u542B\u94FE\u63A5\uFF0C\u672A\u4FEE\u6539\u5F53\u524D\u5B89\u88C5\u3002");
182
- if (before.packageManager && !/^pnpm@\d+\.\d+\.\d+(?:\+.*)?$/.test(before.packageManager)) throw new Error("\u5F53\u524D profile \u4F7F\u7528\u4E86\u5176\u4ED6\u5305\u7BA1\u7406\u5668\uFF0C\u672A\u4FEE\u6539\u5B89\u88C5\u3002");
183
- writePrivateJsonAtomic(filename, { ...before, dependencies: {
184
- ...before.dependencies,
185
- [PLUGIN_PACKAGE]: "file:harness-remote-update.tgz"
186
- }, packageManager: `pnpm@${INSTALL_PNPM_VERSION}` });
187
- }
188
- fs.copyFileSync(path.join(job.directory, "release.tgz"), path.join(staged, "harness-remote-update.tgz"));
128
+ async function installProfile(job) {
129
+ const scope = path.basename(job.profile), home = path.dirname(path.dirname(job.profile));
130
+ if (!safeProfileName(scope) || path.dirname(job.profile) !== path.join(home, "profiles") || !/^[\w.+-]{1,80}$/.test(job.targetVersion)) throw new Error("\u5B89\u88C5\u76EE\u6807\u4E0D\u660E\u786E\u3002");
131
+ fs.mkdirSync(job.profile, { recursive: true, mode: 448 });
132
+ const archive = path.join(job.directory, "release.tgz");
133
+ const digest = createHash("sha256").update(fs.readFileSync(archive)).digest("hex");
134
+ const archiveBase = `harness-remote-${job.targetVersion}-${digest}`;
135
+ const suffix = fs.existsSync(path.join(job.profile, `${archiveBase}.tgz`)) ? `-${randomBytes(8).toString("hex")}` : "";
136
+ const archiveName = `${archiveBase}${suffix}.tgz`;
137
+ fs.copyFileSync(archive, path.join(job.profile, archiveName), fs.constants.COPYFILE_EXCL);
189
138
  const tools = installToolPath(job.directory, job.runtime);
190
- await runNativePlugin(job.cli, scope, stagingHome, tools, job.runtime, path.join(job.directory, "install.log"));
191
- const installed = fs.realpathSync(path.join(staged, "node_modules", PLUGIN_PACKAGE));
192
- const relative = path.relative(staged, installed);
193
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("\u6682\u5B58\u63D2\u4EF6\u4E0D\u5728\u5B89\u88C5\u76EE\u5F55\u5185\u3002");
139
+ const logFile = path.join(job.directory, "install.log");
140
+ const deadline = Date.now() + 6e5;
141
+ const run = (operation = "add") => runNativePlugin(job.cli, scope, home, tools, job.runtime, logFile, archiveName, Math.max(1, deadline - Date.now()), operation);
142
+ try {
143
+ await run();
144
+ } catch (error) {
145
+ const layoutError = /^\s*(?:\[ERR_PNPM_|ERR_PNPM_)(?:UNEXPECTED_VIRTUAL_STORE|UNEXPECTED_STORE|MODULES_BREAKING_CHANGE|VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF|PUBLIC_HOIST_PATTERN_DIFF|HOIST_PATTERN_DIFF)(?:\]|\s)/m;
146
+ if (!(error instanceof NativeInstallError) || error.mayStillBeRunning || !layoutError.test(fs.readFileSync(logFile, "utf8"))) throw error;
147
+ fs.appendFileSync(logFile, "\nRestoring native pnpm layout with dsh plugin install.\n");
148
+ await run("install");
149
+ await run();
150
+ }
151
+ const installed = path.join(job.profile, "node_modules", PLUGIN_PACKAGE);
194
152
  if (JSON.parse(fs.readFileSync(path.join(installed, "package.json"), "utf8")).version !== job.targetVersion) throw new Error("\u5B89\u88C5\u540E\u63D2\u4EF6\u7248\u672C\u4E0D\u5339\u914D\u3002");
195
- const after = JSON.parse(fs.readFileSync(filename, "utf8"));
153
+ const after = JSON.parse(fs.readFileSync(path.join(job.profile, "package.json"), "utf8"));
196
154
  if (!after.dsh?.profile?.bundles?.includes(PLUGIN_PACKAGE)) throw new Error("DSH \u5C1A\u672A\u5C06\u63D2\u4EF6\u6CE8\u518C\u4E3A\u539F\u751F profile \u5C42\u3002");
197
- for (const name of Object.keys(before?.dependencies || {})) if (name !== PLUGIN_PACKAGE) {
198
- if (hash(path.join(job.profile, "node_modules", name, "package.json")) !== hash(path.join(staged, "node_modules", name, "package.json"))) throw new Error("\u5B89\u88C5\u8BD5\u56FE\u6539\u53D8\u5176\u4ED6\u63D2\u4EF6\uFF0C\u539F\u5B89\u88C5\u4FDD\u6301\u4E0D\u53D8\u3002");
199
- }
200
- assertRelocatableProfile(staged);
201
- return staged;
202
155
  }
203
156
  export {
157
+ NativeInstallError,
204
158
  PLUGIN_PACKAGE,
205
- assertRelocatableProfile,
159
+ backupProfile,
160
+ installProfile,
206
161
  installToolPath,
207
162
  runNativePlugin,
208
- safeProfileName,
209
- stageProfile
163
+ safeProfileName
210
164
  };