dsh-any-agent 0.5.2 → 0.6.0

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.
@@ -0,0 +1,560 @@
1
+ /**
2
+ * 找本机的 DSH CLI,并给出“用什么运行时、怎么拉起它”。零配置、零依赖。
3
+ *
4
+ * 来源(全部自动发现,不需要手填 DSH_BIN):
5
+ * - desktop:DeepSeek Harness 桌面版。CLI 打包在 `resources/app.asar` 里,普通 Node
6
+ * 读不了 asar,所以用桌面版自带的 Electron 以 Node 模式(ELECTRON_RUN_AS_NODE=1)
7
+ * 运行它 —— 顺带用上桌面版自带的 Node(DSH 0.2.2 起要求 Node >= 24.9)。
8
+ * Windows 从卸载注册表(HKCU/HKLM)读安装目录,另外各平台都查默认安装位置。
9
+ * - global:npm 全局安装(`node <bin.js>`)。
10
+ * - npx:npx 缓存(随 `npm cache clean` 随时消失,同等条件下最低优先)。
11
+ *
12
+ * 选择顺序:当前运行时能满足 DSH `engines.node` 的优先 → 版本高的优先 →
13
+ * desktop > global > npx。这样用户的 GUI 与本服务默认跑同一份 DSH(两者共用
14
+ * ~/.dsh 会话存储,版本不一致时新版本写的会话可能被旧版本 resume)。
15
+ *
16
+ * 显式覆盖:DSH_BIN(指向 bin.js;可以指进 .asar 内部,运行时自动推导),
17
+ * DSH_RUNTIME(可选,指定拉起 DSH 的可执行文件,例如一个 Node 24;配合 DSH_BIN,
18
+ * 或单独设置时作用于自动发现的 npm/npx 候选 —— 桌面版始终用它自带的运行时)。
19
+ */
20
+
21
+ import { execFileSync, spawnSync } from 'node:child_process';
22
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
23
+ import { homedir, tmpdir } from 'node:os';
24
+ import { basename, dirname, join, sep } from 'node:path';
25
+
26
+ /** 相对 DSH 包根的入口与包描述。 */
27
+ const DSH_PKG_PARTS = ['@deepseek-ai', 'dsh'];
28
+ const DSH_ENTRY = join(...DSH_PKG_PARTS, 'lib', 'bin.js');
29
+ /** 桌面版 asar 内 DSH 包的位置(实测 0.2.2-rc.1:`app.asar/dsh/node_modules/@deepseek-ai/dsh`)。 */
30
+ const ASAR_DSH_ROOT = ['dsh', 'node_modules', ...DSH_PKG_PARTS];
31
+
32
+ const SOURCE_RANK = { env: 4, desktop: 3, global: 2, npx: 1 };
33
+ export const SOURCE_LABEL = { env: 'DSH_BIN', desktop: '桌面版', global: 'npm 全局', npx: 'npx 缓存' };
34
+
35
+ /** 发现结果缓存:注册表 + 文件系统探测约 100ms,拉进程/体检时会频繁调用。 */
36
+ const DISCOVERY_TTL_MS = 60_000;
37
+ let discoveryCache = null;
38
+
39
+ // ── 版本工具 ──────────────────────────────────────────────────────
40
+
41
+ /** 极简 semver 比较(够用于挑最新);返回 a-b 的符号。 */
42
+ export function cmpVersion(a, b) {
43
+ const [ac, ap] = String(a).split('-', 2);
44
+ const [bc, bp] = String(b).split('-', 2);
45
+ const an = ac.split('.').map(Number);
46
+ const bn = bc.split('.').map(Number);
47
+ for (let i = 0; i < 3; i++) {
48
+ const d = (an[i] || 0) - (bn[i] || 0);
49
+ if (d) return d;
50
+ }
51
+ if (!ap && bp) return 1; // release > prerelease
52
+ if (ap && !bp) return -1;
53
+ if (ap && bp) {
54
+ const ar = ap.split('.');
55
+ const br = bp.split('.');
56
+ for (let i = 0; i < Math.max(ar.length, br.length); i++) {
57
+ const x = ar[i] ?? '';
58
+ const y = br[i] ?? '';
59
+ const xn = x !== '' && Number.isFinite(Number(x));
60
+ const yn = y !== '' && Number.isFinite(Number(y));
61
+ if (xn && yn) {
62
+ if (Number(x) !== Number(y)) return Number(x) - Number(y);
63
+ } else if (xn) return -1;
64
+ else if (yn) return 1;
65
+ else if (x !== y) return x < y ? -1 : 1;
66
+ }
67
+ }
68
+ return 0;
69
+ }
70
+
71
+ /**
72
+ * Node 版本是否满足 `engines.node`。支持 `||`、空格分隔的 `>= > <= < =` 与 `^X`;
73
+ * 看不懂的写法返回 null(未知,不当作不满足)。
74
+ */
75
+ export function satisfiesNodeRange(range, version) {
76
+ if (!range || !version) return null;
77
+ const v = String(version).replace(/^v/, '');
78
+ let understood = true;
79
+ const alt = String(range)
80
+ .split('||')
81
+ .map((part) =>
82
+ part
83
+ .trim()
84
+ .split(/\s+/)
85
+ .filter(Boolean)
86
+ .every((cmp) => {
87
+ const m = /^(>=|<=|>|<|=|\^|~)?v?(\d+(?:\.\d+){0,2}(?:-[\w.]+)?)$/.exec(cmp);
88
+ if (!m) {
89
+ understood = false;
90
+ return false;
91
+ }
92
+ const [, op = '=', target] = m;
93
+ const d = cmpVersion(v, target);
94
+ const major = (s) => Number(String(s).split('.')[0]);
95
+ switch (op) {
96
+ case '>=': return d >= 0;
97
+ case '>': return d > 0;
98
+ case '<=': return d <= 0;
99
+ case '<': return d < 0;
100
+ case '^': return d >= 0 && major(v) === major(target);
101
+ case '~': return d >= 0 && String(v).split('.').slice(0, 2).join('.') === String(target).split('.').slice(0, 2).join('.');
102
+ default: return d === 0;
103
+ }
104
+ }),
105
+ );
106
+ if (alt.some(Boolean)) return true;
107
+ return understood ? false : null;
108
+ }
109
+
110
+ // ── asar 只读访问(纯 Node,不需要 Electron)──────────────────────
111
+
112
+ const asarHeaders = new Map();
113
+
114
+ /** 读 asar 头:`[u32 4][u32 headerSize]` + pickle(`[u32 payload][u32 len][json]`),文件区从 8+headerSize 开始。 */
115
+ function readAsarHeader(asarPath) {
116
+ let st;
117
+ try {
118
+ st = statSync(asarPath);
119
+ } catch {
120
+ return null;
121
+ }
122
+ const key = `${asarPath}|${st.size}|${st.mtimeMs}`;
123
+ const hit = asarHeaders.get(asarPath);
124
+ if (hit?.key === key) return hit.value;
125
+ let value = null;
126
+ let fd;
127
+ try {
128
+ fd = openSync(asarPath, 'r');
129
+ const head = Buffer.alloc(8);
130
+ readSync(fd, head, 0, 8, 0);
131
+ const size = head.readUInt32LE(4);
132
+ if (size <= 8 || size > st.size) throw new Error('bad asar header size');
133
+ const hb = Buffer.alloc(size);
134
+ readSync(fd, hb, 0, size, 8);
135
+ const strLen = hb.readUInt32LE(4);
136
+ value = { header: JSON.parse(hb.toString('utf8', 8, 8 + strLen)), base: 8 + size };
137
+ } catch {
138
+ value = null;
139
+ } finally {
140
+ if (fd !== undefined) closeSync(fd);
141
+ }
142
+ asarHeaders.set(asarPath, { key, value });
143
+ return value;
144
+ }
145
+
146
+ /** 把 `X/app.asar/a/b.js` 拆成 `{ asar: 'X/app.asar', inner: ['a','b.js'] }`;不在 asar 里返回 null。 */
147
+ export function splitAsarPath(p) {
148
+ const parts = String(p ?? '').split(/[\\/]/);
149
+ const i = parts.findIndex((s, idx) => idx < parts.length - 1 && /\.asar$/i.test(s));
150
+ if (i < 0) return null;
151
+ return { asar: parts.slice(0, i + 1).join(sep), inner: parts.slice(i + 1) };
152
+ }
153
+
154
+ function asarEntry(asarPath, inner) {
155
+ const h = readAsarHeader(asarPath);
156
+ if (!h) return null;
157
+ let node = h.header;
158
+ for (const k of inner) {
159
+ node = node?.files?.[k];
160
+ if (!node) return null;
161
+ }
162
+ return { node, base: h.base };
163
+ }
164
+
165
+ /** 读文件(asar 感知);读不到返回 null。 */
166
+ export function readMaybeAsar(p) {
167
+ const split = splitAsarPath(p);
168
+ if (!split) {
169
+ try {
170
+ return readFileSync(p, 'utf8');
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ const e = asarEntry(split.asar, split.inner);
176
+ if (!e || e.node.files || e.node.link) return null;
177
+ if (e.node.unpacked) {
178
+ try {
179
+ return readFileSync(join(`${split.asar}.unpacked`, ...split.inner), 'utf8');
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+ let fd;
185
+ try {
186
+ fd = openSync(split.asar, 'r');
187
+ const buf = Buffer.alloc(e.node.size);
188
+ readSync(fd, buf, 0, e.node.size, e.base + Number(e.node.offset));
189
+ return buf.toString('utf8');
190
+ } catch {
191
+ return null;
192
+ } finally {
193
+ if (fd !== undefined) closeSync(fd);
194
+ }
195
+ }
196
+
197
+ /** 路径是否存在(asar 感知)。 */
198
+ export function existsMaybeAsar(p) {
199
+ const split = splitAsarPath(p);
200
+ if (!split) return existsSync(p);
201
+ return asarEntry(split.asar, split.inner) !== null;
202
+ }
203
+
204
+ /** 读 bin.js 所在包的 version / engines.node(asar 感知)。 */
205
+ export function readDshPackage(bin) {
206
+ const txt = readMaybeAsar(join(dirname(bin), '..', 'package.json'));
207
+ if (!txt) return { version: null, enginesNode: null };
208
+ try {
209
+ const j = JSON.parse(txt);
210
+ return { version: j.version ?? null, enginesNode: j.engines?.node ?? null };
211
+ } catch {
212
+ return { version: null, enginesNode: null };
213
+ }
214
+ }
215
+
216
+ // ── 桌面版发现 ────────────────────────────────────────────────────
217
+
218
+ const DESKTOP_NAME = 'DeepSeek Harness';
219
+
220
+ /** 解析 `reg export` 产出的 .reg(UTF-16LE):返回 [{ key, values: {Name: string} }]。 */
221
+ export function parseRegExport(text) {
222
+ const out = [];
223
+ let cur = null;
224
+ for (const raw of String(text).replace(/^\uFEFF/, '').split(/\r?\n/)) {
225
+ const line = raw.trim();
226
+ const sec = /^\[(.+)\]$/.exec(line);
227
+ if (sec) {
228
+ cur = { key: sec[1], values: {} };
229
+ out.push(cur);
230
+ continue;
231
+ }
232
+ const kv = /^"((?:[^"\\]|\\.)*)"="((?:[^"\\]|\\.)*)"$/.exec(line);
233
+ if (kv && cur) {
234
+ const un = (s) => s.replace(/\\(.)/g, '$1');
235
+ cur.values[un(kv[1])] = un(kv[2]);
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Windows 卸载注册表里的桌面版安装。
243
+ * 用 `reg export`(UTF-16LE 文件)而不是 `reg query` 的控制台输出:后者按系统代码页
244
+ * 编码,中文路径在 GBK 系统上会乱码。
245
+ */
246
+ function windowsRegistryInstalls() {
247
+ if (process.platform !== 'win32') return [];
248
+ const roots = [
249
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
250
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
251
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
252
+ ];
253
+ const reg = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'reg.exe');
254
+ const found = [];
255
+ roots.forEach((root, i) => {
256
+ const file = join(tmpdir(), `dsh-locate-${process.pid}-${Date.now()}-${i}.reg`);
257
+ try {
258
+ execFileSync(existsSync(reg) ? reg : 'reg', ['export', root, file, '/y'], {
259
+ stdio: 'ignore',
260
+ windowsHide: true,
261
+ timeout: 5000,
262
+ });
263
+ for (const sec of parseRegExport(readFileSync(file, 'utf16le'))) {
264
+ const v = sec.values;
265
+ if (!/deepseek\s*harness/i.test(v.DisplayName ?? '')) continue;
266
+ const icon = (v.DisplayIcon ?? '').replace(/,\s*-?\d+$/, '').replace(/^"|"$/g, '');
267
+ const dir = v.InstallLocation || (icon ? dirname(icon) : '');
268
+ if (dir) found.push({ dir, exe: /\.exe$/i.test(icon) ? icon : null, displayVersion: v.DisplayVersion ?? null, via: 'registry' });
269
+ }
270
+ } catch {
271
+ // 该根不存在 / reg 不可用:静默跳过
272
+ } finally {
273
+ rmSync(file, { force: true });
274
+ }
275
+ });
276
+ return found;
277
+ }
278
+
279
+ /** 各平台默认安装位置(注册表之外的兜底,以及 macOS/Linux)。 */
280
+ function defaultDesktopDirs() {
281
+ const home = homedir();
282
+ if (process.platform === 'win32') {
283
+ return [
284
+ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', DESKTOP_NAME),
285
+ process.env.ProgramFiles && join(process.env.ProgramFiles, DESKTOP_NAME),
286
+ process.env['ProgramFiles(x86)'] && join(process.env['ProgramFiles(x86)'], DESKTOP_NAME),
287
+ ].filter(Boolean);
288
+ }
289
+ if (process.platform === 'darwin') {
290
+ return [join('/Applications', `${DESKTOP_NAME}.app`), join(home, 'Applications', `${DESKTOP_NAME}.app`)];
291
+ }
292
+ return ['/opt/DeepSeek Harness', '/opt/deepseek-harness', '/usr/lib/deepseek-harness', join(home, '.local', 'share', 'deepseek-harness')];
293
+ }
294
+
295
+ /** 由 asar 路径推导桌面版可执行文件(显式 DSH_BIN 指进 asar 时也用它)。 */
296
+ export function desktopExeForAsar(asarPath, exeHint = null) {
297
+ if (exeHint && existsSync(exeHint)) return exeHint;
298
+ const resources = dirname(asarPath);
299
+ if (process.platform === 'darwin' && basename(resources) === 'Resources') {
300
+ const macos = join(dirname(resources), 'MacOS');
301
+ const named = join(macos, DESKTOP_NAME);
302
+ if (existsSync(named)) return named;
303
+ try {
304
+ const first = readdirSync(macos)[0];
305
+ return first ? join(macos, first) : null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+ const root = dirname(resources);
311
+ const names =
312
+ process.platform === 'win32'
313
+ ? [`${DESKTOP_NAME}.exe`]
314
+ : [DESKTOP_NAME, 'deepseek-harness', 'dsh-desktop'];
315
+ for (const n of names) {
316
+ const p = join(root, n);
317
+ if (existsSync(p)) return p;
318
+ }
319
+ if (process.platform === 'win32') {
320
+ try {
321
+ const exe = readdirSync(root).find((f) => /\.exe$/i.test(f) && !/^(uninstall|elevate)/i.test(f));
322
+ if (exe) return join(root, exe);
323
+ } catch {}
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /** 检查一个桌面版安装目录,返回候选(不是有效安装时返回 null)。导出供测试。 */
329
+ export function desktopCandidate({ dir, exe = null, displayVersion = null, via }) {
330
+ const asar =
331
+ process.platform === 'darwin' && /\.app$/i.test(dir)
332
+ ? join(dir, 'Contents', 'Resources', 'app.asar')
333
+ : join(dir, 'resources', 'app.asar');
334
+ if (!existsSync(asar)) return null;
335
+ const bin = join(asar, ...ASAR_DSH_ROOT, 'lib', 'bin.js');
336
+ if (!existsMaybeAsar(bin)) return null;
337
+ const runtime = desktopExeForAsar(asar, exe);
338
+ if (!runtime) return null;
339
+ const pkg = readDshPackage(bin);
340
+ return {
341
+ source: 'desktop',
342
+ via,
343
+ installDir: dir,
344
+ displayVersion,
345
+ bin,
346
+ version: pkg.version,
347
+ enginesNode: pkg.enginesNode,
348
+ command: runtime,
349
+ args: [bin],
350
+ env: { ELECTRON_RUN_AS_NODE: '1' },
351
+ runtime: 'electron',
352
+ // 桌面版自带运行时就是 GUI 自己跑 DSH 用的那个:视为满足(体检时再实测版本)。
353
+ runtimeOk: null,
354
+ };
355
+ }
356
+
357
+ function desktopCandidates() {
358
+ const seen = new Set();
359
+ const out = [];
360
+ const entries = [...windowsRegistryInstalls(), ...defaultDesktopDirs().map((dir) => ({ dir, via: 'default-path' }))];
361
+ for (const e of entries) {
362
+ const k = e.dir.toLowerCase();
363
+ if (seen.has(k)) continue;
364
+ seen.add(k);
365
+ const c = desktopCandidate(e);
366
+ if (c) out.push(c);
367
+ }
368
+ return out;
369
+ }
370
+
371
+ // ── npm 全局 / npx 缓存 ─────────────────────────────────────────
372
+
373
+ function nodeCandidate(source, bin) {
374
+ if (!existsSync(bin)) return null;
375
+ const pkg = readDshPackage(bin);
376
+ return {
377
+ source,
378
+ bin,
379
+ version: pkg.version,
380
+ enginesNode: pkg.enginesNode,
381
+ command: process.execPath,
382
+ args: [bin],
383
+ env: {},
384
+ runtime: 'node',
385
+ runtimeOk: satisfiesNodeRange(pkg.enginesNode, process.versions.node),
386
+ };
387
+ }
388
+
389
+ function globalCandidates() {
390
+ const nodeDir = dirname(process.execPath);
391
+ const roots = [
392
+ process.env.APPDATA && join(process.env.APPDATA, 'npm', 'node_modules'),
393
+ join(homedir(), 'AppData', 'Roaming', 'npm', 'node_modules'),
394
+ join(nodeDir, 'node_modules'), // Windows 下 prefix=node 安装目录时
395
+ join(nodeDir, '..', 'lib', 'node_modules'), // Unix prefix
396
+ '/usr/local/lib/node_modules',
397
+ '/usr/lib/node_modules',
398
+ ].filter(Boolean);
399
+ const seen = new Set();
400
+ const out = [];
401
+ for (const r of roots) {
402
+ const bin = join(r, DSH_ENTRY);
403
+ if (seen.has(bin.toLowerCase())) continue;
404
+ seen.add(bin.toLowerCase());
405
+ const c = nodeCandidate('global', bin);
406
+ if (c) out.push(c);
407
+ }
408
+ return out;
409
+ }
410
+
411
+ /** npx 的缓存目录名带随机 hash(`_npx/<hash>/`),写死会随环境失效,所以这里筛一遍。 */
412
+ function npxCandidates() {
413
+ const roots = [join(homedir(), 'AppData', 'Local', 'npm-cache', '_npx'), join(homedir(), '.npm', '_npx')];
414
+ const out = [];
415
+ for (const root of roots) {
416
+ let entries;
417
+ try {
418
+ entries = readdirSync(root, { withFileTypes: true });
419
+ } catch {
420
+ continue;
421
+ }
422
+ for (const e of entries) {
423
+ if (!e.isDirectory()) continue;
424
+ const c = nodeCandidate('npx', join(root, e.name, 'node_modules', DSH_ENTRY));
425
+ if (c) out.push(c);
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** DSH 入口是不是落在 npx 缓存里(`_npx/<hash>/…`):那里随 `npm cache clean` 或 npx 自身淘汰随时消失。 */
432
+ export function isNpxCachePath(p) {
433
+ return /[\\/]_npx[\\/]/.test(String(p ?? ''));
434
+ }
435
+
436
+ // ── 选择 ──────────────────────────────────────────────────────────
437
+
438
+ /**
439
+ * 候选排序:已知跑不起来的(runtimeOk=false)垫底 → 版本高优先 → desktop > global > npx。
440
+ * ★ 不按“版本最高”一刀切:npx 里一份要求 Node 24 的新版,在 Node 22 上可能根本跑不起来,
441
+ * 不如桌面版自带运行时的同代版本可靠。
442
+ */
443
+ export function rankCandidates(list) {
444
+ return [...list].sort(
445
+ (a, b) =>
446
+ (a.runtimeOk === false) - (b.runtimeOk === false) ||
447
+ cmpVersion(b.version ?? '0.0.0', a.version ?? '0.0.0') ||
448
+ (SOURCE_RANK[b.source] ?? 0) - (SOURCE_RANK[a.source] ?? 0),
449
+ );
450
+ }
451
+
452
+ /** 自动发现到的全部候选(已排序,第一个就是默认选择)。结果缓存 60 秒。 */
453
+ export function discoverDsh({ fresh = false } = {}) {
454
+ const runtime = process.env.DSH_RUNTIME || null;
455
+ if (
456
+ !fresh &&
457
+ discoveryCache &&
458
+ discoveryCache.runtime === runtime &&
459
+ Date.now() - discoveryCache.at < DISCOVERY_TTL_MS
460
+ ) {
461
+ return discoveryCache.list;
462
+ }
463
+ if (runtime && !existsSync(runtime)) throw new Error(`DSH_RUNTIME 指向的文件不存在: ${runtime}`);
464
+ const raw = [...desktopCandidates(), ...globalCandidates(), ...npxCandidates()];
465
+ const list = rankCandidates(runtime ? applyRuntimeOverride(raw, runtime) : raw);
466
+ discoveryCache = { at: Date.now(), runtime, list };
467
+ return list;
468
+ }
469
+
470
+ /**
471
+ * DSH_RUNTIME 对**自动发现**的候选同样生效(不只是配合 DSH_BIN):dsh_doctor 在
472
+ * "engines.node 不满足"时建议的正是"用 DSH_RUNTIME 指定一个满足要求的 node" —— 以前只有
473
+ * 显式 DSH_BIN 分支读它,照做之后服务仍用自己的 Node 22 拉起要求 Node 24 的 npm 版 DSH。
474
+ *
475
+ * 只替换 npm 全局 / npx 这类 `node <bin.js>` 候选;桌面版的 CLI 在 app.asar 里,普通 node
476
+ * 读不了,必须用它自带的 Electron 运行时,所以桌面版候选保持不变。替换后用实测的运行时
477
+ * Node 版本重新判定 engines(拿不到版本就记 null = 未知,不当作不满足)。导出供测试。
478
+ */
479
+ export function applyRuntimeOverride(list, runtime) {
480
+ const env = { ELECTRON_RUN_AS_NODE: '1' };
481
+ const runtimeNode = probeRuntimeNode({ command: runtime, env });
482
+ return list.map((c) =>
483
+ c.runtime === 'node'
484
+ ? {
485
+ ...c,
486
+ command: runtime,
487
+ env,
488
+ runtime: 'custom',
489
+ runtimeOk: satisfiesNodeRange(c.enginesNode, runtimeNode),
490
+ }
491
+ : c,
492
+ );
493
+ }
494
+
495
+ /** 显式 DSH_BIN(可选 DSH_RUNTIME)。 */
496
+ function explicitDsh(binPath, runtimePath) {
497
+ if (!existsMaybeAsar(binPath)) throw new Error(`DSH_BIN 指向的文件不存在: ${binPath}`);
498
+ const pkg = readDshPackage(binPath);
499
+ const split = splitAsarPath(binPath);
500
+ let command = process.execPath;
501
+ if (runtimePath) {
502
+ if (!existsSync(runtimePath)) throw new Error(`DSH_RUNTIME 指向的文件不存在: ${runtimePath}`);
503
+ command = runtimePath;
504
+ } else if (split) {
505
+ command = desktopExeForAsar(split.asar);
506
+ if (!command) {
507
+ throw new Error(`DSH_BIN 在 asar 内(${split.asar}),但找不到配套的桌面版可执行文件;请用 DSH_RUNTIME 指定`);
508
+ }
509
+ }
510
+ const foreign = command !== process.execPath;
511
+ return {
512
+ source: 'env',
513
+ bin: binPath,
514
+ version: pkg.version,
515
+ enginesNode: pkg.enginesNode,
516
+ command,
517
+ args: [binPath],
518
+ // ELECTRON_RUN_AS_NODE 对普通 node 无副作用,所以外部运行时一律带上。
519
+ env: foreign ? { ELECTRON_RUN_AS_NODE: '1' } : {},
520
+ runtime: split && !runtimePath ? 'electron' : foreign ? 'custom' : 'node',
521
+ runtimeOk: foreign ? null : satisfiesNodeRange(pkg.enginesNode, process.versions.node),
522
+ };
523
+ }
524
+
525
+ /**
526
+ * 解析要用的 DSH:`{ bin, command, args, env, version, source, runtime, runtimeOk, ... }`。
527
+ * 拉起方式:`spawn(command, [...args, '--profile', X], { env: { ...process.env, ...env } })`。
528
+ */
529
+ export function resolveDsh({ fresh = false } = {}) {
530
+ const explicit = process.env.DSH_BIN;
531
+ if (explicit) return explicitDsh(explicit, process.env.DSH_RUNTIME || null);
532
+ const list = discoverDsh({ fresh });
533
+ if (list.length) return list[0];
534
+ throw new Error(
535
+ 'DSH CLI 未找到:没有发现 DeepSeek Harness 桌面版、npm 全局安装或 npx 缓存里的 @deepseek-ai/dsh。\n' +
536
+ ' 安装桌面版即可自动识别;或 npm i -g @deepseek-ai/dsh;或设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js',
537
+ );
538
+ }
539
+
540
+ /** 实测某个运行时的 Node 版本(体检用;约 0.2s,结果缓存)。 */
541
+ const runtimeNodeCache = new Map();
542
+ export function probeRuntimeNode(c) {
543
+ if (c.command === process.execPath) return process.versions.node;
544
+ const key = `${c.command}|${JSON.stringify(c.env)}`;
545
+ if (runtimeNodeCache.has(key)) return runtimeNodeCache.get(key);
546
+ let v = null;
547
+ try {
548
+ const r = spawnSync(c.command, ['-e', 'process.stdout.write(process.versions.node)'], {
549
+ env: { ...process.env, ...c.env },
550
+ encoding: 'utf8',
551
+ timeout: 15_000,
552
+ windowsHide: true,
553
+ });
554
+ v = /^\d+\.\d+\.\d+/.test(r.stdout ?? '') ? r.stdout.trim() : null;
555
+ } catch {
556
+ v = null;
557
+ }
558
+ runtimeNodeCache.set(key, v);
559
+ return v;
560
+ }