dsh-mobile-plugin 0.5.23 → 0.5.24

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.
@@ -27,11 +27,38 @@
27
27
  * 一个吓人的红字。反过来,没来过才轮到防火墙说话。
28
28
  */
29
29
 
30
+ const net = require('node:net');
30
31
  const { readFirewall } = require('./firewall-probe.js');
31
32
 
32
33
  /** 「最近来过」的窗口。比侧栏那 30 秒心跳宽得多:它要回答的是「人还在不在用」。 */
33
34
  const MOBILE_RECENT_MS = 30 * 60 * 1000;
34
35
 
36
+ /**
37
+ * 试一下本机某个回环端口连不连得上。
38
+ *
39
+ * 直连隧道(tailcat)的数据是「隧道 → 回环入口 → 下游端口」一路转的:助手进程报 ready
40
+ * 只说明它起来了、拿到了地址,**不代表这条链的下游还活着**。所以自检要真去拨一下那个下游
41
+ * 端口——拨得通才算这条通道真能用。这不是「朝自己的局域网地址发请求」(那是假阳性,见
42
+ * 文件头),而是对一个明确在 127.0.0.1 上的内部端口做 TCP 连接,只回答「有没有人在听」。
43
+ */
44
+ function defaultProbePort(port, timeoutMs = 1000) {
45
+ return new Promise((resolve) => {
46
+ if (!Number.isInteger(port) || port <= 0) return resolve(false);
47
+ let settled = false;
48
+ const finish = (ok) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ socket.destroy();
52
+ resolve(ok);
53
+ };
54
+ const socket = net.connect({ host: '127.0.0.1', port });
55
+ socket.setTimeout(timeoutMs);
56
+ socket.once('connect', () => finish(true));
57
+ socket.once('timeout', () => finish(false));
58
+ socket.once('error', () => finish(false));
59
+ });
60
+ }
61
+
35
62
  /**
36
63
  * 纯判据。守卫直接喂五组输入断言这五档,不碰进程、不碰时钟。
37
64
  *
@@ -63,6 +90,7 @@ function createConnectionCheck(deps = {}) {
63
90
  const lanCandidates = typeof deps.lanCandidates === 'function' ? deps.lanCandidates : () => [];
64
91
  const mobileActivity = typeof deps.mobileActivity === 'function' ? deps.mobileActivity : () => ({ lastMobileAt: 0 });
65
92
  const probe = typeof deps.probeFirewall === 'function' ? deps.probeFirewall : readFirewall;
93
+ const probePort = typeof deps.probePort === 'function' ? deps.probePort : defaultProbePort;
66
94
  const now = typeof deps.now === 'function' ? deps.now : Date.now;
67
95
  const platform = typeof deps.platform === 'string' ? deps.platform : process.platform;
68
96
 
@@ -87,6 +115,32 @@ function createConnectionCheck(deps = {}) {
87
115
  const lastMobileAt = Number.isFinite(mobile.lastMobileAt) ? mobile.lastMobileAt : 0;
88
116
  const at = now();
89
117
  const listeningState = typeof lan.state === 'string' ? lan.state : 'off';
118
+ // 直连隧道的下游端口真拨一下:`ready` 只说明助手起来了、拿到地址,不代表这条链还通
119
+ // (见 defaultProbePort)。关了/没起来就 reachable 留 null(没得测,不是「不通」)。
120
+ const tc = snapshot.tailcat && typeof snapshot.tailcat === 'object' ? snapshot.tailcat : null;
121
+ let tailcat = null;
122
+ if (tc) {
123
+ const available = tc.available !== false;
124
+ const enabled = tc.enabled !== false;
125
+ const ready = tc.ready === true;
126
+ const targetPort = Number.isInteger(tc.targetPort) && tc.targetPort > 0 ? tc.targetPort : 0;
127
+ let reachable = null;
128
+ if (available && enabled && ready && targetPort > 0) {
129
+ try {
130
+ reachable = await probePort(targetPort);
131
+ } catch {
132
+ reachable = null;
133
+ }
134
+ }
135
+ tailcat = {
136
+ available,
137
+ enabled,
138
+ ready,
139
+ paired: Number.isInteger(tc.paired) ? tc.paired : 0,
140
+ targetPort,
141
+ reachable,
142
+ };
143
+ }
90
144
  return {
91
145
  ok: true,
92
146
  checkedAt: at,
@@ -102,6 +156,7 @@ function createConnectionCheck(deps = {}) {
102
156
  state: typeof tunnel.state === 'string' ? tunnel.state : 'off',
103
157
  relayUrl: typeof snapshot.relayUrl === 'string' && snapshot.relayUrl ? snapshot.relayUrl : null,
104
158
  },
159
+ tailcat,
105
160
  firewall,
106
161
  mobile: {
107
162
  lastMobileAt,
@@ -22,11 +22,11 @@ const desktopHalf = require('../desktop-half-stats.cjs')
22
22
  const TUNNEL_ADMISSION = [
23
23
  {
24
24
  // 中转关连接时用的文案是 'bad registration secret'(中转侧拼的),
25
- // 而 lib/tunnel-registration.js 内部的 reason 拼作 'bad register secret'。
25
+ // 而中转侧内部的 reason 拼作 'bad register secret'。
26
26
  // 两种拼法都出现过,所以正则认词干不认具体字母:regist + 任意尾巴 + secret。
27
27
  pattern: /bad regist\w*\s+secret/i,
28
28
  flag: 'registerSecretRequired',
29
- // 这两条拒绝都由中转的准入判定给出(对应 lib/tunnel-registration.js 的口径),
29
+ // 这两条拒绝都由中转的准入判定给出(对应中转侧的准入口径),
30
30
  // 文案变了这里就会漏判,所以判定正则放宽到只认关键词。
31
31
  //
32
32
  // 插件每 5 秒重试一次,而状态里只有一句「隧道关闭: 1008 bad registration secret」。
@@ -1,19 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 控制面路由(`/api/relay/*`):桌面端设置页与宿主本机运维动作走这里。
4
+ * 控制面路由(`/api/relay/*`):桌面设置页与宿主本机运维动作走这里。
5
5
  *
6
- * 与移动端路由表是刻意的两套:控制面只认本机来源(localOnly),移动端只认 token(guarded)。
7
- * 控制面会读进程级错误账本(含堆栈)、能改配置、能吊销全部授权,暴露给中转等于把后门开到
8
- * 公网;反过来移动端请求可能来自中转,根本没有本机 socket 可言。
9
- *
10
- * 依赖以取值器 / 函数注入,不捕获对象:闭包里那些 `let`(sidecarSnapshot、sidecarState…)
11
- * 会被整体替换。`getSidecar` 尤其重要:sidecar 会被自重启换掉,拿到实例就等于拿到旧 facade
12
- * 的快照。
13
- *
14
- * 改中转地址(`POST /api/relay/config`)与手机端写的是同一份配置、走同一条下发路径;「先登记
15
- * 迁移、再重建隧道」的顺序在 worker 的 configure 里(见 ../relay/migrate.js),这里只把那次
16
- * 下发的失败原因如实带回给面板。
6
+ * 与移动端路由表刻意是两套:控制面只认本机来源(localOnly),移动端只认 token(guarded)。控制面能读进程级错误账本(含堆栈)、改配置、吊销全部授权,暴露给中转等于把后门开到公网;移动端请求可能来自中转,没有本机 socket 可言。
7
+ * 依赖以取值器 / 函数注入、不捕获对象:闭包里那些 let(sidecar 等)会被整体替换,`getSidecar` 拿到实例就等于拿到旧 facade 快照。
8
+ * 改中转地址与手机端写同一份配置、走同一条下发路径;「先登记迁移、再重建隧道」在 worker 的 configure 里(见 ../relay/migrate.js),这里只把失败原因如实带回面板。
17
9
  */
18
10
 
19
11
  const fs = require('node:fs');
@@ -19,6 +19,7 @@ const sessions = require('../mobile/sessions.js');
19
19
  const sessionModelOptions = require('../mobile/session-model-options.js');
20
20
  const sessionFile = require('../mobile/session-file.js');
21
21
  const sessionLocalImage = require('../mobile/session-local-image.js');
22
+ const { promoReceiver: promoReceiverCoords } = require('../mobile/promo-receiver.cjs');
22
23
  const sessionStep = require('../mobile/session-step.js');
23
24
  const fileRead = require('../mobile/file-read.js');
24
25
  const stats = require('../mobile/stats.js');
@@ -55,6 +56,10 @@ const narration = require('../mobile/narration.js');
55
56
  const git = require('../mobile/git.js');
56
57
  const terminal = require('../mobile/terminal.js');
57
58
  const pairing = require('../mobile/pairing.js');
59
+ // 设备批量同步(docs/device-batch-sync.md):一次性入场券 + 密文收件箱。
60
+ const sync = require('../mobile/sync.js');
61
+ const { createTransferTickets } = require('../mobile/transfer-tickets.js');
62
+ const { createSyncInboxStore } = require('../mobile/sync-inbox-store.js');
58
63
  const { credentialsEntry } = require('../shared/credentials-entry.js');
59
64
  const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
60
65
  const extensions = require('../mobile/extensions.js');
@@ -170,6 +175,24 @@ function registerMobileRoutes(deps) {
170
175
  // 单例去拿:同一进程里两份 relay.json 的真值只会打架。所以这里按装配层给的依赖建一次。
171
176
  const relayControl = createRelayControlHandlers(deps.relayControl || {})
172
177
 
178
+ // 设备批量同步的两份状态:一次性券表只活在内存(进程重启即清),收件箱按实例落 `$DSH_HOME`。
179
+ // 页面能挂几次本函数就建几份;配对那条要用同一份券表,所以在这里建好、同时交给下面两处。
180
+ const transferTickets = createTransferTickets()
181
+ const syncInboxStore = createSyncInboxStore()
182
+ const syncHandlers = sync.createSyncHandlers({
183
+ state,
184
+ ticketStore: transferTickets,
185
+ inboxStore: syncInboxStore,
186
+ safeLanCandidates,
187
+ safeIpv6Candidates,
188
+ lanPortInUse,
189
+ // 面板那条回包用的是 pairingApproval.hostPlatform().platform(见 pairing.js),这里同源。
190
+ hostPlatform: () => (pairingApproval && typeof pairingApproval.hostPlatform === 'function'
191
+ ? pairingApproval.hostPlatform().platform
192
+ : process.platform),
193
+ log: (...args) => console.warn('[dsh-mobile-plugin] [sync]', ...args),
194
+ })
195
+
173
196
  registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
174
197
  // 内存 `state` 是本进程唯一的真值。旧实现这里 `loadRelayState()` 重新读盘,
175
198
  // 于是同一份状态出现两个来源:控制面改完内存立刻返回,而移动端读到的还是
@@ -250,11 +273,20 @@ function registerMobileRoutes(deps) {
250
273
  lanCandidates: safeLanCandidates,
251
274
  ipv6Candidates: safeIpv6Candidates,
252
275
  lanPort: lanPortInUse,
276
+ // 批量同步导入的新手机带着 `ticketId` 来配:从这张券表取券当票据(一次性)。不传它就
277
+ // 只认得面板二维码那张 state 票据。
278
+ transferTickets,
253
279
  // 配对成功即换码 + 换票据(§5.1):谁截了二维码,那张图从这一刻起就没用了。
254
280
  rotatePairing,
255
281
  log: (...args) => console.warn('[dsh-mobile-plugin] [pair]', ...args),
256
282
  }),
257
283
  })
284
+
285
+ // 设备批量同步:申领一次性入场券 + 密文收件箱读写。三条端点、两条注册(inbox 按 method
286
+ // 分派写 / 读,宿主 webServer 一个路径只能挂一个 handler),都走 route() 即包 guarded ——
287
+ // 只有已配对设备才能领券或碰收件箱,否则任何人能领走配对资格、往这台机器里塞东西。
288
+ route('/api/mobile/v1/sync/tickets', syncHandlers.handleMintTickets)
289
+ route('/api/mobile/v1/sync/inbox', syncHandlers.handleInbox)
258
290
  // 局域网/中转连接信息。**这条由宿主自己回答**:中转只转发,不再代答。
259
291
  // 纯局域网配对(App 只扫到局域网二维码、完全不经中转)需要一个宿主自己回答的地址,
260
292
  // 否则 App 拿不到 lanCandidates,换网卡后就再也找不到这台机器;中转在中间时这条
@@ -322,6 +354,9 @@ function registerMobileRoutes(deps) {
322
354
  ipv6Candidates: ipv6,
323
355
  updatedAt: lanUpdatedAt,
324
356
  lastSeen: lanUpdatedAt,
357
+ // 商店素材的接收端(本机的 Mac-to-eagle 桌面程序):手机端不用手填地址与密钥,
358
+ // 这里一并交给它。没有那个程序 / 还没生成密钥时是 null(App 那边什么都不做)。
359
+ promoReceiver: promoReceiverCoords({ lanCandidates: candidates }),
325
360
  })
326
361
  })
327
362
 
@@ -13,6 +13,10 @@
13
13
  * 2. 配对窗口是一次性的:每次开窗口都换一把临时钥匙,换完(或过期)就作废。
14
14
  * 3. 手机被吊销时白名单立刻变,助手那边整体重启节点;失败就把白名单退回原样,不能出现
15
15
  * 「界面上已吊销、实际上还能连」。
16
+ *
17
+ * 助手是随插件装的可选依赖,插件加载时可能还没有(镜像跳过 optionalDependencies、装晚了、
18
+ * 平台包没装)。所以「没找到」不能判死:开配对窗口 / 报名这些入口每次被调用时重探一次,
19
+ * 装上了就正常拉起 —— 用户不用重开 DSH。平时不扫描,只在入口上探。
16
20
  */
17
21
 
18
22
  const { createTailcatHost, resolveBinary, TUNNEL_PORT, PAIRING_PORT } = require('./host-process.cjs');
@@ -44,10 +48,19 @@ const STATE = {
44
48
  * @param {() => string} [options.hostName] 电脑名(手机配对时显示)
45
49
  * @param {(message: string) => void} [options.log] 诊断日志
46
50
  * @param {object} [options.host] 测试注入的助手句柄
51
+ * @param {() => (string|null)} [options.resolveBinary] 测试注入的助手查找函数
47
52
  */
48
53
  function createTailcatSession(options) {
49
54
  const log = options.log || (() => {});
50
- const binary = options.host ? 'injected' : resolveBinary();
55
+ const findBinary = typeof options.resolveBinary === 'function' ? options.resolveBinary : resolveBinary;
56
+ /**
57
+ * 助手的二进制路径。找不到时留着 null —— 入口每次被调用时重探一次(`locate()`),
58
+ * 而不是把这台机器永久判成「没有助手」。
59
+ *
60
+ * 注入了假助手句柄时不必真去磁盘上找(单测不该依赖本机有没有编译产物);但给了
61
+ * `resolveBinary` 就以它为准。
62
+ */
63
+ let binary = options.host && !options.resolveBinary ? 'injected' : findBinary();
51
64
  let host = null;
52
65
  let lastError = '';
53
66
  /** 正在等窗口的那次请求(一次只开一个窗口)。 */
@@ -132,12 +145,27 @@ function createTailcatSession(options) {
132
145
 
133
146
  let pendingWindowTimeout = null;
134
147
 
148
+ /**
149
+ * 找一次助手。已经找到就沿用(一次成功之后不会再变),没找到就趁入口这次机会再探一遍。
150
+ *
151
+ * 只在入口上调:助手是可选依赖,插件加载那一刻没有不代表这台机器装不上,等装上了下次
152
+ * 开码 / 报名就能自己起来;反过来,没有人来用这条通道时也不值得每分钟去磁盘上找一次。
153
+ */
154
+ function locate() {
155
+ if (binary) return binary;
156
+ binary = findBinary();
157
+ if (binary) log(`直连隧道:找到助手 ${binary}`);
158
+ return binary;
159
+ }
160
+
135
161
  function start() {
136
162
  if (host) return;
137
- if (!binary) {
163
+ if (!locate()) {
138
164
  lastError = 'helper-missing';
139
165
  return;
140
166
  }
167
+ // 上一次没找到时留下的那条结论已经过时了:界面会把它当成「助手起不来」报错。
168
+ if (lastError === 'helper-missing') lastError = '';
141
169
  const state = snapshot();
142
170
  if (!state.enabled) return;
143
171
  host = options.host || createTailcatHost({
@@ -161,7 +189,13 @@ function createTailcatSession(options) {
161
189
  return Promise.resolve();
162
190
  }
163
191
 
164
- /** 面板与路由看到的现状。 */
192
+ /**
193
+ * 面板与路由看到的现状。
194
+ *
195
+ * 这里**不重探**:面板是每秒轮询这张状态的,探磁盘会把「找不到」变成一种常态开销。
196
+ * `available: false` 只说明「这个会话到目前为止没找到助手」,下次有人开码 / 报名时
197
+ * `locate()` 会再找一遍;找到了 `available` 跟着变真。
198
+ */
165
199
  function status() {
166
200
  const state = snapshot();
167
201
  if (!binary) return { available: false, enabled: state.enabled, ready: false, address: '', paired: state.allowed.length, error: 'helper-missing' };
@@ -180,9 +214,10 @@ function createTailcatSession(options) {
180
214
  /**
181
215
  * 开一个配对窗口。成功给出发给手机的那三样(地址 / 一次性私钥 / 证明串);
182
216
  * 拉不起节点或超时就给 null,调用方照「这次只能走原来的路子」处理。
217
+ *
218
+ * 入口这里就是那个「再找一次」的机会:助手装晚了也能从这一发开始用上。
183
219
  */
184
220
  function openPairingWindow(ttlSeconds) {
185
- if (!binary) return Promise.resolve(null);
186
221
  start();
187
222
  if (!host) return Promise.resolve(null);
188
223
  if (pendingWindow) return pendingWindow.promise;
@@ -209,9 +244,11 @@ function createTailcatSession(options) {
209
244
  * 的地址回给它 —— 与当初「宿主改了中转地址、手机跟着换」是同一套机制。
210
245
  *
211
246
  * 白名单变了节点要整体重启(v0.7 没有单独加人的接口),所以这里要等一小段。
247
+ *
248
+ * 与开码那一处同理:入口上再找一次助手,装晚了的那台手机不用等插件重载。
212
249
  */
213
250
  async function enroll(publicKey) {
214
- if (!binary || typeof publicKey !== 'string' || !publicKey.startsWith('nodekey:')) return null;
251
+ if (typeof publicKey !== 'string' || !publicKey.startsWith('nodekey:')) return null;
215
252
  start();
216
253
  if (!host) return null;
217
254
  const state = snapshot();
@@ -21,15 +21,29 @@ const { createTailcatSession } = require('./session.cjs');
21
21
  * @param {object} options.state relay.json 那份内存状态(唯一真值)
22
22
  * @param {(state: object) => void} options.saveState 落盘
23
23
  * @param {() => number} options.lanPortInUse 当前局域网端口(会漂)
24
+ * @param {() => number} [options.targetPort] 直连隧道该转到的端口;给了就用它,否则退回 lanPortInUse(见 chooseTailcatTarget)
24
25
  * @param {() => string} options.hostName 电脑名
25
26
  * @param {object} options.ctx 插件 ctx(只为 `ctx.effect`)
26
27
  * @param {string} options.name 插件名,用于 effect 标签
27
28
  * @returns {object|undefined} 会话;起不来就是 undefined(路由与面板按「不可用」处理)
28
29
  */
30
+ /**
31
+ * 直连隧道把连接转到哪个端口。
32
+ *
33
+ * 局域网开着:照旧转给「当前的局域网端口」,和一台 LAN 手机走完全同一条路。
34
+ * 局域网关着:那个端口根本没人监听(`resolveLanPort` 在关闭时直接早返回、sidecar 也不起),
35
+ * 隧道会**握手成功、数据一到就断**(现场表现为「连上却没反应」)。此时改转宿主自己的 web
36
+ * 端口 —— 手机 API 就在那里,局域网 sidecar 平时也只是把连接原样转给 `ctx.webServer.port`。
37
+ */
38
+ function chooseTailcatTarget({ lanEnabled, lanPort, webPort }) {
39
+ return () => (lanEnabled() ? lanPort() : webPort());
40
+ }
41
+
29
42
  function mountTailcat(options) {
30
- const { mount, warn, state, saveState, lanPortInUse, hostName, ctx, name } = options;
43
+ const { mount, warn, state, saveState, lanPortInUse, targetPort, hostName, ctx, name } = options;
44
+ const resolvedTargetPort = typeof targetPort === 'function' ? targetPort : () => lanPortInUse();
31
45
  const ingress = mount('直连隧道入口', () =>
32
- createLoopbackIngress({ targetPort: () => lanPortInUse(), log: message => warn(message) }),
46
+ createLoopbackIngress({ targetPort: resolvedTargetPort, log: message => warn(message) }),
33
47
  );
34
48
 
35
49
  /**
@@ -39,7 +53,14 @@ function mountTailcat(options) {
39
53
  */
40
54
  let session = null;
41
55
  const door = {
42
- status: () => (session ? session.status() : { available: Boolean(ingress), enabled: true, ready: false, address: '', paired: 0, error: 'starting' }),
56
+ // 除了助手进程那一格,再把「这条链当前该转到的下游端口」一并报出去:连接自检要拿它
57
+ // 真去拨一下,才能分辨「助手 ready」与「下游其实没人听」。
58
+ status: () => {
59
+ const base = session
60
+ ? session.status()
61
+ : { available: Boolean(ingress), enabled: true, ready: false, address: '', paired: 0, error: 'starting' };
62
+ return { ...base, targetPort: resolvedTargetPort() };
63
+ },
43
64
  openPairingWindow: ttl => (session ? session.openPairingWindow(ttl) : Promise.resolve(null)),
44
65
  setAllowed: keys => (session ? session.setAllowed(keys) : false),
45
66
  setEnabled: enabled => (session ? session.setEnabled(enabled) : false),
@@ -93,4 +114,4 @@ function mountTailcat(options) {
93
114
  return door;
94
115
  }
95
116
 
96
- module.exports = { mountTailcat };
117
+ module.exports = { mountTailcat, chooseTailcatTarget };
@@ -1,44 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 自己装新版:在 profile 里跑一次它自己的包管理器,把本插件换到指定版本。
5
- *
6
- * 不借道 DSH 插件市场:更新分「查」(update-registry)和「装」,装该由插件自己完成,不该因为
7
- * 另一个插件没装、太老或坏了就整个失效。
8
- *
9
- * 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
10
- * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
11
- * 开关有三层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
12
- * (一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
13
- * lockfile,别的插件刚发也会连累我们)、ignore-workspace-root-check=true(profile 自己带
14
- * `pnpm-workspace.yaml`,在 pnpm 眼里就是 workspace 根;pnpm 10.4 及以前会拿
15
- * `ERR_PNPM_ADDING_TO_ROOT` 把这一句 add 直接拒掉,机械地退回 PATH 上那个老 pnpm 的机器全中),
16
- * 再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
17
- * pnpm 12 的原生 CLI 会静默忽略这几个 --config 参数,环境变量那一份还认。
18
- *
19
- * 失败要**说出原因**:`failure.message` 是手机、面板与日志唯一显示的那句话,曾经只写「跑过的
20
- * 命令 + 退出码」—— 排错的人拿着一张只有命令的截图,包管理器自己的报错(ERR_PNPM_* 那一行)
21
- * 被丢在了 `output` 里没人看。现在报错正文跟着附上。
22
- *
23
- * 装完要**登记**:这个版本是被我们绕过晾置期装进去的,不写进 profile 的 minimumReleaseAgeExclude,
24
- * 它就会留在 lockfile 里,之后 pnpm 校验整份 lockfile 时把这台机器上所有安装都拒掉
25
- * (官方桌面端不替用户临时放行,只能干等 24 小时)。
26
- *
27
- * 包管理器优先用宿主发布的那个(profile 是宿主自带 pnpm 装的,PATH 上版本不同会
28
- * ERR_PNPM_UNEXPECTED_STORE),找不到再退回 PATH。装之前先看有没有 agent 在跑;force 与 auto
29
- * 都跳过这道闸(自动更新一检测到新版就装,与面板上那颗「强制更新」
30
- * 同一个口径),两者的区别只剩「没新版时装不装」。顺序上先答「有没有新版」,没东西可装时
31
- * 不该拿「有对话在跑」挡在前面。
32
- *
33
- * 宿主环境对不上时先修一次再重试:node_modules 是旧版 pnpm 建的(两个 hoist 相关的
34
- * ERR_PNPM_*_DIFF)就重建依赖树;记录的 store 和现在解析的不是同一个(ERR_PNPM_UNEXPECTED_STORE)
35
- * 就把记录里的那个 store 写回 profile 的 storeDir。两种都只修一次,修不了就如实失败 —— 这类
36
- * 现场不修的话,这台机器之后每一次更新都会撞同一堵墙。
37
- *
38
- * 只动 profile 的依赖树;找不到包管理器就如实失败;装完以盘上版本号为准,退出码 0 不算装上。
39
- *
40
- * Windows 上包管理器是 `.cmd`(pnpm 装出来的就是个批处理外壳),不是可执行文件:直接
41
- * `spawn` 必然 `EINVAL`,得让 cmd 去跑它,见 `cmdInvocation`。
4
+ * 自己装新版:在 profile 里跑它自己的包管理器,把本插件换到指定版本;不借道插件市场,也不自己实现包管理。
5
+ * 必要开关(auto-install-peers / minimum-release-age / ignore-workspace-root-check)命令行与环境变量各带一份,因为 pnpm 12 会静默忽略部分 --config。
6
+ * 装完把版本登记进 profile 的 minimumReleaseAgeExclude,否则 pnpm 校验整份 lockfile 会把整台机器的安装都拒掉;包管理器优先用宿主发布的那份(PATH 版本不同会 ERR_PNPM_UNEXPECTED_STORE)。
7
+ * 环境对不上时按 ERR_PNPM_*_DIFF / store 不一致各修一次再重试;装前先看有没有 agent 在跑(force 与 auto 跳过这道闸);失败要带出包管理器自己的报错正文;Windows 上是 .cmd,见 `cmdInvocation`。
42
8
  */
43
9
 
44
10
  const fs = require('node:fs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
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
  "icon": "./icon.png",