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/README.md +16 -12
- package/assets/plugin.tgz +0 -0
- package/assets/release.json +8 -10
- package/bin/dsh-discovery.mjs +175 -0
- package/bin/native-control.mjs +4 -2
- package/bin/native-recovery.mjs +44 -0
- package/bin/release-selection.mjs +36 -33
- package/bin/setup.mjs +129 -61
- package/lib/install-control.js +1 -1
- package/lib/install-profile.js +113 -159
- package/lib/install-runtime.js +17 -0
- package/lib/native-recovery.js +7478 -0
- package/lib/update-policy.js +26 -27
- package/lib/update-worker.js +295 -176
- package/package.json +1 -1
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 {
|
|
12
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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)
|
|
161
|
-
if (
|
|
162
|
-
|
|
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
|
}
|
package/lib/install-control.js
CHANGED
|
@@ -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
|
|
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");
|
package/lib/install-profile.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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("
|
|
105
|
-
fs.writeFileSync(path.join(bin, "pnpm.cmd"),
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
162
|
-
const scope = path.basename(job.profile);
|
|
163
|
-
if (!safeProfileName(scope) ||
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
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(
|
|
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
|
-
|
|
159
|
+
backupProfile,
|
|
160
|
+
installProfile,
|
|
206
161
|
installToolPath,
|
|
207
162
|
runNativePlugin,
|
|
208
|
-
safeProfileName
|
|
209
|
-
stageProfile
|
|
163
|
+
safeProfileName
|
|
210
164
|
};
|