dsh-mobile-plugin 0.1.60 → 0.1.61
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/lib/index.js +35 -30
- package/lib/panel/index.html +1 -1
- package/lib/routes/control-plane.js +112 -15
- package/lib/shell/updater.js +68 -8
- package/lib/update-install.cjs +311 -0
- package/lib/update-registry.cjs +142 -0
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ const readReceipt = require('../read-receipt.cjs');
|
|
|
27
27
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
28
28
|
// 「那半边根本没在跑」和「跑了但没清掉」(详见 lib/desktop-half-stats.cjs 的文件头)。
|
|
29
29
|
const desktopHalf = require('../desktop-half-stats.cjs');
|
|
30
|
+
// 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
|
|
31
|
+
// 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
|
|
32
|
+
const { checkForUpdate } = require('../update-registry.cjs');
|
|
33
|
+
const { installLatest, declaredSource } = require('../update-install.cjs');
|
|
30
34
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
31
35
|
|
|
32
36
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
@@ -47,6 +51,22 @@ const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
|
|
|
47
51
|
/** 插件包根(`<pkg>/lib/routes` 往上两级):诊断时读它的 `package.json` 与 `React/`。 */
|
|
48
52
|
const PACKAGE_DIR = path.join(__dirname, '..', '..');
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 这一代(`lib/` 所在的那份包)自己的版本号。
|
|
56
|
+
*
|
|
57
|
+
* 「检查更新」在**没有外壳**的宿主上(无头部署、或那一版没装外壳)也要能答,而盘上版本
|
|
58
|
+
* 是外壳的 `manager.js` 才知道的事;拿不到就退回这一代的版本号 —— 它至少是「这台机器上
|
|
59
|
+
* 正在跑的实现」的真实版本,比空着或回一句「问不了」有用。
|
|
60
|
+
*/
|
|
61
|
+
function generationVersion() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf8'));
|
|
64
|
+
return typeof raw.version === 'string' && raw.version ? raw.version : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
/** 按 mtime 缓存:文件被换掉之后内容变了,缓存自然失效,不必手工清。 */
|
|
51
71
|
let panelCache = { file: '', mtime: -1, html: '' };
|
|
52
72
|
|
|
@@ -362,12 +382,12 @@ function registerControlPlaneRoutes(deps) {
|
|
|
362
382
|
}) })
|
|
363
383
|
// ── 插件更新(2026-10 搬回自更新之后重新有这四条)──
|
|
364
384
|
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// 免得「宿主能装、面板说看不见」这种两边写法不一致的偏差。
|
|
385
|
+
// 分工(2026-10-11 起):**查归插件自己**(问 npm 注册表,`../update-registry.cjs`)、
|
|
386
|
+
// **装也归插件自己**(在 profile 里跑它自己的包管理器,`../update-install.cjs`),
|
|
387
|
+
// **换代码留给外壳**(`lib/index.js` 的 runtime)。市场不再是这条路上的任何一环。
|
|
369
388
|
//
|
|
370
|
-
// 四条都走 localOnly
|
|
389
|
+
// 四条都走 localOnly:`check` 读的是版本情况,另外三条会改盘上的插件(`apply` 直接动
|
|
390
|
+
// profile 的依赖树),全是本机运维动作,不该有局域网 / 中转来的调用者。
|
|
371
391
|
const pluginRuntime = () => {
|
|
372
392
|
try {
|
|
373
393
|
return getPluginRuntime() || null
|
|
@@ -375,23 +395,44 @@ function registerControlPlaneRoutes(deps) {
|
|
|
375
395
|
return null
|
|
376
396
|
}
|
|
377
397
|
}
|
|
378
|
-
const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '
|
|
398
|
+
const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '这一版没有可换代的外壳(插件是被直接挂载的),装完没法就地生效,所以这里不装:无头部署请 rsync 源码后重启' } }
|
|
379
399
|
registerRoute({ kind: 'exact', path: '/api/relay/update/status', handler: localOnly('GET', async (_req, res) => {
|
|
380
400
|
const runtime = pluginRuntime()
|
|
381
401
|
if (!runtime) return sendJson(res, 200, { ok: true, data: { available: false, runningVersion: null, installedVersion: null } })
|
|
382
402
|
return sendJson(res, 200, { ok: true, data: { available: true, ...runtime.status() } })
|
|
383
403
|
}) })
|
|
404
|
+
// 「检查更新」:插件自己问 npm 注册表,**不经过市场、也没有经过外壳**。
|
|
405
|
+
//
|
|
406
|
+
// 刻意不要求外壳在场(所以这一条**不是** `runtimeUnavailable`):查不需要换代能力,
|
|
407
|
+
// 装才需要。从前这条转调 `runtime.check()` → 外壳 → 市场,于是市场没装、太老或者坏了,
|
|
408
|
+
// 「有没有新版」就整个答不出来 —— 一个插件得另一个插件在场才能告诉你自己的版本情况。
|
|
409
|
+
// 盘上版本仍然优先取外壳那份(市场装完立刻变,和运行中版本对照才看得出「要不要换」),
|
|
410
|
+
// 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
|
|
384
411
|
registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
|
|
385
412
|
const runtime = pluginRuntime()
|
|
386
|
-
|
|
413
|
+
const snapshot = runtime ? runtime.status() : null
|
|
387
414
|
try {
|
|
388
|
-
const
|
|
389
|
-
|
|
415
|
+
const update = await checkForUpdate({
|
|
416
|
+
installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
|
|
417
|
+
// 「安装来源」也自己看:profile 的依赖声明里写的是什么(npm / file: / link:)。读不到
|
|
418
|
+
// 才退回外壳上次问市场的记忆 —— 这条路上不为它去敲市场。
|
|
419
|
+
source: declaredSource(snapshot?.profileNodeModules ? path.dirname(snapshot.profileNodeModules) : null) ?? snapshot?.marketResult?.source ?? null,
|
|
420
|
+
})
|
|
421
|
+
return sendJson(res, 200, { ok: true, data: { update, status: snapshot } })
|
|
390
422
|
} catch (error) {
|
|
391
|
-
return sendJson(res, 500, {
|
|
423
|
+
return sendJson(res, 500, {
|
|
424
|
+
ok: false,
|
|
425
|
+
error: { code: 'update_check_failed', message: String(error?.message || error) },
|
|
426
|
+
data: { update: null, status: snapshot },
|
|
427
|
+
})
|
|
392
428
|
}
|
|
393
429
|
}) })
|
|
394
|
-
//
|
|
430
|
+
// 「更新并立即生效」:插件**自己**去 profile 里装新版(`../update-install.cjs`),装完请外壳
|
|
431
|
+
// 就地换代。不再问市场 —— 装这一半也是插件自己的事了(查在同一条路上的 `check`)。
|
|
432
|
+
//
|
|
433
|
+
// 只在有外壳时才装:盘上换了版本、运行中的实现换不掉,等于「装了个用不上的东西」,不如
|
|
434
|
+
// 直说办不了。装失败一律回 200 + 如实的结果(面板照着 `failure` 说人话),只有我们这边
|
|
435
|
+
// 出了意料之外的错才回 500。
|
|
395
436
|
registerRoute({ kind: 'exact', path: '/api/relay/update/apply', handler: localOnly('POST', async (req, res) => {
|
|
396
437
|
const runtime = pluginRuntime()
|
|
397
438
|
if (!runtime) return sendJson(res, 409, runtimeUnavailable)
|
|
@@ -401,14 +442,70 @@ function registerControlPlaneRoutes(deps) {
|
|
|
401
442
|
} catch {
|
|
402
443
|
body = {}
|
|
403
444
|
}
|
|
445
|
+
const force = body.force === true
|
|
446
|
+
const snapshot = runtime.status()
|
|
447
|
+
const beforeVersion = snapshot.installedVersion ?? snapshot.version ?? generationVersion()
|
|
448
|
+
const outcome = {
|
|
449
|
+
installed: false,
|
|
450
|
+
skipped: false,
|
|
451
|
+
state: 'failed',
|
|
452
|
+
beforeVersion,
|
|
453
|
+
installedVersion: beforeVersion,
|
|
454
|
+
swapped: false,
|
|
455
|
+
// 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
|
|
456
|
+
restartRequired: false,
|
|
457
|
+
refreshRequired: false,
|
|
458
|
+
failure: null,
|
|
459
|
+
}
|
|
460
|
+
const answer = (extra = {}) => Object.assign(outcome, extra)
|
|
461
|
+
|
|
462
|
+
const profileNodeModules = snapshot.profileNodeModules ?? null
|
|
463
|
+
if (!profileNodeModules) {
|
|
464
|
+
answer({ failure: { code: 'profile-unknown', message: '宿主没给出 profile 的 node_modules 路径,装不了' } })
|
|
465
|
+
return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: outcome.failure.message }, data: { ...outcome, status: snapshot } })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let target = null
|
|
469
|
+
try {
|
|
470
|
+
const check = await checkForUpdate({
|
|
471
|
+
installedVersion: beforeVersion,
|
|
472
|
+
source: snapshot.marketResult?.source ?? null,
|
|
473
|
+
})
|
|
474
|
+
target = check.latestVersion
|
|
475
|
+
if (!check.updateAvailable && !force) {
|
|
476
|
+
answer({ skipped: true, state: 'succeeded', installedVersion: check.installedVersion ?? beforeVersion })
|
|
477
|
+
return sendJson(res, 200, { ok: true, data: { ...outcome, status: runtime.status() } })
|
|
478
|
+
}
|
|
479
|
+
} catch (error) {
|
|
480
|
+
answer({ failure: { code: 'update_check_failed', message: String(error?.message || error) } })
|
|
481
|
+
return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: outcome.failure.message }, data: { ...outcome, status: snapshot } })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let result
|
|
485
|
+
try {
|
|
486
|
+
result = await installLatest({ profileNodeModules, version: target, force })
|
|
487
|
+
} catch (error) {
|
|
488
|
+
result = { installed: false, installedVersion: beforeVersion, command: null, output: '', failure: { code: 'install_failed', message: String(error?.message || error) } }
|
|
489
|
+
}
|
|
490
|
+
outcome.installedVersion = result.installedVersion ?? beforeVersion
|
|
491
|
+
outcome.failure = result.failure ?? null
|
|
492
|
+
outcome.command = result.command ?? null
|
|
493
|
+
outcome.output = result.output ?? ''
|
|
494
|
+
if (!result.installed) {
|
|
495
|
+
return sendJson(res, 200, { ok: false, data: { ...outcome, status: runtime.status() } })
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 装上了才换代:换代码是外壳的事,失败要如实报(回滚由外壳负责,见 shell/runtime.js)。
|
|
404
499
|
try {
|
|
405
|
-
const
|
|
406
|
-
|
|
500
|
+
const status = await runtime.reload()
|
|
501
|
+
answer({ installed: true, state: 'succeeded', swapped: true, installedVersion: status?.version ?? outcome.installedVersion, refreshRequired: status?.shellChanged === true })
|
|
407
502
|
} catch (error) {
|
|
408
|
-
|
|
503
|
+
answer({ installed: true, state: 'installed-not-swapped', failure: { code: 'swap_failed', message: `新版已经装到盘上,但换代码没成:${String(error?.message || error)}` } })
|
|
409
504
|
}
|
|
505
|
+
return sendJson(res, 200, { ok: true, data: { ...outcome, status: runtime.status() } })
|
|
410
506
|
}) })
|
|
411
|
-
//
|
|
507
|
+
// 自动更新开关(默认关)。开=外壳每 10 分钟问一次有没有新版,有就自己装并就地切换
|
|
508
|
+
// (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
|
|
412
509
|
registerRoute({ kind: 'exact', path: '/api/relay/update/auto', handler: localOnly('POST', async (req, res) => {
|
|
413
510
|
const runtime = pluginRuntime()
|
|
414
511
|
if (!runtime) return sendJson(res, 409, runtimeUnavailable)
|
package/lib/shell/updater.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 更新编排:**查与装都归实现自己**,装完**就地换代**(不重启 DSH)。
|
|
3
3
|
*
|
|
4
4
|
* ## 为什么这件事归外壳做,而不是归面板(浏览器)
|
|
5
5
|
*
|
|
6
6
|
* 面板里那颗「更新」按钮只能覆盖「用户正开着页面点一下」这一种情形;自动更新的定义是
|
|
7
7
|
* **没人看着也要发生**。所以轮询和「装完切换」都必须在宿主进程里做,面板只是给它一个开关。
|
|
8
8
|
*
|
|
9
|
-
* ##
|
|
9
|
+
* ## 装法住哪儿(2026-10-11 改)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 从前「查」和「装」都是请 DSH 插件市场代劳的(打它的 HTTP 接口)。现在两件都归实现自己:
|
|
12
|
+
* 查问 npm 注册表、装在 profile 里跑它自己的包管理器(`lib/update-registry.cjs` /
|
|
13
|
+
* `lib/update-install.cjs`)。外壳**只从盘上那一代**把自装模块取进来跑(`installedInstaller`),
|
|
14
|
+
* 所以换装法、换判定都不用等重启 —— 盘上换了,下一次自动更新就走新的。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
+
* 下面那套市场实现是**兜底**,只在「盘上那一代还没有自装模块」时才走到(老一代、刚回滚到
|
|
17
|
+
* 旧代)。自动更新宁可暂时还走老路,也不能直接坏掉;市场装完会报 `restartRequired: true`,
|
|
18
|
+
* 而本插件有外壳可以自己换代,所以那条路里**故意不调它的重启**。
|
|
19
|
+
*
|
|
20
|
+
* 装完新一代是**新目录**(包管理器换链接),外壳照着新 realpath 重新 import 一代即可,见
|
|
16
21
|
* `lib/shell/manager.js` 与 `lib/shell/runtime.js`。
|
|
17
22
|
*
|
|
18
|
-
* ##
|
|
23
|
+
* ## 兜底那条路不做的事
|
|
19
24
|
*
|
|
20
25
|
* 不下载、不解包、不校验、不写 profile —— 全是市场的职责。它只回答四个问题:有没有新版、
|
|
21
26
|
* 请它装、装完了没、盘上现在是什么版本。
|
|
@@ -25,12 +30,46 @@ import fs from 'node:fs'
|
|
|
25
30
|
import path from 'node:path'
|
|
26
31
|
import { createHash } from 'node:crypto'
|
|
27
32
|
import { createRequire } from 'node:module'
|
|
28
|
-
import {
|
|
33
|
+
import { pathToFileURL } from 'node:url'
|
|
34
|
+
import { installedPackageDir, installedVersion, profileNodeModules } from './manager.js'
|
|
29
35
|
|
|
30
36
|
const require_ = createRequire(import.meta.url)
|
|
31
37
|
const { dshHome } = require_('../shared/paths.js')
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 盘上那一代的「自己装」模块(`lib/update-install.cjs`)。
|
|
42
|
+
*
|
|
43
|
+
* 「装」这件事 2026-10-11 起归**实现**自己:在 profile 里跑它自己的包管理器,不再借道 DSH
|
|
44
|
+
* 插件市场。外壳只负责把盘上那一代的模块取进来跑 —— 从**盘上**(不是外壳自己的目录)取,
|
|
45
|
+
* 是为了「装法改了不用等重启」:市场以外的方式换掉那一代之后,下一次自动更新就走新装法。
|
|
46
|
+
*
|
|
47
|
+
* 取不到(老一代没这个文件、安装目录被判了不存在、模块加载失败)就返回 null,调用方退回
|
|
48
|
+
* 下面那套市场实现 —— 自动更新宁可暂时还走老路,也不能直接坏掉。
|
|
49
|
+
*/
|
|
50
|
+
async function installedInstaller() {
|
|
51
|
+
const dir = installedPackageDirSafe()
|
|
52
|
+
if (!dir) return null
|
|
53
|
+
const file = path.join(dir, 'lib', 'update-install.cjs')
|
|
54
|
+
if (!fs.existsSync(file)) return null
|
|
55
|
+
try {
|
|
56
|
+
const mod = await import(`${pathToFileURL(file).href}?v=${Date.now()}`)
|
|
57
|
+
const fn = mod.installLatestIfNeeded ?? mod.default?.installLatestIfNeeded
|
|
58
|
+
return typeof fn === 'function' ? fn : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `installedPackageDir()` 只是读个路径,读不到不该把自动更新整个抛掉。 */
|
|
65
|
+
function installedPackageDirSafe() {
|
|
66
|
+
try {
|
|
67
|
+
return installedPackageDir()
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
/**
|
|
35
74
|
* 外壳自己的指纹(`lib/index.js` + `lib/shell/*.js` 的内容哈希)。
|
|
36
75
|
*
|
|
@@ -190,6 +229,27 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
|
190
229
|
*/
|
|
191
230
|
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, now = Date.now } = {}) {
|
|
192
231
|
const before = installedVersion()
|
|
232
|
+
// 装归实现自己(2026-10-11 起):盘上那一代里有 `update-install.cjs` 就走它。判定(查一次
|
|
233
|
+
// 有没有新版、`link:` 来源不碰)也一并交给它,别在这里抄第二份 —— 外壳与面板的按钮必须
|
|
234
|
+
// 是同一套判定,否则会出现「手动说有新版、自动说没有」。
|
|
235
|
+
const selfInstall = await installedInstaller()
|
|
236
|
+
if (selfInstall) {
|
|
237
|
+
const outcome = await selfInstall({ profileNodeModules: profileNodeModules(), force, onEvent })
|
|
238
|
+
return {
|
|
239
|
+
installed: outcome.installed === true,
|
|
240
|
+
skipped: outcome.skipped === true,
|
|
241
|
+
state: outcome.installed || outcome.skipped ? 'succeeded' : 'failed',
|
|
242
|
+
beforeVersion: outcome.beforeVersion ?? before,
|
|
243
|
+
installedVersion: outcome.installedVersion ?? before,
|
|
244
|
+
// 外壳就地换代,市场那套「装完要重启」在本项目一律不照做。只为如实上报。
|
|
245
|
+
restartRequired: false,
|
|
246
|
+
refreshRequired: false,
|
|
247
|
+
failure: outcome.failure ?? null,
|
|
248
|
+
retryable: false,
|
|
249
|
+
command: outcome.command ?? null,
|
|
250
|
+
check: outcome.check ?? null,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
193
253
|
const check = await checkUpdate(base, name, { force })
|
|
194
254
|
if (check.source !== null && !updatableSource(check.source)) {
|
|
195
255
|
// 本地 link / file: 装的:市场不会碰它(见 updatableSource)。如实返回,别去试。
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 自己装新版:在 profile 里跑一次它**自己的**包管理器,把本插件换到指定版本。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么不借道 DSH 插件市场
|
|
7
|
+
*
|
|
8
|
+
* 插件的更新分两步:**查**(线上最新是几,见 `update-registry.cjs`)和**装**(把新版本真的
|
|
9
|
+
* 落到这台机器的 profile 里)。2026-10-11 之前「装」是请市场代劳的(打它的
|
|
10
|
+
* `/dsh-market/api/v1/updates`,等它跑完 pnpm 再问结果)。用户的判断是:这件事插件应该
|
|
11
|
+
* 自己完成,不该因为「另一个插件没装 / 太老 / 坏了」就整个失效。这里就是那一步。
|
|
12
|
+
*
|
|
13
|
+
* ## 关键:不自己实现包管理,只**驱动**它
|
|
14
|
+
*
|
|
15
|
+
* profile 里装插件用的是 pnpm,而且 profile 自己的 `pnpm-workspace.yaml` 已经写好了所有
|
|
16
|
+
* 关键设置(`node-linker=hoisted`、`auto-install-peers=false`、`minimum-release-age-exclude`
|
|
17
|
+
* 那些按包放行的条目)。所以这里是「cd 到 profile,跑一句 `pnpm add <包名>@<版本>`」——
|
|
18
|
+
* **不传布局类开关**,让它按 profile 的既有配置来,免得把别人的布局改写掉。市场那套
|
|
19
|
+
* (`pnpm-compat.js` + `install.js`)几千行都在处理 pnpm 各版本的旗标拼写、hoist 恢复、
|
|
20
|
+
* 镜像/超时/Windows shim 这些边角;这里刻意**只做我们自己这条路上必须的那两三个开关**,
|
|
21
|
+
* 剩下的交给 profile 自己的配置。
|
|
22
|
+
*
|
|
23
|
+
* ## 三个开关,每个都有理由
|
|
24
|
+
*
|
|
25
|
+
* · `--config.auto-install-peers=false`:本插件的 peer 里有一串 `@deepseek-ai/*`
|
|
26
|
+
* (`cordis`、`dsh-tools`、`dsh-client-runtime`…),它们是 **DSH 运行时注入的、npm 上
|
|
27
|
+
* 根本没有**。pnpm 8 起 `auto-install-peers` 默认开,会去 npm 上拉这些 peer 然后 404。
|
|
28
|
+
* profile 里通常已经关掉了,但那是市场帮它写的 —— 换台机器不一定有,所以这里显式带上。
|
|
29
|
+
* · `--config.minimum-release-age=0`:**只在强制更新时**带。pnpm 的「新发布先晾一会儿」
|
|
30
|
+
* 门槛会把刚发的版本挡在外面,`force` 就是「我知道,装它」。
|
|
31
|
+
* · `CI=1`:没有终端可交互。包管理器在无人值守的子进程里问问题是没法回答的。
|
|
32
|
+
*
|
|
33
|
+
* ## 边界(照实说)
|
|
34
|
+
*
|
|
35
|
+
* · **只动 profile 的依赖树**(`pnpm add` 会顺手把 profile 的 `package.json` 里的依赖声明
|
|
36
|
+
* 也更新掉——那正是「装上了」的凭据)。不碰仓库、不碰 `~/.dsh` 里别的东西。
|
|
37
|
+
* · **找不到包管理器就如实失败**,不猜、不回退到「假装装好了」。桌面端由 GUI 拉起时 PATH
|
|
38
|
+
* 往往很干净(LaunchServices 只给 `/usr/bin:/bin` 之类),所以这里照市场的做法补一组
|
|
39
|
+
* 常见 bin 目录再找,见 `toolSearchDirs`。
|
|
40
|
+
* · 装完**以盘上的版本号为准**:`installedVersion` 没变就是没装上,哪怕命令退出码是 0。
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const fs = require('node:fs');
|
|
44
|
+
const os = require('node:os');
|
|
45
|
+
const path = require('node:path');
|
|
46
|
+
const { spawn } = require('node:child_process');
|
|
47
|
+
|
|
48
|
+
const { checkForUpdate } = require('./update-registry.cjs');
|
|
49
|
+
|
|
50
|
+
/** 本插件的 npm 包名(`package.json` 的 name)。 */
|
|
51
|
+
const DEFAULT_NAME = 'dsh-mobile-plugin';
|
|
52
|
+
|
|
53
|
+
/** 一次安装的上限:pnpm 装一个包 + 拉依赖,给足但别无限等。 */
|
|
54
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
/** 命令输出留多少字符(日志与错误信息里给个人看得懂的尾巴)。 */
|
|
57
|
+
const OUTPUT_TAIL = 4000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 去哪儿找包管理器。
|
|
61
|
+
*
|
|
62
|
+
* 与市场那份(dshmarket 的 `dsh-cli.js` `toolSearchDirs`)**故意保持一致**:桌面端由 GUI
|
|
63
|
+
* 或 Dock 拉起时继承不到 shell 的 PATH,pnpm 常常不在其中,而这一组目录覆盖了官方安装器
|
|
64
|
+
* 与 Homebrew / npm -g 的落点。`PNPM_HOME` 放最前面:安装器设了它,那是最准的答案。
|
|
65
|
+
*/
|
|
66
|
+
function toolSearchDirs({ platform = process.platform, env = process.env, home = os.homedir(), execDir = path.dirname(process.execPath) } = {}) {
|
|
67
|
+
const dirs = [];
|
|
68
|
+
const pnpmHome = typeof env.PNPM_HOME === 'string' ? env.PNPM_HOME.trim() : '';
|
|
69
|
+
if (pnpmHome) dirs.push(pnpmHome);
|
|
70
|
+
if (platform === 'win32') {
|
|
71
|
+
const local = typeof env.LOCALAPPDATA === 'string' ? env.LOCALAPPDATA.trim() : '';
|
|
72
|
+
const roaming = typeof env.APPDATA === 'string' ? env.APPDATA.trim() : '';
|
|
73
|
+
if (local) dirs.push(path.join(local, 'pnpm'));
|
|
74
|
+
if (roaming) dirs.push(path.join(roaming, 'npm'));
|
|
75
|
+
} else {
|
|
76
|
+
dirs.push('/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local', 'bin'));
|
|
77
|
+
dirs.push(path.join(home, 'Library', 'pnpm'), path.join(home, '.local', 'share', 'pnpm'));
|
|
78
|
+
dirs.push(path.join(home, '.npm-global', 'bin'));
|
|
79
|
+
}
|
|
80
|
+
dirs.push(execDir);
|
|
81
|
+
return [...new Set(dirs.filter(dir => typeof dir === 'string' && dir.trim() !== ''))];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 找一个能跑的可执行文件:先给的那几组目录,再 PATH。Windows 上认 `.cmd` 后缀。 */
|
|
85
|
+
function resolveExecutable(command, dirs, { platform = process.platform, env = process.env } = {}) {
|
|
86
|
+
const names = platform === 'win32' ? [`${command}.cmd`, `${command}.exe`, command] : [command];
|
|
87
|
+
const fromPath = String(env.PATH || '')
|
|
88
|
+
.split(path.delimiter)
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
for (const dir of [...dirs, ...fromPath]) {
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
const candidate = path.join(dir, name);
|
|
93
|
+
try {
|
|
94
|
+
const stat = fs.statSync(candidate);
|
|
95
|
+
if (stat.isFile()) return candidate;
|
|
96
|
+
} catch {
|
|
97
|
+
// 不存在就下一个。
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 把补出来的目录接在 PATH 前面(子进程里的 pnpm 还要再 spawn 它自己的东西)。 */
|
|
105
|
+
function spawnEnv(dirs, env = process.env) {
|
|
106
|
+
const existing = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
107
|
+
return { ...env, PATH: [...dirs, ...existing].join(path.delimiter), CI: env.CI || '1' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** profile 里装着的那份插件包的版本号(读不到返回 null)。 */
|
|
111
|
+
function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = JSON.parse(fs.readFileSync(path.join(profileNodeModules, name, 'package.json'), 'utf8'));
|
|
114
|
+
return typeof raw.version === 'string' && raw.version ? raw.version : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* profile 的依赖声明里这个包是怎么装的(`profileDir` 是 profile 根,不是它的 node_modules)。
|
|
122
|
+
*
|
|
123
|
+
* 只有 `link:` 要拦:那是**故意**把插件指向某个本地目录(开发时),装一次 npm 版会把这个
|
|
124
|
+
* 指向悄悄改掉。`file:` 的 tgz 与普通版号都照装(前者会一并换成注册表来源,这是想要的)。
|
|
125
|
+
* 读不到就说不知道(null),不要瞎猜。
|
|
126
|
+
*/
|
|
127
|
+
function declaredSource(profileDir, name = DEFAULT_NAME) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8'));
|
|
130
|
+
const spec = raw?.dependencies?.[name] ?? raw?.devDependencies?.[name];
|
|
131
|
+
if (typeof spec !== 'string') return null;
|
|
132
|
+
if (spec.startsWith('link:')) return 'linked';
|
|
133
|
+
if (spec.startsWith('file:')) return 'file';
|
|
134
|
+
if (spec.startsWith('workspace:')) return 'workspace';
|
|
135
|
+
return 'npm';
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
142
|
+
return new Promise(resolve => {
|
|
143
|
+
let child;
|
|
144
|
+
try {
|
|
145
|
+
child = spawnImpl(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
resolve({ ok: false, code: null, output: '', error: String(error?.message || error) });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let output = '';
|
|
151
|
+
const collect = chunk => {
|
|
152
|
+
output += String(chunk);
|
|
153
|
+
if (output.length > OUTPUT_TAIL * 2) output = output.slice(-OUTPUT_TAIL);
|
|
154
|
+
};
|
|
155
|
+
child.stdout?.on('data', collect);
|
|
156
|
+
child.stderr?.on('data', collect);
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
try {
|
|
159
|
+
child.kill('SIGKILL');
|
|
160
|
+
} catch {
|
|
161
|
+
// 已经退出了。
|
|
162
|
+
}
|
|
163
|
+
}, timeoutMs);
|
|
164
|
+
child.on('error', error => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
resolve({ ok: false, code: null, output, error: String(error?.message || error) });
|
|
167
|
+
});
|
|
168
|
+
child.on('close', code => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
resolve({ ok: code === 0, code, output: output.slice(-OUTPUT_TAIL), error: null });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 装一次:把 <name> 换到 <version>。
|
|
177
|
+
*
|
|
178
|
+
* @param {object} options
|
|
179
|
+
* @param {string} options.profileNodeModules profile 的 `node_modules` 目录
|
|
180
|
+
* @param {string} options.version 要装的版本(注册表给的)
|
|
181
|
+
* @param {boolean} [options.force] true = 跳过「新发布先晾一会儿」的门槛
|
|
182
|
+
* @returns {Promise<{installed, skipped, beforeVersion, installedVersion, source, command, output, failure}>}
|
|
183
|
+
*/
|
|
184
|
+
async function installLatest({
|
|
185
|
+
profileNodeModules,
|
|
186
|
+
version,
|
|
187
|
+
name = DEFAULT_NAME,
|
|
188
|
+
force = false,
|
|
189
|
+
timeoutMs = INSTALL_TIMEOUT_MS,
|
|
190
|
+
env = process.env,
|
|
191
|
+
platform = process.platform,
|
|
192
|
+
// 去哪儿找包管理器。默认那组见 `toolSearchDirs`;注入只为测试。
|
|
193
|
+
dirs = null,
|
|
194
|
+
onEvent = () => {},
|
|
195
|
+
} = {}) {
|
|
196
|
+
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
197
|
+
const profileDir = path.dirname(profileNodeModules);
|
|
198
|
+
const searchDirs = dirs ?? toolSearchDirs({ platform, env });
|
|
199
|
+
const pnpm = resolveExecutable('pnpm', searchDirs, { platform, env });
|
|
200
|
+
const npm = pnpm ? null : resolveExecutable('npm', searchDirs, { platform, env });
|
|
201
|
+
const manager = pnpm ? 'pnpm' : npm ? 'npm' : null;
|
|
202
|
+
const binary = pnpm || npm;
|
|
203
|
+
|
|
204
|
+
const fail = (code, message) => ({
|
|
205
|
+
installed: false,
|
|
206
|
+
skipped: false,
|
|
207
|
+
beforeVersion,
|
|
208
|
+
installedVersion: installedVersion(profileNodeModules, name),
|
|
209
|
+
source: declaredSource(profileDir, name),
|
|
210
|
+
command: null,
|
|
211
|
+
output: '',
|
|
212
|
+
failure: { code, message },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!binary) {
|
|
216
|
+
return fail(
|
|
217
|
+
'PACKAGE_MANAGER_MISSING',
|
|
218
|
+
'这台机器上找不到 pnpm / npm,装不了新版。装一个 pnpm(或 npm)之后重试;' +
|
|
219
|
+
'插件不会自己去下载解包 —— 那等于把包管理器的活抄一遍,抄漏一处就是装出半个包。',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const source = declaredSource(profileDir, name);
|
|
224
|
+
if (source === 'linked') {
|
|
225
|
+
return fail(
|
|
226
|
+
'LINKED_SOURCE',
|
|
227
|
+
'这个插件在 profile 里指向一个本地目录(link:),是有意为之。自己装会把那个指向改掉,' +
|
|
228
|
+
'所以这里不动它:要去掉这个指向,先在插件市场里按包名重装一次。',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const spec = `${name}@${version}`;
|
|
233
|
+
const args = manager === 'pnpm'
|
|
234
|
+
? ['add', spec, '--config.auto-install-peers=false', ...(force ? ['--config.minimum-release-age=0'] : [])]
|
|
235
|
+
: ['install', spec, '--no-audit', '--no-fund'];
|
|
236
|
+
const command = [manager, ...args].join(' ');
|
|
237
|
+
onEvent({ phase: 'install-started', command });
|
|
238
|
+
|
|
239
|
+
const result = await run(binary, args, { cwd: profileDir, env: spawnEnv(searchDirs, env), timeoutMs });
|
|
240
|
+
const after = installedVersion(profileNodeModules, name);
|
|
241
|
+
if (!result.ok) {
|
|
242
|
+
return {
|
|
243
|
+
installed: false,
|
|
244
|
+
skipped: false,
|
|
245
|
+
beforeVersion,
|
|
246
|
+
installedVersion: after,
|
|
247
|
+
source: declaredSource(profileDir, name),
|
|
248
|
+
command,
|
|
249
|
+
output: result.output,
|
|
250
|
+
// 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
|
|
251
|
+
failure: {
|
|
252
|
+
code: result.code === null ? 'INSTALL_ABORTED' : 'INSTALL_FAILED',
|
|
253
|
+
message: `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// 退出码 0 不算数:**盘上版本真的变了**才算装上(市场那份代码吃过这个亏)。
|
|
258
|
+
const changed = after !== null && after !== beforeVersion;
|
|
259
|
+
return {
|
|
260
|
+
installed: changed,
|
|
261
|
+
skipped: false,
|
|
262
|
+
beforeVersion,
|
|
263
|
+
installedVersion: after,
|
|
264
|
+
source: declaredSource(profileDir, name),
|
|
265
|
+
command,
|
|
266
|
+
output: result.output,
|
|
267
|
+
failure: changed
|
|
268
|
+
? null
|
|
269
|
+
: { code: 'VERSION_UNCHANGED', message: `${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数` },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 「让我变成最新」:先查一次,再决定装不装。
|
|
275
|
+
*
|
|
276
|
+
* 面板那颗按钮**与外壳的自动更新**都打这个入口 —— 两处走同一套判定,免得出现「手动说有
|
|
277
|
+
* 新版、自动说没有」这种最难查的偏差。已经是最新(`force` 为假)就什么都不做、如实报
|
|
278
|
+
* `skipped`。
|
|
279
|
+
*/
|
|
280
|
+
async function installLatestIfNeeded(options = {}) {
|
|
281
|
+
const { profileNodeModules, force = false, name = DEFAULT_NAME } = options;
|
|
282
|
+
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
283
|
+
const check = await checkForUpdate({ installedVersion: beforeVersion, source: declaredSource(path.dirname(profileNodeModules), name) });
|
|
284
|
+
if (!force && !check.updateAvailable) {
|
|
285
|
+
return {
|
|
286
|
+
installed: false,
|
|
287
|
+
skipped: true,
|
|
288
|
+
beforeVersion,
|
|
289
|
+
installedVersion: beforeVersion,
|
|
290
|
+
source: check.source,
|
|
291
|
+
command: null,
|
|
292
|
+
output: '',
|
|
293
|
+
failure: null,
|
|
294
|
+
check,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const result = await installLatest({ ...options, version: check.latestVersion });
|
|
298
|
+
return { ...result, check };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
installLatest,
|
|
303
|
+
installLatestIfNeeded,
|
|
304
|
+
installedVersion,
|
|
305
|
+
declaredSource,
|
|
306
|
+
toolSearchDirs,
|
|
307
|
+
resolveExecutable,
|
|
308
|
+
DEFAULT_NAME,
|
|
309
|
+
INSTALL_TIMEOUT_MS,
|
|
310
|
+
OUTPUT_TAIL,
|
|
311
|
+
};
|