@aaayeaaa-p1ws/dsh-plugin-auto-update 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/client.js +713 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +8 -0
- package/index.js +1135 -0
- package/lib/restart.mjs +48 -0
- package/lib/store.mjs +112 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/index.js
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自动更新(Host 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 每次打开 DSH(宿主启动)后,等市场把自己那条路由挂好,就通过插件市场自己的
|
|
5
|
+
* 公开接口 UPDATE-API V1 检查已安装插件有没有新版本,有就一个接一个直接装。
|
|
6
|
+
*
|
|
7
|
+
* 为什么走 HTTP 接口而不是 import 市场的内部模块:
|
|
8
|
+
* 市场把 /dsh-market/api/v1/* 声明成「稳定契约」(lib/update-api-v1.js + UPDATE-API-V1.md),
|
|
9
|
+
* 内部函数则随时会变。宿主 → loopback 调自己的 webServer 是市场本来就支持的用法。
|
|
10
|
+
*
|
|
11
|
+
* 安全约束(宿主启动是 all-or-nothing,本文件的任何异常都不能往上抛):
|
|
12
|
+
* - 顶层只 import node 内置模块;
|
|
13
|
+
* - apply() 里全部 try/catch,定时器 unref,dispose 时清干净;
|
|
14
|
+
* - 只在 loopback 上请求,不改 profile 文件、不碰 pnpm、不做任何除了「调市场接口」以外的事。
|
|
15
|
+
*
|
|
16
|
+
* 节奏(2026-11-01 修):
|
|
17
|
+
* - 每次宿主启动都会检查一轮,这是用户要的「每天打开 DSH 就检查」。启动这一轮
|
|
18
|
+
* 不受「今天已经跑过」的限制,只做一次「距上次检查不足 bootMinIntervalMinutes
|
|
19
|
+
* 就跳过」的节流,避免一天里反复重启把检查打爆。
|
|
20
|
+
* - 进程内的 tick 仍然按本地日历日判断,兼容「DSH 一直开着跨天」;
|
|
21
|
+
* 跨过午夜那一次算当天额度,但它不再是唯一机会 —— 下次开机还会再查。
|
|
22
|
+
* - 新发布的版本要过 pnpm 的 minimumReleaseAge(默认 24h)才会被市场报出来,
|
|
23
|
+
* 所以「发布当天就装上」是不可能的,通常晚 1 天。
|
|
24
|
+
* 踩过的坑:v1 的 summary 只回 updateAvailable===true 的行,被等待期扣住的版本
|
|
25
|
+
* 直接不出现(heldUpdate 只存在于内部接口),所以查完显示「已是最新」是正常的。
|
|
26
|
+
*/
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { CONFIG_DEFAULTS, normalizeConfig, readConfigFile, tickDue, writeConfigFile } from './lib/store.mjs';
|
|
32
|
+
import { desktopLaunchInfo, scheduleDesktopRestart } from './lib/restart.mjs';
|
|
33
|
+
|
|
34
|
+
export const name = 'dsh-auto-update';
|
|
35
|
+
|
|
36
|
+
/** 必须是显式依赖:拿 tools 服务要靠注入,apply 里再 ctx.get('tools') 是拿不到的。 */
|
|
37
|
+
export const inject = ['tools', 'webServer'];
|
|
38
|
+
|
|
39
|
+
const SCHEMA = 'dsh-market/update-api/v1';
|
|
40
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back']);
|
|
41
|
+
|
|
42
|
+
/** 可配置项与默认值。市场那一侧同一时刻只允许一个更新操作,这里全部串行。 */
|
|
43
|
+
const DEFAULTS = {
|
|
44
|
+
enabled: true,
|
|
45
|
+
autoApply: true,
|
|
46
|
+
oncePerDay: true,
|
|
47
|
+
delaySeconds: 12, // 启动后等多久再动手:让宿主把路由挂完、市场跑完自己的恢复逻辑
|
|
48
|
+
bootMinIntervalMinutes: 20, // 距上次检查不到这么久就跳过一次启动检查(防止一天里反复重启时狂打市场)
|
|
49
|
+
tickMinutes: 5, // 复查「是不是新的一天 / 有没有欠着的更新」,没欠账时只是一次读数
|
|
50
|
+
agentsBusyRetryMinutes: 5, // 有 agent 正在跑时市场会拒绝更新(AGENTS_RUNNING),隔几分钟再来,不算失败
|
|
51
|
+
drainWaitMinutes: 10, // 改用市场的 drain 模式后,最多陪它等 agent 空闲这么久;超时就把操作留给市场自己完成
|
|
52
|
+
heldRetryMinutes: 120, // 版本还在 24h 等待期(held):隔两小时再看一眼,别把今天的额度用掉
|
|
53
|
+
requestTimeoutMs: 25000,
|
|
54
|
+
operationTimeoutMs: 180000, // 单个插件(含 pnpm 安装)最多等多久
|
|
55
|
+
pollIntervalMs: 1500,
|
|
56
|
+
runBudgetMs: 15 * 60 * 1000,
|
|
57
|
+
retryDelayMinutes: 30,
|
|
58
|
+
maxAttemptsPerDay: 5, // 现在是「自上次成功以来连续失败了几轮」的上限,成功后归零
|
|
59
|
+
historyLimit: 20,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const EMPTY_STATE = {
|
|
63
|
+
version: 1,
|
|
64
|
+
lastDay: null,
|
|
65
|
+
lastCheckAt: null,
|
|
66
|
+
lastResult: null,
|
|
67
|
+
skipUntil: null,
|
|
68
|
+
attempts: { day: null, count: 0 },
|
|
69
|
+
agentsBusyStreak: 0,
|
|
70
|
+
pendingRefresh: [],
|
|
71
|
+
history: [],
|
|
72
|
+
/** 上次弹窗确认过的日期(当天弹过就不再弹) */
|
|
73
|
+
popupAckDay: null,
|
|
74
|
+
/** 待展示的一轮结果:{ day, at, result } */
|
|
75
|
+
popupPending: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------- 小工具
|
|
79
|
+
|
|
80
|
+
function errText(error) {
|
|
81
|
+
if (error instanceof Error) return error.message;
|
|
82
|
+
return String(error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sleep(ms) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const timer = setTimeout(resolve, ms);
|
|
88
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function localDay(date = new Date()) {
|
|
93
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
94
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
95
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function localTime(value) {
|
|
99
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
100
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return typeof value === 'string' ? value : '?';
|
|
101
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
102
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function num(value, fallback, min, max) {
|
|
106
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
107
|
+
if (!Number.isFinite(n)) return fallback;
|
|
108
|
+
if (n < min) return min;
|
|
109
|
+
if (n > max) return max;
|
|
110
|
+
return n;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function bool(value, fallback) {
|
|
114
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function str(value, fallback) {
|
|
118
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clip(value, max = 600) {
|
|
122
|
+
const text = typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
|
|
123
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trimBase(value) {
|
|
127
|
+
if (typeof value !== 'string') return null;
|
|
128
|
+
const text = value.trim().replace(/\/+$/, '');
|
|
129
|
+
return /^https?:\/\/[^\s]+$/i.test(text) ? text : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 一次 HTTP 往返;任何异常都收敛成 {status:0, error},调用方不用再 try。 */
|
|
133
|
+
async function requestJson(base, path, options = {}) {
|
|
134
|
+
const timeoutMs = options.timeoutMs || 25000;
|
|
135
|
+
const url = `${base}${path}`;
|
|
136
|
+
const init = {
|
|
137
|
+
method: options.method || 'GET',
|
|
138
|
+
headers: { accept: 'application/json' },
|
|
139
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
140
|
+
};
|
|
141
|
+
if (options.body !== undefined) {
|
|
142
|
+
init.headers['content-type'] = 'application/json';
|
|
143
|
+
init.body = JSON.stringify(options.body);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(url, init);
|
|
147
|
+
const text = await response.text();
|
|
148
|
+
let json = null;
|
|
149
|
+
try {
|
|
150
|
+
json = text ? JSON.parse(text) : null;
|
|
151
|
+
} catch {
|
|
152
|
+
json = null;
|
|
153
|
+
}
|
|
154
|
+
return { status: response.status, ok: response.ok, json, text: clip(text, 1200) };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { status: 0, ok: false, json: null, text: clip(errText(error), 400) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function messageOf(reply) {
|
|
161
|
+
const json = reply && reply.json;
|
|
162
|
+
if (json && typeof json === 'object') {
|
|
163
|
+
if (typeof json.error === 'string') return clip(json.error, 600);
|
|
164
|
+
if (json.failure && typeof json.failure.message === 'string') return clip(json.failure.message, 600);
|
|
165
|
+
}
|
|
166
|
+
return clip((reply && reply.text) || '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function codeOf(reply) {
|
|
170
|
+
const json = reply && reply.json;
|
|
171
|
+
if (json && typeof json === 'object') {
|
|
172
|
+
if (json.failure && typeof json.failure.code === 'string') return json.failure.code;
|
|
173
|
+
if (typeof json.error === 'string' && json.error) return 'HTTP_' + String(reply.status);
|
|
174
|
+
}
|
|
175
|
+
return 'HTTP_' + String((reply && reply.status) || 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 这些状态码只说明「这一轮不行」,不代表今天没戏:
|
|
180
|
+
* 0 是网络/端口失败,405 见过市场路由热挂载瞬间的空回(2026-11-10 的坑),
|
|
181
|
+
* 409 是市场被别的操作占着(同一时刻只允许一个更新),5xx 是市场自己出状况。
|
|
182
|
+
*/
|
|
183
|
+
function isTransientStatus(status) {
|
|
184
|
+
return status === 0 || status === 405 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 市场的「版本还在等待期」拒绝(pnpm minimumReleaseAge,发布不满 24h):
|
|
189
|
+
* 不是故障也不是今天没戏,等它的时间窗过去再试就行。drain 路径下市场只回
|
|
190
|
+
* UPDATE_REJECTED + 原文错误,所以除了 code 还要看文案。
|
|
191
|
+
*/
|
|
192
|
+
function isHeldUpdate(outcome) {
|
|
193
|
+
if (!outcome) return false;
|
|
194
|
+
const code = String(outcome.code || '');
|
|
195
|
+
if (code === 'RELEASE_AGE_HELD' || code === 'RELEASE_AGE_UNVERIFIED' || code === 'TARGET_CHANGED') return true;
|
|
196
|
+
return /等待期|held|release-age/i.test(String(outcome.message || ''));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------- 插件入口
|
|
200
|
+
|
|
201
|
+
export function apply(ctx, rawConfig = {}) {
|
|
202
|
+
const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
203
|
+
const cfg = {
|
|
204
|
+
enabled: bool(config.enabled, DEFAULTS.enabled),
|
|
205
|
+
autoApply: bool(config.autoApply, DEFAULTS.autoApply),
|
|
206
|
+
oncePerDay: bool(config.oncePerDay, DEFAULTS.oncePerDay),
|
|
207
|
+
delaySeconds: num(config.delaySeconds, DEFAULTS.delaySeconds, 0, 600),
|
|
208
|
+
bootMinIntervalMinutes: num(config.bootMinIntervalMinutes, DEFAULTS.bootMinIntervalMinutes, 0, 1440),
|
|
209
|
+
tickMinutes: num(config.tickMinutes, DEFAULTS.tickMinutes, 1, 1440),
|
|
210
|
+
agentsBusyRetryMinutes: num(config.agentsBusyRetryMinutes, DEFAULTS.agentsBusyRetryMinutes, 1, 120),
|
|
211
|
+
drainWaitMinutes: num(config.drainWaitMinutes, DEFAULTS.drainWaitMinutes, 0, 120),
|
|
212
|
+
heldRetryMinutes: num(config.heldRetryMinutes, DEFAULTS.heldRetryMinutes, 5, 1440),
|
|
213
|
+
requestTimeoutMs: num(config.requestTimeoutMs, DEFAULTS.requestTimeoutMs, 2000, 120000),
|
|
214
|
+
operationTimeoutMs: num(config.operationTimeoutMs, DEFAULTS.operationTimeoutMs, 30000, 1800000),
|
|
215
|
+
pollIntervalMs: num(config.pollIntervalMs, DEFAULTS.pollIntervalMs, 500, 30000),
|
|
216
|
+
runBudgetMs: num(config.runBudgetMs, DEFAULTS.runBudgetMs, 60000, 3600000),
|
|
217
|
+
retryDelayMinutes: num(config.retryDelayMinutes, DEFAULTS.retryDelayMinutes, 1, 720),
|
|
218
|
+
maxAttemptsPerDay: num(config.maxAttemptsPerDay, DEFAULTS.maxAttemptsPerDay, 1, 100),
|
|
219
|
+
historyLimit: num(config.historyLimit, DEFAULTS.historyLimit, 1, 200),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME.trim() : join(homedir(), '.dsh');
|
|
223
|
+
const stateDir = join(home, 'auto-update');
|
|
224
|
+
const statePath = join(stateDir, 'state.json');
|
|
225
|
+
const logPath = join(stateDir, 'log.ndjson');
|
|
226
|
+
/** GUI 里改得到的设置:config.json 覆盖 cordis.patch.yml 里的同名项。 */
|
|
227
|
+
const configPath = join(stateDir, 'config.json');
|
|
228
|
+
|
|
229
|
+
let disposed = false;
|
|
230
|
+
let running = false;
|
|
231
|
+
const handles = new Set();
|
|
232
|
+
// 每次装载(= 一个宿主进程)一个新的 id,用来判断「上次写的待重启提示是不是重启前的」。
|
|
233
|
+
const instanceId = randomUUID();
|
|
234
|
+
|
|
235
|
+
// ------------------------------------------------------------ 落盘
|
|
236
|
+
|
|
237
|
+
async function readState() {
|
|
238
|
+
try {
|
|
239
|
+
const text = await readFile(statePath, 'utf8');
|
|
240
|
+
const parsed = JSON.parse(text);
|
|
241
|
+
if (!parsed || typeof parsed !== 'object') return { ...EMPTY_STATE };
|
|
242
|
+
return {
|
|
243
|
+
...EMPTY_STATE,
|
|
244
|
+
...parsed,
|
|
245
|
+
attempts:
|
|
246
|
+
parsed.attempts && typeof parsed.attempts === 'object'
|
|
247
|
+
? { day: parsed.attempts.day ?? null, count: Number(parsed.attempts.count) || 0 }
|
|
248
|
+
: { ...EMPTY_STATE.attempts },
|
|
249
|
+
pendingRefresh: Array.isArray(parsed.pendingRefresh) ? parsed.pendingRefresh : [],
|
|
250
|
+
history: Array.isArray(parsed.history) ? parsed.history : [],
|
|
251
|
+
popupAckDay: typeof parsed.popupAckDay === 'string' ? parsed.popupAckDay : null,
|
|
252
|
+
popupPending:
|
|
253
|
+
parsed.popupPending && typeof parsed.popupPending === 'object' && parsed.popupPending.result
|
|
254
|
+
? parsed.popupPending
|
|
255
|
+
: null,
|
|
256
|
+
};
|
|
257
|
+
} catch {
|
|
258
|
+
return { ...EMPTY_STATE, attempts: { ...EMPTY_STATE.attempts }, pendingRefresh: [], history: [] };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeState(state) {
|
|
263
|
+
try {
|
|
264
|
+
await mkdir(stateDir, { recursive: true });
|
|
265
|
+
const tmp = `${statePath}.tmp`;
|
|
266
|
+
await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
267
|
+
await rename(tmp, statePath);
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function logLine(level, event, message, extra) {
|
|
275
|
+
const entry = { at: new Date().toISOString(), level, event, message: clip(message, 1200) };
|
|
276
|
+
if (extra && typeof extra === 'object') entry.data = extra;
|
|
277
|
+
try {
|
|
278
|
+
await mkdir(stateDir, { recursive: true });
|
|
279
|
+
await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
280
|
+
} catch {
|
|
281
|
+
/* 日志写不进去也不影响主流程 */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ------------------------------------------------------------ 用户设置
|
|
286
|
+
|
|
287
|
+
let userConfig = null;
|
|
288
|
+
let userConfigLoaded = false;
|
|
289
|
+
|
|
290
|
+
async function currentConfig() {
|
|
291
|
+
if (!userConfigLoaded) {
|
|
292
|
+
userConfigLoaded = true;
|
|
293
|
+
userConfig = (await readConfigFile(configPath)) || normalizeConfig({}, CONFIG_DEFAULTS);
|
|
294
|
+
}
|
|
295
|
+
return userConfig;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function saveConfig(patch) {
|
|
299
|
+
const base = await currentConfig();
|
|
300
|
+
const merged = normalizeConfig({ ...base, ...(patch && typeof patch === 'object' ? patch : {}) }, CONFIG_DEFAULTS);
|
|
301
|
+
const saved = await writeConfigFile(configPath, merged);
|
|
302
|
+
if (saved) userConfig = saved;
|
|
303
|
+
await logLine('info', 'config-saved', `设置已保存:${JSON.stringify(saved || merged)}`);
|
|
304
|
+
return saved || merged;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 弹窗只需要「装了什么 / 挂在哪」,把一轮结果裁小再存。 */
|
|
308
|
+
function popupPayload(result) {
|
|
309
|
+
const applied = (result.applied || []).map((item) => ({
|
|
310
|
+
name: item.name,
|
|
311
|
+
from: item.from || null,
|
|
312
|
+
to: item.installed || item.to || null,
|
|
313
|
+
}));
|
|
314
|
+
const failed = (result.failed || []).map((item) => ({
|
|
315
|
+
name: item.name,
|
|
316
|
+
reason: item.message || item.code || item.state || '未知原因',
|
|
317
|
+
code: item.code || item.state || null,
|
|
318
|
+
inUse: item.inUse === true,
|
|
319
|
+
}));
|
|
320
|
+
return {
|
|
321
|
+
at: result.at,
|
|
322
|
+
day: result.day,
|
|
323
|
+
checked: result.checked,
|
|
324
|
+
updatable: result.updatable,
|
|
325
|
+
applied,
|
|
326
|
+
failed,
|
|
327
|
+
deferred: (result.deferred || []).map((item) => ({ name: item.name, reason: item.reason || null })),
|
|
328
|
+
note: result.note || null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ------------------------------------------------------------ 找市场接口
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 解析出本机市场 API 的 origin。
|
|
336
|
+
* 优先问宿主自己的 webServer(最准),再退到环境变量,最后试默认端口。
|
|
337
|
+
* 逐个探 capabilities,只有真的答 200 的才算数。
|
|
338
|
+
*/
|
|
339
|
+
async function resolveMarket() {
|
|
340
|
+
const candidates = [];
|
|
341
|
+
const push = (value) => {
|
|
342
|
+
const base = trimBase(value);
|
|
343
|
+
if (base && !candidates.includes(base)) candidates.push(base);
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
const server = (ctx.get && ctx.get('webServer')) || ctx.webServer;
|
|
347
|
+
if (server && typeof server.getOrigin === 'function') push(server.getOrigin());
|
|
348
|
+
} catch {
|
|
349
|
+
/* 拿不到就走后面的候选 */
|
|
350
|
+
}
|
|
351
|
+
push(process.env.DSH_WEB_URL);
|
|
352
|
+
push('http://127.0.0.1:8817');
|
|
353
|
+
push('http://localhost:8817');
|
|
354
|
+
|
|
355
|
+
for (const base of candidates) {
|
|
356
|
+
const reply = await requestJson(base, '/dsh-market/api/v1/capabilities', { timeoutMs: cfg.requestTimeoutMs });
|
|
357
|
+
if (reply.status === 200 && reply.json && reply.json.schema === SCHEMA) {
|
|
358
|
+
return { base, capabilities: reply.json, tried: candidates };
|
|
359
|
+
}
|
|
360
|
+
if (reply.status === 404) {
|
|
361
|
+
// 端口通但没挂市场路由:市场没装或还没挂完,换下一个候选没意义,直接说明原因。
|
|
362
|
+
throw new Error(`市场接口不在 ${base}(HTTP 404):dshmarket 可能未安装或尚未就绪`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
throw new Error(`找不到可用的市场接口,已尝试:${candidates.join(', ') || '(无候选)'}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ------------------------------------------------------------ 更新流程
|
|
369
|
+
|
|
370
|
+
/** 发起一次更新。drain 模式让市场先排队,等 agent 空闲了它自己装(不占用我们的轮次)。 */
|
|
371
|
+
function postUpdate(base, name, drain) {
|
|
372
|
+
const body = drain ? { packageName: name, busyPolicy: 'drain' } : { packageName: name };
|
|
373
|
+
return requestJson(base, '/dsh-market/api/v1/updates', { method: 'POST', body, timeoutMs: cfg.requestTimeoutMs });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function updateOne(base, pkg, deadline) {
|
|
377
|
+
const name = str(pkg && pkg.name, '');
|
|
378
|
+
const from = str(pkg && pkg.installedVersion, null);
|
|
379
|
+
const to = str(pkg && pkg.latestVersion, null);
|
|
380
|
+
const base_ = { name, from, to, source: str(pkg && pkg.source, null) };
|
|
381
|
+
if (!name) return { ...base_, state: 'failed', code: 'BAD_PACKAGE', message: '市场返回的插件没有名字' };
|
|
382
|
+
|
|
383
|
+
// agent 正在跑的时候普通更新会被市场直接拒(AGENTS_RUNNING)。drain 模式让市场先排上队,
|
|
384
|
+
// 等 agent 空闲了它自己装完 —— 这样「一边聊天一边开着 DSH」也能把更新补上(2026-11-10 的坑)。
|
|
385
|
+
let start = await postUpdate(base, name, true);
|
|
386
|
+
if (start.status === 400 && /AGENT_DRAIN_UNSUPPORTED/.test(String(codeOf(start)))) {
|
|
387
|
+
// 宿主没有 Agent 维护边界、不支持 drain:退回普通的直连更新。
|
|
388
|
+
start = await postUpdate(base, name, false);
|
|
389
|
+
}
|
|
390
|
+
if (start.status !== 202) {
|
|
391
|
+
const code = codeOf(start);
|
|
392
|
+
const message = messageOf(start);
|
|
393
|
+
const transient = isTransientStatus(start.status) || code === 'OPERATION_BUSY';
|
|
394
|
+
return {
|
|
395
|
+
...base_,
|
|
396
|
+
state: 'failed',
|
|
397
|
+
code,
|
|
398
|
+
message,
|
|
399
|
+
// 405 / 409 / 5xx / 网络失败都只是「这一轮不行」;只有 400/404 这种才是真的没戏。
|
|
400
|
+
retryable: transient,
|
|
401
|
+
held: isHeldUpdate({ code, message }),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const operationId = str(start.json && start.json.operation && start.json.operation.operationId, '');
|
|
405
|
+
if (!operationId) return { ...base_, state: 'failed', code: 'NO_OPERATION', message: '市场没有返回 operationId' };
|
|
406
|
+
|
|
407
|
+
const stopAt = Math.min(Date.now() + cfg.operationTimeoutMs, deadline);
|
|
408
|
+
const waitCap = Date.now() + cfg.drainWaitMinutes * 60000;
|
|
409
|
+
while (Date.now() < stopAt && !disposed) {
|
|
410
|
+
await sleep(cfg.pollIntervalMs);
|
|
411
|
+
const reply = await requestJson(
|
|
412
|
+
base,
|
|
413
|
+
`/dsh-market/api/v1/operations?operationId=${encodeURIComponent(operationId)}`,
|
|
414
|
+
{ timeoutMs: cfg.requestTimeoutMs },
|
|
415
|
+
);
|
|
416
|
+
if (reply.status !== 200) continue;
|
|
417
|
+
const operation = (reply.json && reply.json.operation) || {};
|
|
418
|
+
const state = str(operation.state, '');
|
|
419
|
+
// drain:市场在等 agent 空闲(operation.wait 非空)。这种情况可能等很久,
|
|
420
|
+
// 别陪着干等到 operationTimeoutMs —— 记一笔交给下一轮,市场自己会装完。
|
|
421
|
+
if (operation.wait && Date.now() > waitCap) {
|
|
422
|
+
return {
|
|
423
|
+
...base_,
|
|
424
|
+
state: 'deferred',
|
|
425
|
+
operationId,
|
|
426
|
+
code: 'DRAIN_WAITING',
|
|
427
|
+
message: `市场排上队了,等 agent 空闲(${str(operation.wait.reason, 'agents')})就会自己装`,
|
|
428
|
+
retryable: true,
|
|
429
|
+
drainPending: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (!TERMINAL.has(state)) continue;
|
|
433
|
+
const failure = operation.failure && typeof operation.failure === 'object' ? operation.failure : null;
|
|
434
|
+
const outcome = operation.outcome && typeof operation.outcome === 'object' ? operation.outcome : {};
|
|
435
|
+
return {
|
|
436
|
+
...base_,
|
|
437
|
+
state,
|
|
438
|
+
operationId,
|
|
439
|
+
installed: str(operation.installedVersion, to),
|
|
440
|
+
code: failure ? str(failure.code, null) : null,
|
|
441
|
+
message: failure ? clip(failure.message, 600) : null,
|
|
442
|
+
retryable: failure ? failure.retryable === true : false,
|
|
443
|
+
refreshRequired: outcome.refreshRequired === true,
|
|
444
|
+
restartRequired: outcome.restartRequired === true,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
...base_,
|
|
449
|
+
state: 'failed',
|
|
450
|
+
operationId,
|
|
451
|
+
code: 'CLIENT_TIMEOUT',
|
|
452
|
+
message: `等了 ${Math.round(cfg.operationTimeoutMs / 1000)} 秒还没结束(可能是网络慢,也可能市场在等别的东西)`,
|
|
453
|
+
retryable: true,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 市场同一时刻只允许一个操作,所以严格串行;市场自己排最后,免得它一升级就把接口换掉。 */
|
|
458
|
+
async function applyAll(base, packages, result) {
|
|
459
|
+
const ordered = [...packages].sort((a, b) => weight(a) - weight(b));
|
|
460
|
+
const deadline = Date.now() + cfg.runBudgetMs;
|
|
461
|
+
for (const pkg of ordered) {
|
|
462
|
+
if (disposed) break;
|
|
463
|
+
if (Date.now() > deadline) {
|
|
464
|
+
result.skipped.push({ name: str(pkg && pkg.name, '?'), reason: '本次总时长用完,留到下次' });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const name = str(pkg && pkg.name, '');
|
|
468
|
+
try {
|
|
469
|
+
const outcome = await updateOne(base, pkg, deadline);
|
|
470
|
+
if (outcome.state === 'succeeded') {
|
|
471
|
+
result.applied.push(outcome);
|
|
472
|
+
await logLine('info', 'updated', `${outcome.name}: ${outcome.from || '?'} → ${outcome.installed || outcome.to || '?'}`);
|
|
473
|
+
if (outcome.restartRequired || outcome.refreshRequired) {
|
|
474
|
+
if (!result.pendingRefresh.includes(outcome.name)) result.pendingRefresh.push(outcome.name);
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (outcome.state === 'deferred' || outcome.drainPending === true) {
|
|
479
|
+
// 市场已经排上队了(drain),它会在 agent 空闲时自己装完;这一轮不再占用市场那唯一的坑。
|
|
480
|
+
const index = ordered.indexOf(pkg);
|
|
481
|
+
result.deferred.push({ ...outcome, reason: outcome.message || '市场排队中,稍后自己装完' });
|
|
482
|
+
for (const rest of ordered.slice(index + 1)) {
|
|
483
|
+
result.deferred.push({
|
|
484
|
+
name: str(rest && rest.name, '?'),
|
|
485
|
+
state: 'deferred',
|
|
486
|
+
code: 'DRAIN_WAITING',
|
|
487
|
+
reason: '市场同一时刻只处理一个操作,等前一个排完',
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
await logLine('info', 'update-deferred', `${outcome.name}: ${outcome.message || '市场排队中'}`);
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
if (outcome.code === 'AGENTS_RUNNING') {
|
|
494
|
+
// 市场在 agent 跑着的时候一律拒绝更新;剩下几个也一定一样,直接收手,等空闲再来。
|
|
495
|
+
const index = ordered.indexOf(pkg);
|
|
496
|
+
result.deferred.push({ ...outcome, reason: 'agent 正在运行,稍后自动重试' });
|
|
497
|
+
for (const rest of ordered.slice(index + 1)) {
|
|
498
|
+
result.deferred.push({
|
|
499
|
+
name: str(rest && rest.name, '?'),
|
|
500
|
+
state: 'deferred',
|
|
501
|
+
code: 'AGENTS_RUNNING',
|
|
502
|
+
reason: 'agent 正在运行,稍后自动重试',
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
await logLine('info', 'update-deferred', `${outcome.name}: agent 正在运行,本轮先不动手`);
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
result.failed.push({
|
|
509
|
+
...outcome,
|
|
510
|
+
// 「插件正在使用中」是唯一一种用户点一下就能救回来的失败:关掉插件 → 更新 → 重启。
|
|
511
|
+
inUse: /bundle-in-use|正在使用中|the plugin is in use/i.test(String(outcome.message || outcome.code || '')),
|
|
512
|
+
});
|
|
513
|
+
await logLine(
|
|
514
|
+
'warn',
|
|
515
|
+
outcome.held ? 'update-held' : 'update-failed',
|
|
516
|
+
`${outcome.name}: ${outcome.code || outcome.state} — ${outcome.message || ''}`,
|
|
517
|
+
);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
const message = errText(error);
|
|
520
|
+
result.failed.push({ name, state: 'failed', code: 'EXCEPTION', message: clip(message, 600), retryable: true });
|
|
521
|
+
await logLine('error', 'update-exception', `${name}: ${message}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function weight(pkg) {
|
|
527
|
+
const name = str(pkg && pkg.name, '').toLowerCase();
|
|
528
|
+
return name === 'dshmarket' || name === '@deepseek-ai/dsh-market' ? 9 : 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ------------------------------------------------------------ 每日判定 + 执行
|
|
532
|
+
|
|
533
|
+
async function runOnce(reason, options = {}) {
|
|
534
|
+
const state = await readState();
|
|
535
|
+
const now = Date.now();
|
|
536
|
+
const today = localDay();
|
|
537
|
+
const conf = await currentConfig();
|
|
538
|
+
|
|
539
|
+
const forced = options.force === true;
|
|
540
|
+
if (!forced && conf.enabled === false) {
|
|
541
|
+
return { ok: true, skipped: '自动更新已在设置里关掉', state };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 退避只挡进程内的 tick;开机这一轮靠下面的 bootMinIntervalMinutes 节流,不再被上一次的退避压住
|
|
545
|
+
// (否则「上次失败 → 30 分钟不再查」会把用户开机时想要的那次检查也吃掉)。
|
|
546
|
+
if (!forced && state.skipUntil && reason !== 'boot') {
|
|
547
|
+
const until = Date.parse(state.skipUntil);
|
|
548
|
+
if (Number.isFinite(until) && until > now) {
|
|
549
|
+
return { ok: true, skipped: `退避中,${localTime(state.skipUntil)} 之后再试`, state };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!forced && reason === 'boot') {
|
|
554
|
+
// 启动这一轮就是用户要的「每天打开 DSH 就检查」:不看 lastDay,只看「刚刚查过没有」。
|
|
555
|
+
// 少了这道判断,跨午夜那次 tick 会把当天额度用掉,之后所有开机检查都被跳过(2026-11-01 的坑)。
|
|
556
|
+
const last = Date.parse(state.lastCheckAt || '');
|
|
557
|
+
const gapMinutes = cfg.bootMinIntervalMinutes;
|
|
558
|
+
if (Number.isFinite(last) && gapMinutes > 0 && now - last < gapMinutes * 60000) {
|
|
559
|
+
const skipped = `距上次检查不到 ${gapMinutes} 分钟(上次 ${localTime(state.lastCheckAt)})`;
|
|
560
|
+
await logLine('info', 'boot-skip', skipped);
|
|
561
|
+
return { ok: true, skipped, state };
|
|
562
|
+
}
|
|
563
|
+
// 定时 / 间隔模式下,开机太早(还没到点)就不算这一轮,交给 tick 到点再跑。
|
|
564
|
+
if (conf.mode !== 'startup' && !state.skipUntil) {
|
|
565
|
+
const due = tickDue(conf, state);
|
|
566
|
+
if (!due.due && state.lastDay === today) return { ok: true, skipped: due.why, state };
|
|
567
|
+
}
|
|
568
|
+
} else if (!forced) {
|
|
569
|
+
if (conf.mode === 'startup') {
|
|
570
|
+
// 进程内 tick:当天已经成功跑过就不再重复;跨天时 lastDay 是昨天,自然放行。
|
|
571
|
+
// skipUntil 有值 = 还欠着一轮重试(agent 占着、或上次重试类失败),这种情况不能被日期闸门挡掉。
|
|
572
|
+
if (cfg.oncePerDay && state.lastDay === today && !state.skipUntil) {
|
|
573
|
+
return { ok: true, skipped: `今天(${today})已经检查过`, state };
|
|
574
|
+
}
|
|
575
|
+
} else if (!state.skipUntil) {
|
|
576
|
+
const due = tickDue(conf, state);
|
|
577
|
+
if (!due.due) return { ok: true, skipped: due.why, state };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const attempts = state.attempts && state.attempts.day === today ? state.attempts : { day: today, count: 0 };
|
|
582
|
+
if (!forced && attempts.count >= cfg.maxAttemptsPerDay) {
|
|
583
|
+
return { ok: false, skipped: `连续失败 ${attempts.count} 次,不再自动重试`, state };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const result = {
|
|
587
|
+
at: new Date().toISOString(),
|
|
588
|
+
day: today,
|
|
589
|
+
reason,
|
|
590
|
+
base: null,
|
|
591
|
+
marketVersion: null,
|
|
592
|
+
checked: 0,
|
|
593
|
+
updatable: 0,
|
|
594
|
+
applied: [],
|
|
595
|
+
failed: [],
|
|
596
|
+
deferred: [],
|
|
597
|
+
skipped: [],
|
|
598
|
+
pendingRefresh: [],
|
|
599
|
+
note: null,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// 先记一次尝试再动手:万一中途崩了,也不会陷入当天无限重试。
|
|
603
|
+
attempts.count += 1;
|
|
604
|
+
state.attempts = attempts;
|
|
605
|
+
state.lastCheckAt = result.at;
|
|
606
|
+
await writeState(state);
|
|
607
|
+
|
|
608
|
+
let fatal = null;
|
|
609
|
+
try {
|
|
610
|
+
const market = await resolveMarket();
|
|
611
|
+
result.base = market.base;
|
|
612
|
+
result.marketVersion = str(market.capabilities && market.capabilities.marketVersion, null);
|
|
613
|
+
|
|
614
|
+
const summary = await requestJson(market.base, '/dsh-market/api/v1/updates/summary?force=1', {
|
|
615
|
+
timeoutMs: Math.max(cfg.requestTimeoutMs, 60000), // 全量检查要问 npm / GitHub,慢一点
|
|
616
|
+
});
|
|
617
|
+
if (summary.status !== 200 || !summary.json || typeof summary.json !== 'object') {
|
|
618
|
+
throw new Error(`检查更新失败:HTTP ${summary.status} ${messageOf(summary)}`.trim());
|
|
619
|
+
}
|
|
620
|
+
result.checked = Number(summary.json.checked) || 0;
|
|
621
|
+
const packages = Array.isArray(summary.json.packages) ? summary.json.packages : [];
|
|
622
|
+
result.updatable = packages.length;
|
|
623
|
+
|
|
624
|
+
if (packages.length === 0) {
|
|
625
|
+
result.note = `已是最新(检查了 ${result.checked} 个插件)`;
|
|
626
|
+
} else if (!conf.autoApply) {
|
|
627
|
+
result.note = `发现 ${packages.length} 个可更新,但「只检查不安装」开着,只记录`;
|
|
628
|
+
result.skipped.push(...packages.map((pkg) => ({ name: str(pkg && pkg.name, '?'), reason: 'autoApply=false' })));
|
|
629
|
+
} else {
|
|
630
|
+
result.note = `发现 ${packages.length} 个可更新,开始安装`;
|
|
631
|
+
await applyAll(market.base, packages, result);
|
|
632
|
+
}
|
|
633
|
+
} catch (error) {
|
|
634
|
+
fatal = errText(error);
|
|
635
|
+
result.note = fatal;
|
|
636
|
+
await logLine('error', 'check-failed', fatal);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const retryable = result.failed.some((item) => item.retryable === true);
|
|
640
|
+
const busy = result.failed.some((item) => item.code === 'OPERATION_BUSY');
|
|
641
|
+
const held = result.failed.some((item) => item.held === true);
|
|
642
|
+
const deferredOnly = result.deferred.length > 0 && result.failed.length === 0;
|
|
643
|
+
const allDone = fatal === null && result.failed.length === 0 && result.deferred.length === 0;
|
|
644
|
+
const next = await readState();
|
|
645
|
+
// 换了进程(真的重启过),上一轮的「需重启生效」提示就过期了。
|
|
646
|
+
if (next.instanceId !== instanceId) {
|
|
647
|
+
next.pendingRefresh = [];
|
|
648
|
+
next.instanceId = instanceId;
|
|
649
|
+
}
|
|
650
|
+
next.lastCheckAt = result.at;
|
|
651
|
+
next.lastResult = result;
|
|
652
|
+
next.attempts = attempts;
|
|
653
|
+
if (result.pendingRefresh.length > 0) {
|
|
654
|
+
const merged = new Set([...(Array.isArray(next.pendingRefresh) ? next.pendingRefresh : []), ...result.pendingRefresh]);
|
|
655
|
+
next.pendingRefresh = [...merged];
|
|
656
|
+
}
|
|
657
|
+
if (allDone) {
|
|
658
|
+
next.lastDay = today;
|
|
659
|
+
next.skipUntil = null;
|
|
660
|
+
next.agentsBusyStreak = 0;
|
|
661
|
+
next.heldRounds = 0;
|
|
662
|
+
// 成功一轮就把「连续失败」清零:attempts 现在只统计失败,不再按天累计,
|
|
663
|
+
// 否则一天里开几次 DSH 就会撞上 maxAttemptsPerDay 而再也不查。
|
|
664
|
+
next.attempts = { day: today, count: 0 };
|
|
665
|
+
} else if (deferredOnly) {
|
|
666
|
+
// agent 正忙、市场主动拒绝:这不算今天的额度,隔几分钟再来;连着被拒就指数退避,别一直打搅。
|
|
667
|
+
attempts.count = Math.max(0, attempts.count - 1);
|
|
668
|
+
const streak = (Number(next.agentsBusyStreak) || 0) + 1;
|
|
669
|
+
next.agentsBusyStreak = streak;
|
|
670
|
+
const minutes = Math.min(cfg.agentsBusyRetryMinutes * 2 ** Math.min(streak - 1, 4), 60);
|
|
671
|
+
next.skipUntil = new Date(Date.now() + minutes * 60000).toISOString();
|
|
672
|
+
} else if (held && !retryable && fatal === null) {
|
|
673
|
+
// 只是「版本太新、还在 24h 等待期」:不是故障,也不是今天没戏 —— 过两小时再看一眼。
|
|
674
|
+
// 以前这种被当成「不可重试」,直接把 lastDay 记成今天、一整天都不再检查了(2026-11-10 的坑)。
|
|
675
|
+
attempts.count = Math.max(0, attempts.count - 1);
|
|
676
|
+
const heldRounds = (Number(next.heldRounds) || 0) + 1;
|
|
677
|
+
next.heldRounds = heldRounds;
|
|
678
|
+
if (heldRounds > 6) {
|
|
679
|
+
// 连着几轮都卡在等待期:今天先算了,明天照常检查。
|
|
680
|
+
next.lastDay = today;
|
|
681
|
+
next.skipUntil = null;
|
|
682
|
+
next.heldRounds = 0;
|
|
683
|
+
} else {
|
|
684
|
+
next.skipUntil = new Date(Date.now() + cfg.heldRetryMinutes * 60000).toISOString();
|
|
685
|
+
}
|
|
686
|
+
} else if (retryable || fatal !== null) {
|
|
687
|
+
// 市场被别的操作占着 / 网络抽风:几分钟就回来;别的失败按 retryDelayMinutes 退。
|
|
688
|
+
const minutes = busy ? cfg.agentsBusyRetryMinutes : cfg.retryDelayMinutes;
|
|
689
|
+
next.skipUntil = new Date(Date.now() + minutes * 60000).toISOString();
|
|
690
|
+
} else {
|
|
691
|
+
// 有非重试类失败(比如市场自己不能自己更新):当天不再折腾,明天再试。
|
|
692
|
+
next.lastDay = today;
|
|
693
|
+
next.skipUntil = null;
|
|
694
|
+
}
|
|
695
|
+
const history = Array.isArray(next.history) ? next.history : [];
|
|
696
|
+
history.unshift({
|
|
697
|
+
at: result.at,
|
|
698
|
+
day: today,
|
|
699
|
+
reason,
|
|
700
|
+
checked: result.checked,
|
|
701
|
+
updatable: result.updatable,
|
|
702
|
+
applied: result.applied.map((item) => `${item.name} ${item.from || '?'}→${item.installed || item.to || '?'}`),
|
|
703
|
+
failed: result.failed.map((item) => `${item.name} ${item.code || ''}`.trim()),
|
|
704
|
+
deferred: result.deferred.map((item) => item.name),
|
|
705
|
+
note: result.note,
|
|
706
|
+
});
|
|
707
|
+
next.history = history.slice(0, cfg.historyLimit);
|
|
708
|
+
// 这一轮真的装过东西或失败过,才值得开机弹一次;全是最新就别打扰。
|
|
709
|
+
const interesting = result.applied.length + result.failed.length > 0;
|
|
710
|
+
if (conf.popupEnabled !== false && interesting) {
|
|
711
|
+
next.popupPending = { day: today, at: result.at, result: popupPayload(result) };
|
|
712
|
+
}
|
|
713
|
+
await writeState(next);
|
|
714
|
+
|
|
715
|
+
await logLine('info', 'checked', result.note || '', {
|
|
716
|
+
checked: result.checked,
|
|
717
|
+
updatable: result.updatable,
|
|
718
|
+
applied: result.applied.length,
|
|
719
|
+
failed: result.failed.length,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
return { ok: fatal === null, result, state: next, fatal };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function tick(reason, options = {}) {
|
|
726
|
+
if (disposed) return { ok: false, skipped: '插件已卸载' };
|
|
727
|
+
if (running) return { ok: false, skipped: '上一轮还没跑完' };
|
|
728
|
+
running = true;
|
|
729
|
+
try {
|
|
730
|
+
return await runOnce(reason, options);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
await logLine('error', 'tick', errText(error));
|
|
733
|
+
return { ok: false, error: errText(error) };
|
|
734
|
+
} finally {
|
|
735
|
+
running = false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ------------------------------------------------------------ 定时器
|
|
740
|
+
|
|
741
|
+
function scheduleTimeout(fn, delay) {
|
|
742
|
+
const timer = setTimeout(() => {
|
|
743
|
+
handles.delete(timer);
|
|
744
|
+
try {
|
|
745
|
+
fn();
|
|
746
|
+
} catch {
|
|
747
|
+
/* 定时器里绝不抛 */
|
|
748
|
+
}
|
|
749
|
+
}, delay);
|
|
750
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
751
|
+
handles.add(timer);
|
|
752
|
+
return timer;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function clearAll() {
|
|
756
|
+
for (const handle of handles) {
|
|
757
|
+
try {
|
|
758
|
+
clearTimeout(handle);
|
|
759
|
+
clearInterval(handle);
|
|
760
|
+
} catch {
|
|
761
|
+
/* ignore */
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
handles.clear();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
ctx.effect(() => () => {
|
|
769
|
+
disposed = true;
|
|
770
|
+
clearAll();
|
|
771
|
+
});
|
|
772
|
+
} catch {
|
|
773
|
+
/* 没有 effect 就靠进程退出兜底 */
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (!cfg.enabled) {
|
|
777
|
+
void logLine('info', 'disabled', '插件自动更新被配置关掉了(enabled=false)');
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 启动后第一次:等宿主把市场路由挂完再动手。这一轮不受「今天跑过了」限制。
|
|
782
|
+
scheduleTimeout(() => void tick('boot'), Math.max(1000, cfg.delaySeconds * 1000));
|
|
783
|
+
|
|
784
|
+
// 之后每 30 秒看一眼「到点了吗 / 是不是还没成功」。只是一次读数,没到点立刻返回,
|
|
785
|
+
// 定时的「每天 09:30」「每 6 小时」才准点(2026-11-14 加设置页时改的)。
|
|
786
|
+
const tickIntervalMs = Math.max(30000, Math.min(300000, Math.round(cfg.tickMinutes * 60000)));
|
|
787
|
+
const interval = setInterval(() => void tick('tick'), tickIntervalMs);
|
|
788
|
+
if (typeof interval.unref === 'function') interval.unref();
|
|
789
|
+
handles.add(interval);
|
|
790
|
+
|
|
791
|
+
void currentConfig().then((conf) => {
|
|
792
|
+
const modeText =
|
|
793
|
+
conf.mode === 'daily'
|
|
794
|
+
? `每天 ${conf.dailyAt} 检查`
|
|
795
|
+
: conf.mode === 'interval'
|
|
796
|
+
? `每 ${conf.intervalHours} 小时检查`
|
|
797
|
+
: '每次打开 DSH 检查';
|
|
798
|
+
void logLine('info', 'armed', `已武装:${modeText}(启动后 ${cfg.delaySeconds}s 跑第一轮,距上次不足 ${cfg.bootMinIntervalMinutes} 分钟则跳过;每 ${Math.round(tickIntervalMs / 1000)} 秒复查一次是否到点)`, {
|
|
799
|
+
mode: conf.mode,
|
|
800
|
+
autoApply: conf.autoApply,
|
|
801
|
+
popupEnabled: conf.popupEnabled,
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// ------------------------------------------------------------ 一键修复(关插件 → 更新 → 重启)
|
|
806
|
+
|
|
807
|
+
const fixJob = { running: false, startedAt: null, steps: [], done: false, error: null };
|
|
808
|
+
|
|
809
|
+
function pluginManagerService() {
|
|
810
|
+
try {
|
|
811
|
+
if (ctx && typeof ctx.get === 'function') return ctx.get('pluginManager') || null;
|
|
812
|
+
return ctx.pluginManager || null;
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function fixAndRestart() {
|
|
819
|
+
if (fixJob.running) return { ok: false, error: '已经有一轮修复在跑了' };
|
|
820
|
+
fixJob.running = true;
|
|
821
|
+
fixJob.done = false;
|
|
822
|
+
fixJob.error = null;
|
|
823
|
+
fixJob.steps = [];
|
|
824
|
+
fixJob.startedAt = new Date().toISOString();
|
|
825
|
+
try {
|
|
826
|
+
const state = await readState();
|
|
827
|
+
const last = state.lastResult || {};
|
|
828
|
+
const blockers = [
|
|
829
|
+
...new Set(
|
|
830
|
+
(Array.isArray(last.failed) ? last.failed : [])
|
|
831
|
+
.filter((item) => item && item.inUse === true && item.name)
|
|
832
|
+
.map((item) => item.name),
|
|
833
|
+
),
|
|
834
|
+
];
|
|
835
|
+
await logLine('info', 'fix-start', `开始修复:${blockers.length ? blockers.join('、') : '(没有正在使用的插件)'}`);
|
|
836
|
+
const pm = pluginManagerService();
|
|
837
|
+
for (const name of blockers) {
|
|
838
|
+
if (!pm || typeof pm.setBundleEnabled !== 'function') {
|
|
839
|
+
fixJob.steps.push({ name, step: 'disable', ok: false, error: '拿不到 pluginManager,无法关闭插件' });
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
try {
|
|
843
|
+
const result = await pm.setBundleEnabled(name, false);
|
|
844
|
+
fixJob.steps.push({ name, step: 'disable', ok: true, application: result && result.application });
|
|
845
|
+
} catch (error) {
|
|
846
|
+
fixJob.steps.push({ name, step: 'disable', ok: false, error: errText(error) });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// 给卸载留一点时间,再让市场重试这几个。
|
|
850
|
+
await sleep(1500);
|
|
851
|
+
const round = await tick('fix', { force: true });
|
|
852
|
+
const applied = ((round && round.result && round.result.applied) || []).map((item) => `${item.name} → ${item.installed || item.to || '?'}`);
|
|
853
|
+
const failed = ((round && round.result && round.result.failed) || []).map((item) => `${item.name}(${item.message || item.code || '失败'})`);
|
|
854
|
+
fixJob.steps.push({ step: 'update', ok: applied.length > 0 && failed.length === 0, applied, failed });
|
|
855
|
+
// 关掉的插件要装回来(是否真的能重载另说,重启后一定是开的)。
|
|
856
|
+
for (const name of blockers) {
|
|
857
|
+
if (!pm || typeof pm.setBundleEnabled !== 'function') break;
|
|
858
|
+
try {
|
|
859
|
+
await pm.setBundleEnabled(name, true);
|
|
860
|
+
} catch {
|
|
861
|
+
/* 重启后由 profile 里的选择决定,这里失败不影响 */
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const restart =
|
|
865
|
+
blockers.length > 0 || applied.length > 0
|
|
866
|
+
? scheduleDesktopRestart({ delayMs: 1500 })
|
|
867
|
+
: { ok: false, reason: '没有需要重启的变化' };
|
|
868
|
+
fixJob.steps.push({ step: 'restart', ...restart });
|
|
869
|
+
fixJob.done = true;
|
|
870
|
+
await logLine('info', 'fix-done', `修复完成:装上 ${applied.length} 个,失败 ${failed.length} 个,重启 ${restart.ok ? '已安排' : '未安排'}`);
|
|
871
|
+
return { ok: true, steps: fixJob.steps, restart };
|
|
872
|
+
} catch (error) {
|
|
873
|
+
fixJob.error = errText(error);
|
|
874
|
+
await logLine('error', 'fix-failed', fixJob.error);
|
|
875
|
+
return { ok: false, error: fixJob.error };
|
|
876
|
+
} finally {
|
|
877
|
+
fixJob.running = false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ------------------------------------------------------------ GUI 路由(/dsh-auto-update/*)
|
|
882
|
+
|
|
883
|
+
function sendJson(res, status, body) {
|
|
884
|
+
try {
|
|
885
|
+
res.writeHead(status, {
|
|
886
|
+
'content-type': 'application/json; charset=utf-8',
|
|
887
|
+
'cache-control': 'no-store',
|
|
888
|
+
});
|
|
889
|
+
res.end(JSON.stringify(body));
|
|
890
|
+
} catch {
|
|
891
|
+
try {
|
|
892
|
+
res.end('{}');
|
|
893
|
+
} catch {
|
|
894
|
+
/* 连接已断 */
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function readBody(req) {
|
|
900
|
+
return new Promise((resolve) => {
|
|
901
|
+
let size = 0;
|
|
902
|
+
const chunks = [];
|
|
903
|
+
req.on('data', (chunk) => {
|
|
904
|
+
size += chunk.length;
|
|
905
|
+
if (size > 64 * 1024) return;
|
|
906
|
+
chunks.push(chunk);
|
|
907
|
+
});
|
|
908
|
+
req.on('end', () => {
|
|
909
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
910
|
+
try {
|
|
911
|
+
resolve(text ? JSON.parse(text) : {});
|
|
912
|
+
} catch {
|
|
913
|
+
resolve({});
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
req.on('error', () => resolve({}));
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function tailLog(limit) {
|
|
921
|
+
try {
|
|
922
|
+
const text = await readFile(logPath, 'utf8');
|
|
923
|
+
return text
|
|
924
|
+
.trim()
|
|
925
|
+
.split('\n')
|
|
926
|
+
.slice(-limit)
|
|
927
|
+
.filter((line) => line.trim())
|
|
928
|
+
.map((line) => {
|
|
929
|
+
try {
|
|
930
|
+
const entry = JSON.parse(line);
|
|
931
|
+
return { at: entry.at, level: entry.level, event: entry.event, message: entry.message || '' };
|
|
932
|
+
} catch {
|
|
933
|
+
return { at: null, level: 'info', event: 'raw', message: clip(line, 400) };
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
.reverse();
|
|
937
|
+
} catch {
|
|
938
|
+
return [];
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function statusPayload() {
|
|
943
|
+
const state = await readState();
|
|
944
|
+
const conf = await currentConfig();
|
|
945
|
+
const today = localDay();
|
|
946
|
+
const pending =
|
|
947
|
+
state.popupPending && state.popupPending.day === today && state.popupAckDay !== today ? state.popupPending : null;
|
|
948
|
+
return {
|
|
949
|
+
ok: true,
|
|
950
|
+
running,
|
|
951
|
+
today,
|
|
952
|
+
config: conf,
|
|
953
|
+
last: state.lastResult || null,
|
|
954
|
+
popup: pending ? { show: true, day: pending.day, at: pending.at, result: pending.result } : { show: false },
|
|
955
|
+
state: {
|
|
956
|
+
lastDay: state.lastDay || null,
|
|
957
|
+
lastCheckAt: state.lastCheckAt || null,
|
|
958
|
+
skipUntil: state.skipUntil || null,
|
|
959
|
+
pendingRefresh: Array.isArray(state.pendingRefresh) ? state.pendingRefresh : [],
|
|
960
|
+
attempts: state.attempts || null,
|
|
961
|
+
},
|
|
962
|
+
desktop: desktopLaunchInfo(),
|
|
963
|
+
fix: fixJob,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
ctx.effect(() =>
|
|
969
|
+
ctx.webServer.register({
|
|
970
|
+
kind: 'prefix',
|
|
971
|
+
path: '/dsh-auto-update',
|
|
972
|
+
async handler(req, res) {
|
|
973
|
+
const url = new URL(String(req.url || '/'), 'http://127.0.0.1');
|
|
974
|
+
const route = url.pathname.replace(/\/+$/, '') || '/dsh-auto-update';
|
|
975
|
+
try {
|
|
976
|
+
if (req.method === 'GET' && (route === '/dsh-auto-update' || route === '/dsh-auto-update/status')) {
|
|
977
|
+
return sendJson(res, 200, await statusPayload());
|
|
978
|
+
}
|
|
979
|
+
if (req.method === 'GET' && route === '/dsh-auto-update/log') {
|
|
980
|
+
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit')) || 30));
|
|
981
|
+
return sendJson(res, 200, { ok: true, entries: await tailLog(limit) });
|
|
982
|
+
}
|
|
983
|
+
if (req.method === 'POST' && route === '/dsh-auto-update/run') {
|
|
984
|
+
const outcome = await tick('manual', { force: true });
|
|
985
|
+
return sendJson(res, 200, {
|
|
986
|
+
ok: !(outcome && outcome.error),
|
|
987
|
+
skipped: (outcome && outcome.skipped) || null,
|
|
988
|
+
error: (outcome && outcome.error) || null,
|
|
989
|
+
result: (outcome && outcome.result) || null,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (req.method === 'POST' && route === '/dsh-auto-update/config') {
|
|
993
|
+
const body = await readBody(req);
|
|
994
|
+
const saved = await saveConfig(body);
|
|
995
|
+
return sendJson(res, 200, { ok: true, config: saved });
|
|
996
|
+
}
|
|
997
|
+
if (req.method === 'POST' && route === '/dsh-auto-update/popup/ack') {
|
|
998
|
+
const state = await readState();
|
|
999
|
+
state.popupAckDay = localDay();
|
|
1000
|
+
state.popupPending = null;
|
|
1001
|
+
await writeState(state);
|
|
1002
|
+
return sendJson(res, 200, { ok: true });
|
|
1003
|
+
}
|
|
1004
|
+
if (req.method === 'POST' && route === '/dsh-auto-update/fix') {
|
|
1005
|
+
// 修复要关插件、等市场重装、再拉起重启,可能几分钟;这里只负责开跑,结果让前端轮询 /status。
|
|
1006
|
+
if (fixJob.running) return sendJson(res, 409, { ok: false, error: '已经有一轮修复在跑了' });
|
|
1007
|
+
void fixAndRestart();
|
|
1008
|
+
return sendJson(res, 200, { ok: true, started: true });
|
|
1009
|
+
}
|
|
1010
|
+
return sendJson(res, 404, { ok: false, error: `未知路由 ${req.method} ${route}` });
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
return sendJson(res, 500, { ok: false, error: errText(error) });
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
}),
|
|
1016
|
+
);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
void logLine('error', 'gui-register-failed', errText(error));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ------------------------------------------------------------ 查询 / 手动触发工具
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
const tools = ctx.tools; // 靠 inject: ['tools'] 注入;以前这里 ctx.get('tools') 恒为空,工具从没注册成功
|
|
1025
|
+
if (!tools || typeof tools.register !== 'function') throw new Error('tools 服务不可用(inject 没生效?)');
|
|
1026
|
+
ctx.effect(() =>
|
|
1027
|
+
tools.register({
|
|
1028
|
+
name: 'dsh_auto_update_status',
|
|
1029
|
+
description:
|
|
1030
|
+
'查看「每天自动检查并安装插件市场更新」这次/上次跑的结果,记录文件在 ~/.dsh/auto-update/state.json;传 run=true 可以立刻手动跑一轮(会真的安装更新)。',
|
|
1031
|
+
parameters: {
|
|
1032
|
+
type: 'object',
|
|
1033
|
+
properties: {
|
|
1034
|
+
run: { type: 'boolean', description: 'true = 现在立刻检查并安装(忽略「今天已经跑过」的限制)' },
|
|
1035
|
+
logLines: { type: 'number', description: '附带多少条最近日志,默认 10,最多 50' },
|
|
1036
|
+
},
|
|
1037
|
+
additionalProperties: false,
|
|
1038
|
+
},
|
|
1039
|
+
output: {
|
|
1040
|
+
schema: {
|
|
1041
|
+
type: 'object',
|
|
1042
|
+
properties: {
|
|
1043
|
+
ok: { type: 'boolean' },
|
|
1044
|
+
running: { type: 'boolean' },
|
|
1045
|
+
skipped: { type: 'string' },
|
|
1046
|
+
summary: { type: 'string' },
|
|
1047
|
+
state: { type: 'object' },
|
|
1048
|
+
log: { type: 'array', items: { type: 'string' } },
|
|
1049
|
+
},
|
|
1050
|
+
additionalProperties: true,
|
|
1051
|
+
},
|
|
1052
|
+
render(args, value) {
|
|
1053
|
+
const v = value || {};
|
|
1054
|
+
return [{ type: 'text', text: typeof v.summary === 'string' && v.summary ? v.summary : '(没有结果)' }];
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
isConcurrencySafe: () => true,
|
|
1058
|
+
timeoutMs: 1200000,
|
|
1059
|
+
async execute(args) {
|
|
1060
|
+
const input = args && typeof args === 'object' ? args : {};
|
|
1061
|
+
let last = null;
|
|
1062
|
+
let skipped = null;
|
|
1063
|
+
if (input.run === true) {
|
|
1064
|
+
const outcome = await tick('manual', { force: true });
|
|
1065
|
+
if (outcome && outcome.skipped) skipped = outcome.skipped;
|
|
1066
|
+
if (outcome && outcome.result) last = outcome.result;
|
|
1067
|
+
}
|
|
1068
|
+
const state = await readState();
|
|
1069
|
+
const seen = last || state.lastResult || null;
|
|
1070
|
+
const lines = [];
|
|
1071
|
+
const limit = Math.max(1, Math.min(50, num(input.logLines, 10, 1, 50)));
|
|
1072
|
+
try {
|
|
1073
|
+
const text = await readFile(logPath, 'utf8');
|
|
1074
|
+
for (const line of text.trim().split('\n').slice(-limit)) {
|
|
1075
|
+
if (!line.trim()) continue;
|
|
1076
|
+
try {
|
|
1077
|
+
const entry = JSON.parse(line);
|
|
1078
|
+
lines.push(`${entry.at} [${entry.level}] ${entry.event} ${entry.message || ''}`.trim());
|
|
1079
|
+
} catch {
|
|
1080
|
+
lines.push(clip(line, 300));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} catch {
|
|
1084
|
+
/* 没有日志文件就留空 */
|
|
1085
|
+
}
|
|
1086
|
+
const summary = buildSummary(seen, state, skipped, running);
|
|
1087
|
+
const payload = {
|
|
1088
|
+
ok: !seen || !Array.isArray(seen.failed) || seen.failed.length === 0,
|
|
1089
|
+
running,
|
|
1090
|
+
summary,
|
|
1091
|
+
state: {
|
|
1092
|
+
lastDay: state.lastDay || null,
|
|
1093
|
+
lastCheckAt: state.lastCheckAt || null,
|
|
1094
|
+
skipUntil: state.skipUntil || null,
|
|
1095
|
+
attempts: state.attempts || null,
|
|
1096
|
+
pendingRefresh: Array.isArray(state.pendingRefresh) ? state.pendingRefresh : [],
|
|
1097
|
+
},
|
|
1098
|
+
log: lines,
|
|
1099
|
+
};
|
|
1100
|
+
if (skipped) payload.skipped = String(skipped);
|
|
1101
|
+
return payload;
|
|
1102
|
+
},
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
// 别静默吞:工具没注册上正是今天这个 bug 的表现,写进日志才查得到。
|
|
1107
|
+
void logLine('error', 'tool-register-failed', errText(error));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function buildSummary(result, state, skipped, isRunning) {
|
|
1111
|
+
const lines = [];
|
|
1112
|
+
if (isRunning) lines.push('⏳ 正在跑一轮更新…');
|
|
1113
|
+
if (skipped) lines.push(`本轮跳过:${skipped}`);
|
|
1114
|
+
if (result) {
|
|
1115
|
+
lines.push(`上次检查:${localTime(result.at)}(触发=${result.reason || '?'})`);
|
|
1116
|
+
lines.push(`市场:${result.marketVersion || '?'} @ ${result.base || '?'}`);
|
|
1117
|
+
lines.push(`扫了 ${result.checked} 个插件,可更新 ${result.updatable} 个`);
|
|
1118
|
+
for (const item of result.applied || []) lines.push(`✅ ${item.name} ${item.from || '?'} → ${item.installed || item.to || '?'}`);
|
|
1119
|
+
for (const item of result.failed || []) lines.push(`❌ ${item.name} ${item.code || item.state || ''} ${clip(item.message || '', 200)}`);
|
|
1120
|
+
for (const item of Array.isArray(result.deferred) ? result.deferred : []) {
|
|
1121
|
+
lines.push(`⏸ ${item.name} ${item.reason || '等 agent 空闲后再装'}`);
|
|
1122
|
+
}
|
|
1123
|
+
if (result.note) lines.push(`说明:${result.note}`);
|
|
1124
|
+
} else {
|
|
1125
|
+
lines.push('还没有跑过(每次打开 DSH 会检查一轮,也可以传 run=true 立刻跑)。');
|
|
1126
|
+
}
|
|
1127
|
+
if (state && state.lastDay) lines.push(`${state.lastDay} 的自动检查已完成;下次开机还会再查一轮`);
|
|
1128
|
+
if (state && state.skipUntil) lines.push(`下次自动重试不早于:${localTime(state.skipUntil)}`);
|
|
1129
|
+
if (state && Array.isArray(state.pendingRefresh) && state.pendingRefresh.length > 0) {
|
|
1130
|
+
lines.push(`已更新但需重启 DSH 生效:${state.pendingRefresh.join('、')}`);
|
|
1131
|
+
}
|
|
1132
|
+
lines.push('提示:新发布的版本要过 24 小时(pnpm minimumReleaseAge)才会被市场报成可更新,所以通常晚一天装。');
|
|
1133
|
+
return lines.join('\n');
|
|
1134
|
+
}
|
|
1135
|
+
}
|