dsh-mobile-plugin 0.4.8 → 0.4.9

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.
@@ -18,7 +18,7 @@
18
18
  const path = require('node:path');
19
19
  const { sendJson, readJsonBody } = require('../shared/http-util');
20
20
  const { checkForUpdate } = require('../update-registry.cjs');
21
- const { declaredSource } = require('../update-install.cjs');
21
+ const { declaredSource, profileRegistry } = require('../update-install.cjs');
22
22
  const { version: generationVersion } = require('../../package.json');
23
23
 
24
24
  /** apply 的请求体只有 `force` 一个布尔,给 4KB 都很宽。 */
@@ -113,9 +113,24 @@ async function handlePluginUpdateStatus(ctx, _req, res) {
113
113
  async function handlePluginUpdateCheck(ctx, _req, res) {
114
114
  const status = snapshot(ctx);
115
115
  try {
116
+ // 与装用同一个源:问这台机器上 pnpm 真正在用的注册表(镜像 / 私有源机器上,写死官方源
117
+ // 等于永远查不到)。问不到才退回官方源。
118
+ const runtime = runtimeOf(ctx);
119
+ const live = runtime && typeof runtime.status === 'function' ? runtime.status() : null;
120
+ let packageManager = null;
121
+ try {
122
+ packageManager = typeof ctx?.get === 'function' ? ctx.get('profileContext')?.packageManager ?? null : null;
123
+ } catch {
124
+ packageManager = null;
125
+ }
126
+ const registry = await profileRegistry({
127
+ profileNodeModules: typeof live?.profileNodeModules === 'string' ? live.profileNodeModules : null,
128
+ packageManager,
129
+ });
116
130
  const update = await checkForUpdate({
117
131
  installedVersion: status.installedVersion ?? status.runningVersion ?? generationVersion,
118
132
  source: status.source,
133
+ base: registry ?? undefined,
119
134
  });
120
135
  sendJson(res, 200, { ok: true, update, status });
121
136
  } catch (error) {
@@ -38,13 +38,26 @@ function createReconnectScheduler({
38
38
  let timer = null
39
39
  let misses = 0
40
40
  return {
41
- /** 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。 */
42
- schedule() {
41
+ /**
42
+ * 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。
43
+ *
44
+ * `overrideMs` 是「这台不是坏了、是被人挡在门外」那类失败的专用间隔(目前只有中转回的
45
+ * `plugin_too_old`):它绕开指数退避、也不吃 `maxDelayMs` 的封顶。给一个固定间隔而不是
46
+ * 退避,是因为窗口是按「人去更新插件」量级的,翻倍退避在这一档上没有意义。
47
+ */
48
+ schedule(overrideMs) {
43
49
  if (timer !== null) return false
44
- const backoff = misses === 0 ? 1 : 2 ** Math.min(misses, BACKOFF_EXP_CAP)
45
- const base = Math.min(delayMs * backoff, maxDelayMs)
46
- const wait = base + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0)
47
- misses += 1
50
+ let wait
51
+ if (typeof overrideMs === 'number' && overrideMs > 0) {
52
+ wait = overrideMs + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0)
53
+ } else {
54
+ const backoff = misses === 0 ? 1 : 2 ** Math.min(misses, BACKOFF_EXP_CAP)
55
+ const base = Math.min(delayMs * backoff, maxDelayMs)
56
+ wait = base + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0)
57
+ // 指定间隔那一档**不动这笔账**:它不是「连续失败」,不该把之后正常的掉线重连
58
+ // 一起推成 10s、20s。被版本挡住之后再恢复,仍要从 5s 起算。
59
+ misses += 1
60
+ }
48
61
  timer = setTimer(() => { timer = null; onFire() }, wait)
49
62
  return true
50
63
  },
@@ -17,6 +17,10 @@ const { isFreshRequest } = require('../shared/fresh-request');
17
17
  const { encodeJsonBody } = require('../shared/http-util');
18
18
  const { withViaHeader } = require('../shared/via-header');
19
19
 
20
+ // 上报给中转的插件版本:只有包自己的 package.json 这一个来源(与 lib/relay/status.js 同口径)。
21
+ // 中转靠它判「这台宿主是不是太旧」,见 relay-core 的 PROTOCOL.md 的 register 那行。
22
+ const { version: PLUGIN_VERSION } = require('../../package.json');
23
+
20
24
  const HEARTBEAT_INTERVAL_MS = 15_000;
21
25
  // 中转 60s 收不到 pong 就把这条隧道 terminate 掉(TUNNEL_PONG_TIMEOUT_MS)。宿主必须比它
22
26
  // 先动或同时动:反过来就是「被踢的那一刻我们还以为连着」,那段时间手机发来的每个请求都要
@@ -91,6 +95,10 @@ class RelayTunnel {
91
95
  // 不差。纯转发节点(`MIRROR_CACHE=0`)收下整份镜像也只解析完丢掉,worker 据此只把
92
96
  // 摘要推上去(lib/sidecar-facade-worker.cjs 的 digestFrame)。
93
97
  this.relayMirror = true;
98
+ // 中转在注册阶段拒绝时回的那条控制帧(`code`/`message`)。宿主据此把「版本过旧」和
99
+ // 「连不上」分开:两件事的重连节奏差三个数量级(见 sidecar-facade-worker 的
100
+ // OUTDATED_RETRY_MS),混在一起就会拿 5 秒退避一路撞门。
101
+ this.lastControlError = null;
94
102
  // 清除所有授权的等待回调(一次一个);由 requestRevokeAll 注入,revokeAllAck 触发。
95
103
  this._revokeAllAck = null;
96
104
  }
@@ -405,6 +413,8 @@ class RelayTunnel {
405
413
  return {
406
414
  instanceId: this.instanceId,
407
415
  name: this.name,
416
+ // 中转用它判版本门槛(MIN_PLUGIN_VERSION);老中转不读它,多一个字段没有影响。
417
+ version: PLUGIN_VERSION,
408
418
  // 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
409
419
  ...(this.registerSecret ? { secret: this.registerSecret } : {}),
410
420
  ...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
@@ -469,6 +479,7 @@ class RelayTunnel {
469
479
  // 每条新连接都从「对端存镜像」重新判定:能走到推送的那一步一定已经收到过回执,
470
480
  // 而回执缺这个字段的老中转本来就该按「存」处理。
471
481
  this.relayMirror = true;
482
+ this.lastControlError = null;
472
483
  return new Promise((resolve, reject) => {
473
484
  let settled = false;
474
485
  const done = (fn, arg) => {
@@ -520,7 +531,15 @@ class RelayTunnel {
520
531
  this.cancelledHttp.clear();
521
532
  this.cancelledWs.clear();
522
533
  this.onClose(code, reason);
523
- done(reject, new Error(`隧道关闭: ${code} ${reason}`));
534
+ const closed = new Error(`隧道关闭: ${code} ${reason}`);
535
+ // 中转在关掉之前说过原因(比如「插件太旧」)就把它挂到 error 上:调用方要按 code
536
+ // 分流重连节奏,别把「被拒」当成「网络不好」按 5 秒退避一路撞下去。
537
+ if (this.lastControlError && typeof this.lastControlError.code === 'string') {
538
+ closed.code = this.lastControlError.code;
539
+ closed.pluginMessage = this.lastControlError.message || '';
540
+ closed.retryAfterMs = Number(this.lastControlError.retryAfterMs) || 0;
541
+ }
542
+ done(reject, closed);
524
543
  });
525
544
  ws.on('error', (err) => {
526
545
  clearTimeout(timer);
@@ -573,6 +592,12 @@ class RelayTunnel {
573
592
  // ── 帧分发 ────────────────────────────────────────────────────────
574
593
  handleFrame(frame) {
575
594
  const { type, id, payload } = frame;
595
+ // id=0 的 wsError 是中转在断开之前给的一句话(现在还只用于「注册被拒」):先记下来,
596
+ // 等随后的 close 回调把它带进 start() 的 reject,调用方按 `code` 分流。
597
+ if (type === T.wsError && id === 0) {
598
+ this.lastControlError = parseJson(payload);
599
+ return;
600
+ }
576
601
  switch (type) {
577
602
  case T.httpRequest: {
578
603
  const msg = parseJson(payload);
@@ -44,7 +44,7 @@ const { createSelfCheck } = require('../mobile/self-check.cjs');
44
44
  // 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
45
45
  // 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
46
46
  const { checkForUpdate } = require('../update-registry.cjs');
47
- const { declaredSource } = require('../update-install.cjs');
47
+ const { declaredSource, profileRegistry } = require('../update-install.cjs');
48
48
  const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
49
49
  const { recordSessionRead } = require('../mobile/session-last-turn.js');
50
50
  // 收件目录的占用与清理:和移动端那条路由是同一份实现(`lib/mobile/inbox-clean.js`)。
@@ -253,6 +253,19 @@ function registerControlPlaneRoutes(deps) {
253
253
  */
254
254
  dropDeviceStreams = () => 0,
255
255
  } = deps;
256
+ /**
257
+ * 宿主发布的包管理器调用方式(`profileContext.packageManager`)。
258
+ *
259
+ * 「查更新」要用它去问这台机器上 pnpm 真正在用的注册表;拿不到就当没有(退回官方源),
260
+ * 不影响其它路由。
261
+ */
262
+ const profilePackageManager = () => {
263
+ try {
264
+ return typeof ctx?.get === 'function' ? ctx.get('profileContext')?.packageManager ?? null : null
265
+ } catch {
266
+ return null
267
+ }
268
+ }
256
269
  const selfCheck = createSelfCheck({
257
270
  ctx,
258
271
  credentials,
@@ -730,11 +743,18 @@ function registerControlPlaneRoutes(deps) {
730
743
  // 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
731
744
  const snapshot = runtime ? { available: true, ...runtime.status() } : null
732
745
  try {
746
+ // 查也跟装用同一个源:问这台机器上 pnpm 真正在用的注册表(镜像 / 私有源机器上,
747
+ // 写死官方源等于永远查不到)。问不到才退回官方源。
748
+ const registry = await profileRegistry({
749
+ profileNodeModules: typeof snapshot?.profileNodeModules === 'string' ? snapshot.profileNodeModules : null,
750
+ packageManager: profilePackageManager(),
751
+ })
733
752
  const update = await checkForUpdate({
734
753
  installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
735
754
  // 「安装来源」也自己看:profile 的依赖声明里写的是什么(npm / file: / link:)。读不到
736
755
  // 才退回外壳上次问市场的记忆 —— 这条路上不为它去敲市场。
737
756
  source: declaredSource(snapshot?.profileNodeModules ? path.dirname(snapshot.profileNodeModules) : null) ?? snapshot?.marketResult?.source ?? null,
757
+ base: registry ?? undefined,
738
758
  })
739
759
  return sendJson(res, 200, { ok: true, data: { update, status: snapshot } })
740
760
  } catch (error) {
@@ -89,6 +89,37 @@ const reconnect = createReconnectScheduler({
89
89
  onFire: () => startTunnel(),
90
90
  })
91
91
 
92
+ /**
93
+ * 被中转按「插件太旧」挡下来时的重连间隔。
94
+ *
95
+ * 这一档不是故障:链路是通的,是这台的插件版本低于中转的门槛(`MIN_PLUGIN_VERSION`)。
96
+ * 按 5 秒退避重连一点用都没有——门槛不会因为多敲几次就降下来,而所有老宿主一起敲会把
97
+ * 中转的握手与日志刷满。半小时一次既能让「装上新版之后半小时内自动恢复」,也不会给中转
98
+ * 添负担。宿主重启(装完插件之后通常就是重启 DSH)会立刻重来一次。
99
+ */
100
+ const OUTDATED_RETRY_MS = 30 * 60 * 1000
101
+
102
+ /**
103
+ * 被中转以「插件太旧」挡下来时的收尾:状态另立成 `outdated`、记录那句话、三十分钟后再试。
104
+ *
105
+ * 单独成函数而不是散在两处回调里,是因为它必须**带实例身份闸**(`telemetry` 之外的任何
106
+ * 状态写入点都要),而回调里那一行闸只管得到紧挨着的那一句;收成一个有闸的入口,
107
+ * 两处调用就都只有一行、也不会漏。
108
+ *
109
+ * 返回 true 表示这一轮已经按「版本过旧」处理完了,调用方别再走普通断线那条路。
110
+ */
111
+ function markOutdated(current) {
112
+ if (tunnel !== current) return true
113
+ const info = (current && current.lastControlError) || null
114
+ if (!info || info.code !== 'plugin_too_old') return false
115
+ tunnelState = 'outdated'
116
+ tunnelError = info.message || '插件版本过旧,请更新'
117
+ publishState()
118
+ reconnect.cancel()
119
+ reconnect.schedule(OUTDATED_RETRY_MS)
120
+ return true
121
+ }
122
+
92
123
  function send(res, code, value) {
93
124
  if (res.headersSent) return res.end()
94
125
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -336,7 +367,13 @@ function openTunnel() {
336
367
  if (tunnel !== current) return; reconnect.reset()
337
368
  tunnelState = 'connected'; tunnelError = null; relayMirror = current.relayMirror !== false; publishState()
338
369
  },
339
- onClose: () => { if (tunnel === current && config.enabled) { tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect() } },
370
+ onClose: () => {
371
+ if (tunnel !== current || !config.enabled) return
372
+ tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect()
373
+ // 中转在关掉之前说过「插件太旧」的话,这一档要改口径:状态另立成 outdated、复位成
374
+ // 三十分钟一次。markOutdated 自带实例身份闸,改完直接盖掉上面那句「连接断开」。
375
+ markOutdated(current)
376
+ },
340
377
  onPairRequest: async (message) => {
341
378
  try {
342
379
  const result = await parentJson('/api/relay/internal/approval', { device: message.device || {} })
@@ -351,7 +388,12 @@ function openTunnel() {
351
388
  const ready = current.start()
352
389
  // 挂了就如实报,并按 5s 起退避重连(旧实现在 startTunnel 的 catch 里做同一件事)。
353
390
  // 换地址失败时这里也会排一次:配置里已经是新地址,值得继续试它,而不是悄悄退回旧地址。
354
- ready.catch(error => { if (tunnel === current) { tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect() } })
391
+ ready.catch(error => {
392
+ if (tunnel !== current) return
393
+ tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect()
394
+ // 与 onClose 同一套判据:被中转挡住(版本过旧)时改写状态并改排三十分钟,别落进 5 秒退避。
395
+ markOutdated(current)
396
+ })
355
397
  return ready
356
398
  }
357
399
  function startTunnel() {
@@ -7,16 +7,26 @@
7
7
  * 另一个插件没装、太老或坏了就整个失效。
8
8
  *
9
9
  * 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
10
- * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。三处必要
11
- * 的开关:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、CI=1(无终端),以及
12
- * 撞上 pnpm「新发布先晾一会儿」时带 minimum-release-age=0 重试一次(那道策略查整份 lockfile,
13
- * 别的插件刚发也会连累我们)。
10
+ * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
11
+ * 开关有两层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
12
+ * (2026-10-26 用户要求一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
13
+ * lockfile,别的插件刚发也会连累我们),再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
14
+ * pnpm 12 的原生 CLI 会静默忽略这两个 --config 参数,环境变量那一份还认。
15
+ *
16
+ * 装完要**登记**:这个版本是被我们绕过晾置期装进去的,不写进 profile 的 minimumReleaseAgeExclude,
17
+ * 它就会留在 lockfile 里,之后 pnpm 校验整份 lockfile 时把这台机器上所有安装都拒掉
18
+ * (官方桌面端不替用户临时放行,只能干等 24 小时)。
14
19
  *
15
20
  * 包管理器优先用宿主发布的那个(profile 是宿主自带 pnpm 装的,PATH 上版本不同会
16
21
  * ERR_PNPM_UNEXPECTED_STORE),找不到再退回 PATH。装之前先看有没有 agent 在跑;force 与 auto
17
- * 都跳过这道闸、也都不等发布晾置期(2026-10-26 用户要求自动更新一检测到新版就立刻装,与面板
18
- * 上那颗「强制更新」同一个口径),两者的区别只剩「没新版时装不装」。顺序上先答「有没有新版」,
19
- * 没东西可装时不该拿「有对话在跑」挡在前面。
22
+ * 都跳过这道闸(2026-10-26 用户要求自动更新一检测到新版就立刻装,与面板上那颗「强制更新」
23
+ * 同一个口径),两者的区别只剩「没新版时装不装」。顺序上先答「有没有新版」,没东西可装时
24
+ * 不该拿「有对话在跑」挡在前面。
25
+ *
26
+ * 宿主环境对不上时先修一次再重试:node_modules 是旧版 pnpm 建的(两个 hoist 相关的
27
+ * ERR_PNPM_*_DIFF)就重建依赖树;记录的 store 和现在解析的不是同一个(ERR_PNPM_UNEXPECTED_STORE)
28
+ * 就把记录里的那个 store 写回 profile 的 storeDir。两种都只修一次,修不了就如实失败 —— 这类
29
+ * 现场不修的话,这台机器之后每一次更新都会撞同一堵墙。
20
30
  *
21
31
  * 只动 profile 的依赖树;找不到包管理器就如实失败;装完以盘上版本号为准,退出码 0 不算装上。
22
32
  */
@@ -34,6 +44,9 @@ const DEFAULT_NAME = 'dsh-mobile-plugin';
34
44
  /** 一次安装的上限:pnpm 装一个包 + 拉依赖,给足但别无限等。 */
35
45
  const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
36
46
 
47
+ /** 依赖树是旧版 pnpm 建的:这两种报错都只说明「要重建」,不是这一版有问题。 */
48
+ const HOIST_DIFF = /ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF|ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF/;
49
+
37
50
  /** 命令输出留多少字符(日志与错误信息里给个人看得懂的尾巴)。 */
38
51
  const OUTPUT_TAIL = 4000;
39
52
 
@@ -88,6 +101,90 @@ function spawnEnv(dirs, env = process.env) {
88
101
  return { ...env, PATH: [...dirs, ...existing].join(path.delimiter), CI: env.CI || '1' };
89
102
  }
90
103
 
104
+ /**
105
+ * 每次调 pnpm 都带上的那两格策略开关,**环境变量这一份不是备份,是另一半**。
106
+ *
107
+ * pnpm 11 两种写法都认;pnpm 12 的原生 CLI 会静默忽略部分 `--config.*`(市场在 #600 上实测
108
+ * 过,一次装失败一次白等),但同一个键走 `PNPM_CONFIG_*` 它照读。命令行与环境变量各带一份,
109
+ * 换哪个大版本都不用重新猜。
110
+ */
111
+ const POLICY_ENV = {
112
+ PNPM_CONFIG_AUTO_INSTALL_PEERS: 'false',
113
+ PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0',
114
+ };
115
+
116
+ /** 「配置里这个包用的是哪个注册表」缓存多久:查更新两分钟一轮,源不常换。 */
117
+ const REGISTRY_CACHE_MS = 5 * 60 * 1000;
118
+
119
+ /** profile 目录 → 上一次问到的注册表(问不到也记,省得每轮重跑一次包管理器)。 */
120
+ const registryCache = new Map();
121
+
122
+ /**
123
+ * 从 `<pm> config get registry` 的输出里取地址。
124
+ *
125
+ * pnpm / npm 都只回一行地址;升级提示、警告可能在前面,所以从后往前找第一个 http(s) 行。
126
+ */
127
+ function parseRegistryOutput(output) {
128
+ const lines = String(output || '').split('\n').map(line => line.trim()).filter(Boolean);
129
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
130
+ if (/^https?:\/\/\S+$/.test(lines[i])) return lines[i].replace(/\/+$/, '');
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * 这台机器上 pnpm / npm 真正会用的注册表。
137
+ *
138
+ * 问的是包管理器自己(`config get registry`),不是我们读文件猜:registry 可能来自 profile 的
139
+ * `.npmrc`、用户级 rc、pnpm-workspace.yaml 的 registries,还可以是环境变量改的 —— 只有它自己
140
+ * 的解析链能给出同一个答案。查更新与真正安装因此走同一个源,不会「查得到、装不上」,也是
141
+ * 镜像 / 私有源机器上自动更新还能自己动的前提。
142
+ *
143
+ * 拿不到(没有包管理器、命令超时、输出里没有地址)就回 null,调用方退回官方源:
144
+ * 这条链上多一次探测不该把「检查更新」变成不可用。`DSHM_NPM_REGISTRY` 显式给了值时不探,
145
+ * 那是给人排查 / 私有部署用的最高优先级。
146
+ */
147
+ async function profileRegistry({
148
+ profileNodeModules,
149
+ packageManager = null,
150
+ env = process.env,
151
+ platform = process.platform,
152
+ dirs = null,
153
+ timeoutMs = 20000,
154
+ cache = registryCache,
155
+ now = Date.now,
156
+ } = {}) {
157
+ // `DSHM_NPM_REGISTRY` 显式给了值就不探:那是给人排查 / 私有部署用的最高优先级,
158
+ // 而且查更新那一半读的是进程环境,这里不能只看子进程那一份。
159
+ const override = String(env.DSHM_NPM_REGISTRY || process.env.DSHM_NPM_REGISTRY || '').trim();
160
+ if (!profileNodeModules || override !== '') return null;
161
+ const profileDir = path.dirname(profileNodeModules);
162
+ const hit = cache.get(profileDir);
163
+ if (hit && now() - hit.at < REGISTRY_CACHE_MS) return hit.registry;
164
+ const searchDirs = dirs ?? toolSearchDirs({ platform, env });
165
+ const childEnv = spawnEnv(searchDirs, env);
166
+ const hostInvocation = normalizeInvocation(packageManager);
167
+ const candidates = [];
168
+ if (hostInvocation) candidates.push({ command: hostInvocation.command, prefix: hostInvocation.args, extraEnv: hostInvocation.env });
169
+ const pnpm = resolveExecutable('pnpm', searchDirs, { platform, env });
170
+ if (pnpm) candidates.push({ command: pnpm, prefix: [], extraEnv: {} });
171
+ const npm = resolveExecutable('npm', searchDirs, { platform, env });
172
+ if (npm) candidates.push({ command: npm, prefix: [], extraEnv: {} });
173
+ let registry = null;
174
+ for (const candidate of candidates) {
175
+ const result = await run(candidate.command, [...candidate.prefix, 'config', 'get', 'registry'], {
176
+ cwd: profileDir,
177
+ env: { ...childEnv, ...candidate.extraEnv },
178
+ timeoutMs,
179
+ });
180
+ if (!result.ok) continue;
181
+ registry = parseRegistryOutput(result.output);
182
+ if (registry) break;
183
+ }
184
+ cache.set(profileDir, { at: now(), registry });
185
+ return registry;
186
+ }
187
+
91
188
  /** profile 里装着的那份插件包的版本号(读不到返回 null)。 */
92
189
  function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
93
190
  try {
@@ -155,7 +252,136 @@ function installAllowedFor(source) {
155
252
  }
156
253
 
157
254
  /**
158
- * 宿主**发布出来**的包管理器调用方式(DSH 桌面版把它挂在 `profileContext.packageManager`
255
+ * 把一条「这一版是我故意装的」写进 profile 的 `minimumReleaseAgeExclude`。
256
+ *
257
+ * 为什么非要写:我们是用一次性开关把 24 小时内的版本装进去的,那个开关只对那一条命令生效。
258
+ * 这一版随后留在 lockfile 里,pnpm 11 在**任何** add / remove 之前都会拿当前策略校验整份
259
+ * lockfile —— 官方桌面端不替用户临时放行,于是这台机器上所有插件安装都要干等到它满 24 小时,
260
+ * 报错还会点名我们这个包。写进去就是把「我信任这一版」这件事落在配置里,撤销的代价只有一行。
261
+ *
262
+ * 规则跟市场那边一致:**同名只读第一条**(pnpm 的 #883),所以必须并进已有的那一行,
263
+ * 不能另起一行;条目可以是 `name@v || name@v2`,也可能是带引号的。单行的 flow 写法
264
+ * (`minimumReleaseAgeExclude: [a, b]`)不碰 —— 改坏了比不写更贵,而 pnpm 自己写出来的是块形式。
265
+ *
266
+ * 失败一律返回 false,不抛:登记是善后,不是更新本身,它出问题不该把装好的版本说成没装上。
267
+ */
268
+ function recordReleaseAgeExclusion(profileDir, name, version) {
269
+ const file = path.join(profileDir, 'pnpm-workspace.yaml');
270
+ let text;
271
+ try {
272
+ text = fs.readFileSync(file, 'utf8');
273
+ } catch {
274
+ // 没有 workspace 文件就什么都不做:替人发明一个会改掉 pnpm 认的 workspace 根。
275
+ return false;
276
+ }
277
+ const lines = text.split('\n');
278
+ const at = lines.findIndex(line => /^minimumReleaseAgeExclude\s*:/.test(line));
279
+ if (at !== -1) {
280
+ const inline = lines[at].replace(/^minimumReleaseAgeExclude\s*:/, '').trim();
281
+ if (inline !== '' && !inline.startsWith('#')) return false;
282
+ let end = at;
283
+ for (let i = at + 1; i < lines.length; i += 1) {
284
+ const line = lines[i];
285
+ if (/^\s*$/.test(line) || /^\s*#/.test(line) || /^\s+-/.test(line)) {
286
+ end = i;
287
+ continue;
288
+ }
289
+ break;
290
+ }
291
+ for (let i = at + 1; i <= end; i += 1) {
292
+ const match = /^(\s*-\s*)(.*)$/.exec(lines[i]);
293
+ if (!match) continue;
294
+ const entry = parseExcludeEntry(match[2]);
295
+ if (entry === null || entry.name !== name) continue;
296
+ if (entry.versions.includes(version)) return true;
297
+ lines[i] = `${match[1]}${addVersionToEntry(match[2], version)}`;
298
+ fs.writeFileSync(file, lines.join('\n'));
299
+ return true;
300
+ }
301
+ let indent = ' ';
302
+ for (let i = at + 1; i <= end; i += 1) {
303
+ const match = /^(\s+)-/.exec(lines[i]);
304
+ if (match) {
305
+ indent = match[1];
306
+ break;
307
+ }
308
+ }
309
+ lines.splice(end + 1, 0, `${indent}- ${excludeScalar(name, version)}`);
310
+ fs.writeFileSync(file, lines.join('\n'));
311
+ return true;
312
+ }
313
+ const tail = text.replace(/\s*$/, '');
314
+ fs.writeFileSync(file, `${tail}${tail ? '\n' : ''}minimumReleaseAgeExclude:\n - ${excludeScalar(name, version)}\n`);
315
+ return true;
316
+ }
317
+
318
+ /** 一条排除规则:包名 + 已列举的版本(拼在 ` || ` 后面)。看不懂就回 null,别猜。 */
319
+ function parseExcludeEntry(raw) {
320
+ let body = String(raw || '').trim();
321
+ if (body === '' || body.startsWith('#')) return null;
322
+ const quote = /^(['"])(.*)\1\s*$/.exec(body);
323
+ if (quote) {
324
+ body = quote[2];
325
+ } else {
326
+ const hash = body.search(/\s#/);
327
+ if (hash !== -1) body = body.slice(0, hash).trim();
328
+ }
329
+ const parts = body.split('||').map(part => part.trim()).filter(Boolean);
330
+ if (parts.length === 0) return null;
331
+ const at = parts[0].lastIndexOf('@');
332
+ if (at <= 0) return null;
333
+ return {
334
+ name: parts[0].slice(0, at),
335
+ versions: parts.map(part => part.slice(part.lastIndexOf('@') + 1)),
336
+ };
337
+ }
338
+
339
+ /** 往一条规则后面追版本,引号与行尾注释原样保留。 */
340
+ function addVersionToEntry(raw, version) {
341
+ const body = String(raw).trim();
342
+ const quote = /^(['"])(.*)\1\s*$/.exec(body);
343
+ if (quote) return `${quote[1]}${quote[2]} || ${version}${quote[1]}`;
344
+ const hash = body.search(/\s#/);
345
+ if (hash === -1) return `${body} || ${version}`;
346
+ return `${body.slice(0, hash)} || ${version}${body.slice(hash)}`;
347
+ }
348
+
349
+ /** 写进 YAML 的一条新规则;`@scope/...` 这种以 `@` 开头的标量必须带引号。 */
350
+ function excludeScalar(name, version) {
351
+ const value = `${name}@${version}`;
352
+ return value.startsWith('@') ? `'${value}'` : value;
353
+ }
354
+
355
+ /**
356
+ * pnpm 说「记录的 store 和现在解析的不是同一个」时,把记录里的那个写回 profile。
357
+ *
358
+ * 这是 pnpm 报错里给的持久修法(`store-dir` 只在命令行那一次有效,改不改写
359
+ * `node_modules/.modules.yaml` 里的记录才是关键)。只加、不改:已经有 `storeDir` 就不动,
360
+ * 那种情况不是我们该替人翻的。
361
+ */
362
+ function ensureProfileStoreDir(profileDir, storeDir) {
363
+ const file = path.join(profileDir, 'pnpm-workspace.yaml');
364
+ let text;
365
+ try {
366
+ text = fs.readFileSync(file, 'utf8');
367
+ } catch {
368
+ return false;
369
+ }
370
+ if (/^storeDir\s*:/m.test(text)) return false;
371
+ const quoted = /[\s#:'"]/.test(storeDir) ? JSON.stringify(storeDir) : storeDir;
372
+ const tail = text.replace(/\s*$/, '');
373
+ fs.writeFileSync(file, `${tail}${tail ? '\n' : ''}storeDir: ${quoted}\n`);
374
+ return true;
375
+ }
376
+
377
+ /** `child` 是不是 `parent` 以内(用来确认报错里的 node_modules 真是这个 profile 的)。 */
378
+ function isInsideDir(parent, child) {
379
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
380
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
381
+ }
382
+
383
+ /**
384
+ * 宿主**发布出来的**包管理器调用方式(DSH 桌面版把它挂在 `profileContext.packageManager`
159
385
  * 上,形状 `{command, args, env?}`)。
160
386
  *
161
387
  * 为什么非得用它、而不是 PATH 上那个 pnpm:桌面版由 GUI 拉起时 PATH 很干净,而且**版本
@@ -243,18 +469,15 @@ function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
243
469
  * @param {object} options
244
470
  * @param {string} options.profileNodeModules profile 的 `node_modules` 目录
245
471
  * @param {string} options.version 要装的版本(注册表给的)
246
- * @param {boolean} [options.force] true = 跳过「新发布先晾一会儿」的门槛
247
- * @param {boolean} [options.auto] 自动更新那一轮:同样跳过发布晾置期(见文件头)
248
472
  * @param {() => any} [options.beforeInstall] 真正跑包管理器**之前**调一次(外壳用它把正在跑的
249
473
  * 那一代复制进回退目录)。包管理器一跑就把旧的 store 目录清掉了,晚一步没得复制。
250
- * @returns {Promise<{installed, skipped, beforeVersion, installedVersion, source, command, output, failure}>}
474
+ * @returns {Promise<{installed, skipped, beforeVersion, installedVersion, source, command,
475
+ * manager, healed, releaseAgeRecorded, output, failure}>}
251
476
  */
252
477
  async function installLatest({
253
478
  profileNodeModules,
254
479
  version,
255
480
  name = DEFAULT_NAME,
256
- force = false,
257
- auto = false,
258
481
  timeoutMs = INSTALL_TIMEOUT_MS,
259
482
  env = process.env,
260
483
  platform = process.platform,
@@ -333,41 +556,55 @@ async function installLatest({
333
556
  }
334
557
 
335
558
  const spec = `${name}@${version}`;
336
- const releaseAgeFlag = '--config.minimum-release-age=0';
337
- // `auto` 与 `force` 一样一开始就带上开关:自动更新要「一检测到就装」,不该先被那道策略挡回来
338
- // 再重试一遍(多等一轮、日志里多一条失败)。
339
- const skipReleaseAge = force || auto;
559
+ // 一律带上:自动更新是「一检测到就装」,手动点的那一下同样是「现在就要」,没理由先让这道
560
+ // 策略挡一次、再靠失败重试绕过来。命令行与环境变量各带一份,pnpm 12 只认后者(见 POLICY_ENV)。
561
+ const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0'];
340
562
  const args = picked.manager === 'pnpm'
341
- ? ['add', spec, '--config.auto-install-peers=false', ...(skipReleaseAge ? [releaseAgeFlag] : [])]
563
+ ? ['add', spec, ...policyArgs]
342
564
  : ['install', spec, '--no-audit', '--no-fund'];
343
- let command = [picked.manager, ...args].join(' ');
565
+ const command = [picked.manager, ...args].join(' ');
344
566
  onEvent({ phase: 'install-started', command, manager: picked.kind });
345
567
 
346
- const spawnAdd = extra => run(picked.command, [...picked.prefix, ...args, ...extra], {
568
+ const runManager = (extraArgs, baseArgs = args) => run(picked.command, [...picked.prefix, ...baseArgs, ...extraArgs], {
347
569
  cwd: profileDir,
348
- env: { ...childEnv, ...picked.env },
570
+ env: { ...childEnv, ...picked.env, ...(picked.manager === 'pnpm' ? POLICY_ENV : {}) },
349
571
  timeoutMs,
350
572
  });
351
- let result = await spawnAdd([]);
573
+
574
+ let result = await runManager([]);
352
575
  let output = result.output;
353
576
  /**
354
- * 撞上 pnpm 的「新发布先晾一会儿」策略:**带上开关重试一次**。
577
+ * 宿主环境对不上:不是这个包有问题,是这台机器的依赖树跟当前 pnpm 对不上 —— 不修的话,
578
+ * 之后每一次更新都会撞同一堵墙。修一次、重试一次;修不了就如实失败,下个 tick 再来。
355
579
  *
356
- * 为什么非重试不可:那道策略查的是**整份 lockfile**,不是我们要装的这个包。这台机器上
357
- * 实测过一次 —— 另一个插件(`billion-context@0.1.192`)刚发不久,整份 lockfile 就被判
358
- * 「含未过期的条目」而拒绝,于是我们自己的更新整整一天都做不了,而原因跟我们这个包毫无
359
- * 关系。市场那边对这种拒绝也是带开关重试一次(`RELEASE_AGE_OVERRIDE`)。
360
- *
361
- * 只在**第一次没带开关**、且失败信息确实是这类策略时才重试:别的失败(网络、权限、盘满)
362
- * 重试一次只会把同一条错误再说一遍,还多花一次等待。
580
+ * 两种现场各有各的修法,判据都在 pnpm 自己的报错里:
581
+ * ① 布局是两个大版本之前建的(PUBLIC_HOIST_PATTERN_DIFF / VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF)
582
+ * → 用现在这版 pnpm 重建依赖树;
583
+ * ② 记录的 store 与它现在解析的不是同一个(UNEXPECTED_STORE)→ 把**记录里的那个**写回
584
+ * profile 的 storeDir。只取报错点名、且确实存在于盘上的那个路径;记录本身就是旧盘 /
585
+ * 不存在的路径时不动 —— 那是另一种坏法,写回去只会换一种失败。
363
586
  */
364
- const releaseAgeLike = /MINIMUM_RELEASE_AGE|minimumReleaseAge|minimum-release-age|release age/i;
365
- const retried = !result.ok && picked.manager === 'pnpm' && !skipReleaseAge && releaseAgeLike.test(output);
366
- if (retried) {
367
- onEvent({ phase: 'release-age-retry' });
368
- result = await spawnAdd([releaseAgeFlag]);
369
- command = [picked.manager, ...args, releaseAgeFlag].join(' ');
370
- output = `${output}\n--- 撞上 pnpm 的发布时长策略,带上 ${releaseAgeFlag} 重试一次 ---\n${result.output}`;
587
+ let healed = null;
588
+ if (!result.ok && picked.manager === 'pnpm') {
589
+ if (HOIST_DIFF.test(output)) {
590
+ const rebuild = await runManager([], ['install', '--no-frozen-lockfile', ...policyArgs]);
591
+ if (rebuild.ok) {
592
+ healed = 'rebuild';
593
+ result = await runManager([]);
594
+ output = `${output}\n--- node_modules 是旧版 pnpm 建的,已重建依赖树并重试 ---\n${result.output}`;
595
+ } else {
596
+ output = `${output}\n--- 试着重建依赖树,但也没成:${rebuild.output} ---`;
597
+ }
598
+ } else if (/ERR_PNPM_UNEXPECTED_STORE/.test(output)) {
599
+ const linked = /currently linked from the store at "([^"]+)"/.exec(output)?.[1];
600
+ const modules = /The dependencies at "([^"]+)"/.exec(output)?.[1];
601
+ if (linked && modules && isInsideDir(profileDir, modules) && fs.existsSync(linked)
602
+ && ensureProfileStoreDir(profileDir, linked)) {
603
+ healed = 'store';
604
+ result = await runManager([]);
605
+ output = `${output}\n--- pnpm 记录的 store 与本机解析的不一致,已把记录里的那个写回 storeDir 并重试 ---\n${result.output}`;
606
+ }
607
+ }
371
608
  }
372
609
  const after = installedVersion(profileNodeModules, name);
373
610
  if (!result.ok) {
@@ -379,7 +616,8 @@ async function installLatest({
379
616
  source: declaredSource(profileDir, name),
380
617
  command,
381
618
  manager: picked.kind,
382
- retried,
619
+ healed,
620
+ releaseAgeRecorded: false,
383
621
  output,
384
622
  // 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
385
623
  failure: {
@@ -390,6 +628,8 @@ async function installLatest({
390
628
  }
391
629
  // 退出码 0 不算数:**盘上版本真的变了**才算装上(市场那份代码吃过这个亏)。
392
630
  const changed = after !== null && after !== beforeVersion;
631
+ // 这一版是我们绕开晾置期装进去的,登记进排除清单,免得它反过来把别的安装挡 24 小时。
632
+ const releaseAgeRecorded = changed ? recordReleaseAgeExclusion(profileDir, name, after) : false;
393
633
  return {
394
634
  installed: changed,
395
635
  skipped: false,
@@ -398,7 +638,8 @@ async function installLatest({
398
638
  source: declaredSource(profileDir, name),
399
639
  command,
400
640
  manager: picked.kind,
401
- retried,
641
+ healed,
642
+ releaseAgeRecorded,
402
643
  output,
403
644
  failure: changed
404
645
  ? null
@@ -427,9 +668,12 @@ async function installLatest({
427
668
  * 把它搬到插件自己这条路上来。判到有人跑就**不动盘**,如实报成「可再试」,下个 tick 再来。
428
669
  *
429
670
  * `force` 是**用户明说「我知道,装吧」**的那一挡(面板上那颗「强制更新」):它同时跳过
430
- * 这道闸和「新发布先晾一会儿」的门槛,而且不管有没有新版都装一遍。`auto`(宿主那一轮自动
431
- * 更新)跳过同样的两道闸,只是仍然「没新版就不动盘」—— 2026-10-26 用户要求自动更新一检测到
432
- * 新版就立刻装,所以它不再等发布晾置期,见文件头。
671
+ * 这道闸,而且不管有没有新版都装一遍。`auto`(宿主那一轮自动更新)跳过同一道闸,只是仍然
672
+ * 「没新版就不动盘」。发布晾置期**不再是一个「哪一档才跳过」的问题**:装的时候一律跳过,
673
+ * 装完把这一版登记进排除清单(见文件头),所以手动、强制、自动三条路的安装条件是一致的。
674
+ *
675
+ * 「查」用的是这台机器上 pnpm / npm 真正在用的那个注册表(`profileRegistry`),不是写死的
676
+ * 官方源:镜像是通的、官方源连不上的机器,也要能自己发现新版。
433
677
  */
434
678
  async function installLatestIfNeeded(options = {}) {
435
679
  const { profileNodeModules, force = false, auto = false, name = DEFAULT_NAME, agents = null } = options;
@@ -451,7 +695,17 @@ async function installLatestIfNeeded(options = {}) {
451
695
  check: null,
452
696
  };
453
697
  }
454
- const check = await checkForUpdate({ installedVersion: beforeVersion, source });
698
+ // 查与装用同一个源:问一次这台机器上 pnpm / npm 真正在用的注册表(问不到才退回官方源)。
699
+ // 不这样做的话,镜像 / 私有源机器上会出现「检查更新说连不上、手动装却是通的」。
700
+ const registry = await profileRegistry({
701
+ profileNodeModules,
702
+ packageManager: options.packageManager ?? null,
703
+ env: options.env,
704
+ platform: options.platform,
705
+ dirs: options.dirs ?? null,
706
+ cache: options.registryCache,
707
+ });
708
+ const check = await checkForUpdate({ installedVersion: beforeVersion, source, base: registry ?? undefined });
455
709
  // 「有没有新版」排在「有没有人在跑」**前面**:没东西可装的时候,拿一句「有对话在跑」挡住
456
710
  // 真正的答案(已经是最新)是误导。
457
711
  if (!force && !check.updateAvailable) {
@@ -504,6 +758,10 @@ module.exports = {
504
758
  resolveExecutable,
505
759
  normalizeInvocation,
506
760
  runningAgentIds,
761
+ profileRegistry,
762
+ recordReleaseAgeExclusion,
763
+ ensureProfileStoreDir,
764
+ parseRegistryOutput,
507
765
  DEFAULT_NAME,
508
766
  INSTALL_TIMEOUT_MS,
509
767
  OUTPUT_TAIL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "engines": {