dsh-mobile-plugin 0.4.7 → 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.
- package/lib/mirror/pusher.cjs +4 -0
- package/lib/mobile/auto-continue.js +143 -0
- package/lib/mobile/history-derive.js +7 -0
- package/lib/mobile/plugin-update.js +16 -1
- package/lib/mobile/session-cache.js +21 -1
- package/lib/reconnect-scheduler.cjs +19 -6
- package/lib/relay/tunnel.js +26 -1
- package/lib/routes/control-plane.js +21 -1
- package/lib/sidecar-facade-worker.cjs +44 -2
- package/lib/update-install.cjs +301 -43
- package/package.json +1 -1
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -21,6 +21,7 @@ const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
|
21
21
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
22
22
|
const { dropSessionListCache } = require('../mobile/session-cache.js');
|
|
23
23
|
const { observeCompletionEvent } = require('../mobile/completion.js');
|
|
24
|
+
const { observeAutoContinue } = require('../mobile/auto-continue.js');
|
|
24
25
|
const { lastTurnOf } = require('../mobile/session-last-turn.js');
|
|
25
26
|
|
|
26
27
|
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
@@ -771,6 +772,9 @@ function createMirrorPusher(deps) {
|
|
|
771
772
|
// session/stream 的 SSE 负责。不跳的话每条增量都要重建五份全量快照并推一次
|
|
772
773
|
// sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
|
|
773
774
|
observeCompletionEvent(ctx, sessionId, p?.event);
|
|
775
|
+
// 手机发起的轮被输出 token 上限截断时代发一句「继续」(见 mobile/auto-continue.js)。
|
|
776
|
+
// 只认 turn/end,其余事件进来一行就退;它自己吞掉所有异常,不拖这条流。
|
|
777
|
+
void observeAutoContinue(ctx, sessionId, p?.event);
|
|
774
778
|
if (p?.event?.type === 'assistant/chunk') continue;
|
|
775
779
|
// iOS 推送:turn/end 是「可能刚收尾」的信号,把事实(lastTurnOf 现读)交给
|
|
776
780
|
// 通知器判断——只有 completed / failed 且这一轮之前没推过才会真发一帧。
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 输出 token 用满之后替用户续一句「继续」。
|
|
5
|
+
*
|
|
6
|
+
* 宿主把 `finish=max-tokens` 折成 `turn/end(reason.kind='max-tokens')` 就收尾这一轮,
|
|
7
|
+
* 客户端只会画一行固定提示(桌面 `message.maxTokens` 那两句,App 侧是 `misc.structural.maxTokens`)
|
|
8
|
+
* ——要不要接着写是人的事。手机上这就等于每次长回答被截断,用户都得亲手打两个字;
|
|
9
|
+
* 而在手机上打字正是最费劲的那种操作。所以这里在宿主侧替他发这一句。
|
|
10
|
+
*
|
|
11
|
+
* 三条边界,缺一条就是替用户乱说话:
|
|
12
|
+
*
|
|
13
|
+
* - **只续手机发起的轮**:会话里最后一条 user 消息必须带移动端 rpcId 前缀
|
|
14
|
+
* (`mobile:`,与 `/session/send` 那条路同源,见 history-derive 的
|
|
15
|
+
* `clientMessageIdFromSource`)。电脑上开的轮一动不动 —— 桌面那边用户本来就在键盘前。
|
|
16
|
+
* - **一串最多续 {@link AUTO_CONTINUE_MAX_ROUNDS} 次**:真写满额度、或者思考死循环带出一段
|
|
17
|
+
* 可见正文时都会报 max-tokens(循环守卫只有在「思考是唯一产出」时才报 REASONING_LOOP),
|
|
18
|
+
* 无限续就是无限烧额度。续够次数就停手,把那一行提示留给用户。
|
|
19
|
+
* - **同一轮只续一次**:事件流重连会重放 `turn/end`,不认这一条就是连发好几条「继续」。
|
|
20
|
+
*
|
|
21
|
+
* 续写走的是 `sessions.prompt`(与 App 的 `/session/send` 同一条路),所以它会作为一条
|
|
22
|
+
* 普通的 user 消息落进会话:用户看得见、能接着聊,也能据此判断「刚才那是它替我发的」。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { callSessions, mobilePromptRpcId } = require('./rpc.js');
|
|
26
|
+
const { unwrap } = require('./envelope.js');
|
|
27
|
+
const { clientMessageIdFromSource } = require('./history-derive.js');
|
|
28
|
+
const { autoContinueTurnsOf } = require('./session-cache.js');
|
|
29
|
+
|
|
30
|
+
/** 代发的那句话。短、明确、不夹带额外指令 —— 模型看见的就是用户会打的那两个字。 */
|
|
31
|
+
const AUTO_CONTINUE_TEXT = '继续';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 一串里最多代发几次。
|
|
35
|
+
*
|
|
36
|
+
* 三次是「够写完一篇长答复」与「别在病态输出上无限烧额度」之间的折中:按 8192 输出上限
|
|
37
|
+
* 算,四次加总三万多 token,正常长文到这已经写完了。
|
|
38
|
+
*/
|
|
39
|
+
const AUTO_CONTINUE_MAX_ROUNDS = 3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 代发消息的 clientMessageId 前缀。
|
|
43
|
+
*
|
|
44
|
+
* 它同时是**认回自己**的唯一凭据:这一串续了几次不另开计数器,而是回读会话里最后几条
|
|
45
|
+
* user 消息数连续的那几条 —— 宿主重启、事件重放、用户中途插话,三种情况都不会数错。
|
|
46
|
+
*/
|
|
47
|
+
const AUTO_CONTINUE_CLIENT_PREFIX = 'auto-continue-';
|
|
48
|
+
|
|
49
|
+
/** 回读会话时取的消息条数:只要够看到「上一个人类发言」,不必读全量。 */
|
|
50
|
+
const HISTORY_MESSAGE_LIMIT = 40;
|
|
51
|
+
|
|
52
|
+
/** 这条 user 消息是不是我们自己代发的。 */
|
|
53
|
+
function isAutoContinueId(clientMessageId) {
|
|
54
|
+
return typeof clientMessageId === 'string' && clientMessageId.startsWith(AUTO_CONTINUE_CLIENT_PREFIX);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 数「这一轮是手机发起的吗」「这一串已经代发了几次」。
|
|
59
|
+
*
|
|
60
|
+
* 返回 `{ rounds }` 表示可以续(`rounds` 是已续次数);`null` 表示这一轮不该续
|
|
61
|
+
* —— 最后一条人类发言不是从手机来的,或者压根读不到会话。
|
|
62
|
+
*
|
|
63
|
+
* 只认**最后一条非代发的 user 消息**:它是这一轮的起点。中点插话(steer)也是 user 消息,
|
|
64
|
+
* 它同样带移动端 rpcId,所以「手机上补一句」不会把宿主判成「这是电脑开的轮」。
|
|
65
|
+
*/
|
|
66
|
+
async function chainState(ctx, sessionId) {
|
|
67
|
+
const response = await callSessions(ctx, 'history', { sessionId, maxMessages: HISTORY_MESSAGE_LIMIT });
|
|
68
|
+
const result = unwrap(response);
|
|
69
|
+
if (result.error) return null;
|
|
70
|
+
const events = (result.value && Array.isArray(result.value.events)) ? result.value.events : [];
|
|
71
|
+
|
|
72
|
+
let rounds = 0;
|
|
73
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
74
|
+
const entry = events[index];
|
|
75
|
+
const event = entry && entry.event ? entry.event : entry;
|
|
76
|
+
if (!event || event.type !== 'user/message' || !event.data) continue;
|
|
77
|
+
const clientMessageId = clientMessageIdFromSource(event.data.source, event.data);
|
|
78
|
+
if (isAutoContinueId(clientMessageId)) {
|
|
79
|
+
rounds += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// 收起循环:第一条不是我们代发的 user 消息就是这一轮的起点。
|
|
83
|
+
if (clientMessageId === undefined) return null;
|
|
84
|
+
return { rounds };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 发那一句「继续」。
|
|
91
|
+
*
|
|
92
|
+
* `requestId` 必须走 `mobilePromptRpcId`:宿主要把它写进 user 消息的 `source.rpcId`,
|
|
93
|
+
* 上面的 `chainState` 才能在下一轮认出「这条是我们发的」,App 也能把它当成本设备发的消息
|
|
94
|
+
* 认领(否则手机上会同时出现一条乐观行和一条来历不明的消息)。
|
|
95
|
+
*/
|
|
96
|
+
function sendContinue(ctx, sessionId, turn, round) {
|
|
97
|
+
const clientMessageId = `${AUTO_CONTINUE_CLIENT_PREFIX}${turn}-${round}`;
|
|
98
|
+
const rpcId = mobilePromptRpcId(clientMessageId);
|
|
99
|
+
return callSessions(ctx, 'prompt', {
|
|
100
|
+
sessionId,
|
|
101
|
+
requestId: rpcId,
|
|
102
|
+
content: [{ type: 'text', text: AUTO_CONTINUE_TEXT }],
|
|
103
|
+
}, rpcId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 吃一条宿主会话事件;是「手机发起的这一轮被输出上限截断了」就代发一句「继续」。
|
|
108
|
+
*
|
|
109
|
+
* 由镜像推送那条全局事件循环调用(`lib/mirror/pusher.cjs`,与 `observeCompletionEvent` 同一处)。
|
|
110
|
+
* **绝不抛**:它是事件循环里的一步,抛出去会把镜像推送整条流带走;代发失败没有代价
|
|
111
|
+
* ——那一行「已达到输出 token 上限」照旧在界面上,用户还能自己点。
|
|
112
|
+
*
|
|
113
|
+
* @returns {Promise<boolean>} 是否真的代发了一条(给守卫用)。
|
|
114
|
+
*/
|
|
115
|
+
async function observeAutoContinue(ctx, sessionId, event) {
|
|
116
|
+
if (!sessionId || !event || event.type !== 'turn/end') return false;
|
|
117
|
+
const data = event.data;
|
|
118
|
+
if (!data || typeof data.turn !== 'number') return false;
|
|
119
|
+
if (!data.reason || data.reason.kind !== 'max-tokens') return false;
|
|
120
|
+
|
|
121
|
+
const turn = data.turn;
|
|
122
|
+
const map = autoContinueTurnsOf(ctx);
|
|
123
|
+
if (map.get(sessionId) === turn) return false; // 这一轮已经代发过(事件重放)
|
|
124
|
+
// 先占位再 await:同一轮的 turn/end 可能在历史读完之前又到一条。
|
|
125
|
+
map.set(sessionId, turn);
|
|
126
|
+
try {
|
|
127
|
+
const chain = await chainState(ctx, sessionId);
|
|
128
|
+
if (!chain || chain.rounds >= AUTO_CONTINUE_MAX_ROUNDS) return false;
|
|
129
|
+
await sendContinue(ctx, sessionId, turn, chain.rounds);
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
// 占位撤掉:这一发是网络 / 网关的瞬时失败,下一次同类事件还该再试一次。
|
|
133
|
+
if (map.get(sessionId) === turn) map.delete(sessionId);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
AUTO_CONTINUE_TEXT,
|
|
140
|
+
AUTO_CONTINUE_MAX_ROUNDS,
|
|
141
|
+
AUTO_CONTINUE_CLIENT_PREFIX,
|
|
142
|
+
observeAutoContinue,
|
|
143
|
+
};
|
|
@@ -757,6 +757,13 @@ function deriveMessages(events) {
|
|
|
757
757
|
if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
|
|
758
758
|
}
|
|
759
759
|
}
|
|
760
|
+
// 压缩行带一个空正文:直播侧的同款行本来就带 `text: ''`(App 的 `structuralItems`
|
|
761
|
+
// 一条不落),历史这边少这一格时,没跟上判空修复的旧安装包会在这条行上直接读正文、
|
|
762
|
+
// 当场抛错、整页崩掉(2026-10-27 真机)。标题与摘要照旧按 kind 走,不读这个字段;
|
|
763
|
+
// 其它结构行刻意不带(与 `history-turn-end` 守卫钉的形状一致)。
|
|
764
|
+
for (const item of out) {
|
|
765
|
+
if (item.kind === 'compaction' && typeof item.text !== 'string') item.text = '';
|
|
766
|
+
}
|
|
760
767
|
// 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
|
|
761
768
|
out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
762
769
|
return out;
|
|
@@ -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) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 会话侧的按 ctx
|
|
4
|
+
* 会话侧的按 ctx 隔离状态:十四张表,一个入口。
|
|
5
5
|
*
|
|
6
6
|
* 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
|
|
7
7
|
* 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
|
|
@@ -47,6 +47,10 @@ const writeIdempotency = new WeakMap();
|
|
|
47
47
|
// 与 sessionListResponseCache 同一类东西(列表刚构建完的那份是谁),只是按工作区算。
|
|
48
48
|
const workspaceShortcutStates = new WeakMap();
|
|
49
49
|
|
|
50
|
+
// 输出 token 用满之后「这一轮已经替用户代发过『继续』」的记账(见 mobile/auto-continue.js)。
|
|
51
|
+
// 只用来挡事件流重连的重放;续了几次由会话历史数出来,不放这里。
|
|
52
|
+
const autoContinueTurns = new WeakMap();
|
|
53
|
+
|
|
50
54
|
/** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
|
|
51
55
|
function canonicalDeviceKey(req, fallback = 'legacy') {
|
|
52
56
|
const value = req?.headers?.['x-dsh-device'];
|
|
@@ -241,6 +245,21 @@ function workspaceShortcutStateOf(ctx) {
|
|
|
241
245
|
return state;
|
|
242
246
|
}
|
|
243
247
|
|
|
248
|
+
/**
|
|
249
|
+
* 「这一轮已经替用户代发过『继续』」的记账:sessionId → 轮次。
|
|
250
|
+
*
|
|
251
|
+
* 只挡事件流重连的重放(同一轮会到两条 `turn/end`),不做次数上限 —— 次数由会话历史里
|
|
252
|
+
* 连续的代发消息数出来,见 `mobile/auto-continue.js` 的文件头。
|
|
253
|
+
*/
|
|
254
|
+
function autoContinueTurnsOf(ctx) {
|
|
255
|
+
let turns = autoContinueTurns.get(ctx);
|
|
256
|
+
if (!turns) {
|
|
257
|
+
turns = new Map();
|
|
258
|
+
autoContinueTurns.set(ctx, turns);
|
|
259
|
+
}
|
|
260
|
+
return turns;
|
|
261
|
+
}
|
|
262
|
+
|
|
244
263
|
function completionGenerationOf(ctx, deviceKey) {
|
|
245
264
|
let generations = completionGenerations.get(ctx);
|
|
246
265
|
if (!generations) {
|
|
@@ -387,6 +406,7 @@ module.exports = {
|
|
|
387
406
|
clearCompletionForAllDevices,
|
|
388
407
|
completionGenerationOf,
|
|
389
408
|
workspaceShortcutStateOf,
|
|
409
|
+
autoContinueTurnsOf,
|
|
390
410
|
bumpCompletionGeneration,
|
|
391
411
|
sendIdempotencyStateOf,
|
|
392
412
|
promptOnceFor,
|
|
@@ -38,13 +38,26 @@ function createReconnectScheduler({
|
|
|
38
38
|
let timer = null
|
|
39
39
|
let misses = 0
|
|
40
40
|
return {
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。
|
|
43
|
+
*
|
|
44
|
+
* `overrideMs` 是「这台不是坏了、是被人挡在门外」那类失败的专用间隔(目前只有中转回的
|
|
45
|
+
* `plugin_too_old`):它绕开指数退避、也不吃 `maxDelayMs` 的封顶。给一个固定间隔而不是
|
|
46
|
+
* 退避,是因为窗口是按「人去更新插件」量级的,翻倍退避在这一档上没有意义。
|
|
47
|
+
*/
|
|
48
|
+
schedule(overrideMs) {
|
|
43
49
|
if (timer !== null) return false
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
},
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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: () => {
|
|
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 => {
|
|
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() {
|
package/lib/update-install.cjs
CHANGED
|
@@ -7,16 +7,26 @@
|
|
|
7
7
|
* 另一个插件没装、太老或坏了就整个失效。
|
|
8
8
|
*
|
|
9
9
|
* 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
|
|
10
|
-
* 关键设置,这里只「cd 到 profile 跑一句 pnpm add
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
337
|
-
//
|
|
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,
|
|
563
|
+
? ['add', spec, ...policyArgs]
|
|
342
564
|
: ['install', spec, '--no-audit', '--no-fund'];
|
|
343
|
-
|
|
565
|
+
const command = [picked.manager, ...args].join(' ');
|
|
344
566
|
onEvent({ phase: 'install-started', command, manager: picked.kind });
|
|
345
567
|
|
|
346
|
-
const
|
|
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
|
-
|
|
573
|
+
|
|
574
|
+
let result = await runManager([]);
|
|
352
575
|
let output = result.output;
|
|
353
576
|
/**
|
|
354
|
-
*
|
|
577
|
+
* 宿主环境对不上:不是这个包有问题,是这台机器的依赖树跟当前 pnpm 对不上 —— 不修的话,
|
|
578
|
+
* 之后每一次更新都会撞同一堵墙。修一次、重试一次;修不了就如实失败,下个 tick 再来。
|
|
355
579
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
431
|
-
*
|
|
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
|
-
|
|
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