dsh-update-center 0.8.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/lib/index.js ADDED
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * dsh-update-center — 更新中心(ui-panel 形态)。
3
+ *
4
+ * 能力:
5
+ * 1. 已安装插件清单(profile dependencies/bundles + 版本,npm/link 分类)
6
+ * 2. dsh 本体更新检查(git fetch → ahead/behind 对比)
7
+ * 3. 一键更新:dsh 本体(git pull + 可选 install/build)、单个 npm 插件、
8
+ * 已安装的 link/preset 插件
9
+ *
10
+ * 关键机制:
11
+ * - dsh 仓库代理复用 git 仓库级 http.proxy,npm/pnpm 走 Config.proxy 注入环境变量;
12
+ * - 所有子进程走异步 execAsync(./run-command.mjs:Windows .cmd 经
13
+ * cmd.exe /c 参数数组执行),检查更新不会阻塞 dsh web 的事件循环;
14
+ * - /status 有 5 秒快照缓存(?fresh=1 强制刷新),打开面板不再全量跑 git;
15
+ * - POST 动作校验 Origin 与 Host 同源,防止跨站触发更新;
16
+ * - 更新前拒绝脏工作区;更新后不自动结束自身进程,由面板提示用户重启。
17
+ */
18
+ import { defineTool } from '@deepseek-ai/dsh-tools';
19
+ import z from 'schemastery';
20
+ import { spawn } from 'node:child_process';
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { runCommandAsync } from './run-command.mjs';
27
+ import { mergeGithubTopic } from './github-topic.mjs';
28
+ import { applyNpmMapping } from './npm-mapping.mjs';
29
+ export const name = 'dsh-update-center';
30
+ export const inject = ['tools', 'webServer'];
31
+ export const Config = z.object({
32
+ repoDir: z.string().default(''),
33
+ profileDir: z.string().default(''),
34
+ presetDir: z.string().default(''),
35
+ proxy: z.string().default(''),
36
+ remote: z.string().default('origin'),
37
+ branch: z.string().default('master'),
38
+ upstreams: z.dict(z.string()).default({}),
39
+ });
40
+ /** git remote URL → GitHub owner/repo。捕获组限定为 GitHub 实际字符集
41
+ * (字母数字._-),输出天然 shell 安全;不匹配返回 null。 */
42
+ const UPSTREAM_RE = /github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/i;
43
+ /**
44
+ * 异步执行命令:候选解析(Windows .exe → .cmd)、cmd.exe 参数数组执行与
45
+ * ENOENT 重试竞态守卫统一实现在 ./run-command.mjs(与 worker 的执行语义一致)。
46
+ */
47
+ function execAsync(cmd, args, cwd, timeoutMs = 120_000, extraEnv) {
48
+ return runCommandAsync(cmd, args, cwd, timeoutMs, extraEnv);
49
+ }
50
+ function readJson(file) {
51
+ try {
52
+ return JSON.parse(readFileSync(file, 'utf8'));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ export function apply(ctx, config) {
59
+ const logger = ctx.logger;
60
+ // ── 路径解析(探测优先级:Config → DSH_CHECKOUT/cwd → home 常见路径)──
61
+ const repoDir = resolveRepoDir(config.repoDir);
62
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
63
+ const profileDir = config.profileDir || join(dshHome, 'profiles', 'web');
64
+ const presetDir = config.presetDir || join(dshHome, '.agent-presets');
65
+ const remote = config.remote || 'origin';
66
+ const branch = config.branch || 'master';
67
+ // 已知上游映射仅作兜底:优先展示 git remote 的真实来源。
68
+ const upstreams = {
69
+ 'dsh-bash-terminal': 'MAXeaglet/dsh-bash-terminal',
70
+ '@dsh-external/dsh-super-injector': 'yjh051108/dsh-super-injector',
71
+ 'dsh-router-standard': 'yjh051108/dsh-router-standard',
72
+ 'router-standard': 'yjh051108/dsh-router-standard',
73
+ ...config.upstreams,
74
+ };
75
+ // git 仓库级代理优先(用户在仓库 config 配过 http.proxy 时自动复用)
76
+ const proxyPromise = config.proxy
77
+ ? Promise.resolve(config.proxy)
78
+ : gitProxy(repoDir);
79
+ const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
80
+ const updateHome = join(dshHome, 'update-center');
81
+ const jobsDir = join(updateHome, 'jobs');
82
+ const latestJobPath = join(updateHome, 'latest.json');
83
+ const workerSource = join(pluginRoot, 'scripts', 'update-worker.mjs');
84
+ const workerTarget = join(updateHome, 'dsh-update-worker.mjs');
85
+ // /status 快照缓存:面板打开/任务轮询不再每次全量跑 git 子进程。
86
+ const STATUS_TTL_MS = 5_000;
87
+ let statusCache = null;
88
+ function writeJsonAtomic(file, value) {
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const temporary = `${file}.${process.pid}.tmp`;
91
+ writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf8');
92
+ renameSync(temporary, file);
93
+ }
94
+ function readJob(file) {
95
+ return readJson(file);
96
+ }
97
+ function processAlive(pid) {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ function latestJob() {
107
+ const job = readJob(latestJobPath);
108
+ if (!job || (job.status !== 'queued' && job.status !== 'running'))
109
+ return job;
110
+ const pid = typeof job.workerPid === 'number' ? job.workerPid : null;
111
+ const updatedAt = typeof job.updatedAt === 'string' ? Date.parse(job.updatedAt) : 0;
112
+ const staleQueuedJob = job.status === 'queued' && Date.now() - updatedAt > 15_000;
113
+ if ((pid !== null && processAlive(pid)) || (!pid && !staleQueuedJob))
114
+ return job;
115
+ const failed = {
116
+ ...job,
117
+ status: 'failed',
118
+ stage: 'worker-exit',
119
+ error: '后台更新进程已退出,任务没有完成。',
120
+ finishedAt: new Date().toISOString(),
121
+ updatedAt: new Date().toISOString(),
122
+ };
123
+ const id = typeof job.id === 'string' ? job.id : '';
124
+ if (/^[a-zA-Z0-9-]+$/.test(id))
125
+ writeJsonAtomic(join(jobsDir, `${id}.json`), failed);
126
+ writeJsonAtomic(latestJobPath, failed);
127
+ return failed;
128
+ }
129
+ function jobById(id) {
130
+ if (!/^[a-zA-Z0-9-]+$/.test(id))
131
+ return null;
132
+ return readJob(join(jobsDir, `${id}.json`));
133
+ }
134
+ async function startJob(spec) {
135
+ const active = latestJob();
136
+ if (active && (active.status === 'queued' || active.status === 'running')) {
137
+ return { ok: false, result: '已有更新任务正在执行,请等待当前任务完成。', job: active };
138
+ }
139
+ if (!existsSync(workerSource)) {
140
+ return { ok: false, result: `更新 worker 不存在: ${workerSource}` };
141
+ }
142
+ const proxy = await proxyPromise;
143
+ mkdirSync(jobsDir, { recursive: true });
144
+ copyFileSync(workerSource, workerTarget);
145
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`;
146
+ const statePath = join(jobsDir, `${id}.json`);
147
+ const specPath = join(jobsDir, `${id}.spec.json`);
148
+ const initial = {
149
+ id,
150
+ action: spec.action,
151
+ target: spec.target ?? '',
152
+ status: 'queued',
153
+ stage: 'queued',
154
+ message: '更新任务已排队',
155
+ steps: [],
156
+ restartRequired: false,
157
+ startedAt: new Date().toISOString(),
158
+ updatedAt: new Date().toISOString(),
159
+ };
160
+ writeJsonAtomic(statePath, initial);
161
+ writeJsonAtomic(latestJobPath, initial);
162
+ writeJsonAtomic(specPath, {
163
+ ...spec,
164
+ id,
165
+ statePath,
166
+ latestPath: latestJobPath,
167
+ proxy,
168
+ repoDir,
169
+ });
170
+ try {
171
+ // spawn 参数注入防护:worker 两路径均为内部拼接的绝对路径,拒绝任何以 "-" 开头的值;
172
+ // "--" 终止符确保 node 不会把后续参数解释为自己的选项。
173
+ for (const arg of [workerTarget, specPath]) {
174
+ if (!isAbsolute(arg) || arg.startsWith('-')) {
175
+ throw new Error(`worker 路径非法: ${arg}`);
176
+ }
177
+ }
178
+ const child = spawn(process.execPath, ['--', workerTarget, specPath], {
179
+ cwd: updateHome,
180
+ detached: true,
181
+ stdio: 'ignore',
182
+ windowsHide: true,
183
+ });
184
+ child.unref();
185
+ }
186
+ catch (error) {
187
+ const failed = {
188
+ ...initial,
189
+ status: 'failed',
190
+ stage: 'spawn',
191
+ error: error instanceof Error ? error.message : String(error),
192
+ finishedAt: new Date().toISOString(),
193
+ };
194
+ writeJsonAtomic(statePath, failed);
195
+ writeJsonAtomic(latestJobPath, failed);
196
+ return { ok: false, result: failed.error, job: failed };
197
+ }
198
+ return { ok: true, result: '更新任务已启动', job: initial };
199
+ }
200
+ function resolveRepoDir(configured) {
201
+ if (configured && existsSync(join(configured, 'package.json')))
202
+ return configured;
203
+ if (process.env.DSH_CHECKOUT && existsSync(join(process.env.DSH_CHECKOUT, 'package.json'))) {
204
+ return process.env.DSH_CHECKOUT;
205
+ }
206
+ const cwd = process.cwd();
207
+ if (existsSync(join(cwd, 'package.json'))) {
208
+ const pkg = readJson(join(cwd, 'package.json'));
209
+ if (pkg?.name === '@deepseek-ai/dsh-root')
210
+ return cwd;
211
+ }
212
+ for (const sub of ['dsh-harness', 'deepseek-harness', 'dsh']) {
213
+ const dir = join(homedir(), sub);
214
+ if (existsSync(join(dir, 'package.json')))
215
+ return dir;
216
+ }
217
+ return '';
218
+ }
219
+ /** git 仓库级代理(用户在仓库 config 配过 http.proxy 时自动复用)。 */
220
+ async function gitProxy(dir) {
221
+ if (!dir)
222
+ return '';
223
+ const r = await execAsync('git', ['config', '--get', 'http.proxy'], dir, 10_000);
224
+ return r.ok ? r.out.trim() : '';
225
+ }
226
+ /** git 仓库状态(不 fetch,只读本地 refs;各命令并行)。 */
227
+ async function repoStatus() {
228
+ if (!repoDir)
229
+ return { path: '', version: '', git: null, error: '未找到 dsh 仓库目录' };
230
+ const pkg = readJson(join(repoDir, 'package.json'));
231
+ const [head, branchOut, remoteUrl, dirtyOut] = await Promise.all([
232
+ execAsync('git', ['rev-parse', '--short', 'HEAD'], repoDir, 10_000),
233
+ execAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir, 10_000),
234
+ execAsync('git', ['remote', 'get-url', remote], repoDir, 10_000),
235
+ execAsync('git', ['status', '--porcelain'], repoDir, 10_000),
236
+ ]);
237
+ const count = (range) => execAsync('git', ['rev-list', '--count', range], repoDir, 10_000)
238
+ .then((r) => (r.ok ? Number(r.out.trim()) : null));
239
+ const [behind, ahead] = await Promise.all([
240
+ count(`HEAD..${remote}/${branch}`),
241
+ count(`${remote}/${branch}..HEAD`),
242
+ ]);
243
+ return {
244
+ path: repoDir,
245
+ version: typeof pkg?.version === 'string' ? pkg.version : '',
246
+ git: {
247
+ head: head.ok ? head.out.trim() : null,
248
+ branch: branchOut.ok ? branchOut.out.trim() : null,
249
+ remote: remoteUrl.ok ? remoteUrl.out.trim() : null,
250
+ ahead,
251
+ behind,
252
+ dirty: dirtyOut.ok ? dirtyOut.out.trim().length > 0 : null,
253
+ dirtyFiles: dirtyOut.ok ? dirtyOut.out.trim().split('\n').filter(Boolean).slice(0, 8) : [],
254
+ },
255
+ };
256
+ }
257
+ /** 插件基础清单:profile dependencies + agent-presets,只读文件、不跑子进程。 */
258
+ function inventoryBasic() {
259
+ const entries = [];
260
+ const profilePkg = readJson(join(profileDir, 'package.json'));
261
+ if (profilePkg) {
262
+ const deps = profilePkg.dependencies ?? {};
263
+ const dshMeta = profilePkg.dsh;
264
+ const bundles = dshMeta?.profile?.bundles ?? [];
265
+ const bundleSet = new Set(bundles.map(String));
266
+ for (const [name, spec] of Object.entries(deps)) {
267
+ const isLink = String(spec).startsWith('link:');
268
+ const rawLinkDir = isLink ? String(spec).slice(5) : null;
269
+ const linkDir = rawLinkDir
270
+ ? resolve(isAbsolute(rawLinkDir) ? rawLinkDir : join(profileDir, rawLinkDir))
271
+ : null;
272
+ let version = null;
273
+ if (linkDir) {
274
+ const lpkg = readJson(join(linkDir, 'package.json'));
275
+ version = typeof lpkg?.version === 'string' ? lpkg.version : null;
276
+ }
277
+ else {
278
+ const p = join(profileDir, 'node_modules', ...name.split('/'));
279
+ const lpkg = readJson(join(p, 'package.json'));
280
+ version = typeof lpkg?.version === 'string' ? lpkg.version : null;
281
+ }
282
+ entries.push({
283
+ name,
284
+ spec,
285
+ kind: isLink ? 'link' : 'npm',
286
+ version,
287
+ bundled: bundleSet.has(name),
288
+ linkDir: isLink ? linkDir : null,
289
+ git: null,
290
+ upstream: upstreams[name] ?? null,
291
+ disabled: name in readDisables(),
292
+ // npm 最新版:惰性填充(check 动作里查询)
293
+ latest: null,
294
+ });
295
+ }
296
+ }
297
+ // agent-presets 扫描:每个含 preset 标记的目录是一个 preset 插件
298
+ try {
299
+ for (const dirent of readdirSync(presetDir, { withFileTypes: true })) {
300
+ if (!dirent.isDirectory())
301
+ continue;
302
+ const dir = join(presetDir, dirent.name);
303
+ const presetPkg = readJson(join(dir, 'package.json'));
304
+ const version = typeof presetPkg?.version === 'string'
305
+ ? presetPkg.version
306
+ : null;
307
+ entries.push({
308
+ name: dirent.name,
309
+ spec: 'preset:' + dir,
310
+ kind: 'preset',
311
+ version,
312
+ bundled: false,
313
+ linkDir: dir,
314
+ git: null,
315
+ upstream: upstreams[dirent.name] ?? null,
316
+ latest: null,
317
+ });
318
+ }
319
+ }
320
+ catch { /* preset 目录不可读则跳过 */ }
321
+ return entries;
322
+ }
323
+ /** 从 git remote URL 提取 owner/repo(仅 GitHub 形态)。捕获组限定为 GitHub
324
+ * 实际允许的字符集(字母数字._-),输出天然 shell 安全;失败返回 null。 */
325
+ /**
326
+ * 本地 git 快照(head/branch/status/remote 并行;默认不 fetch)。
327
+ * fetch=true 时额外执行 fetch 并计算 ahead/behind(detached HEAD 回退远端默认分支)。
328
+ */
329
+ async function gitSnapshot(dir, options = {}) {
330
+ const fetchPromise = options.fetch
331
+ ? execAsync('git', ['fetch', '--all', '--prune'], dir, 60_000, options.env)
332
+ : null;
333
+ const [head, branchOut, status, remoteUrl] = await Promise.all([
334
+ execAsync('git', ['rev-parse', '--short', 'HEAD'], dir, 10_000),
335
+ execAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], dir, 10_000),
336
+ execAsync('git', ['status', '--porcelain'], dir, 10_000),
337
+ execAsync('git', ['remote', 'get-url', 'origin'], dir, 10_000),
338
+ ]);
339
+ const fetch = fetchPromise ? await fetchPromise : null;
340
+ const b = branchOut.ok ? branchOut.out.trim() : null;
341
+ const count = (range) => execAsync('git', ['rev-list', '--count', range], dir, 10_000)
342
+ .then((r) => (r.ok ? Number(r.out.trim()) : null));
343
+ let ahead = null;
344
+ let behind = null;
345
+ if (options.fetch) {
346
+ // detached HEAD(b === 'HEAD')没有 upstream:取第一个远端分支作为参照
347
+ let upstream = b && b !== 'HEAD' ? '@{u}' : null;
348
+ if (!upstream) {
349
+ const refs = await execAsync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'], dir, 10_000);
350
+ const refsList = refs.ok ? refs.out.trim().split('\n').filter(Boolean) : [];
351
+ const first = refsList.find((r) => !r.endsWith('/HEAD'));
352
+ if (first)
353
+ upstream = first;
354
+ }
355
+ if (upstream) {
356
+ ;
357
+ [behind, ahead] = await Promise.all([
358
+ count(`HEAD..${upstream}`),
359
+ count(`${upstream}..HEAD`),
360
+ ]);
361
+ }
362
+ }
363
+ const info = {
364
+ head: head.ok ? head.out.trim() : null,
365
+ branch: b,
366
+ remoteUrl: remoteUrl.ok ? remoteUrl.out.trim() : null,
367
+ ahead,
368
+ behind,
369
+ dirty: status.ok ? status.out.trim().length > 0 : null,
370
+ };
371
+ if (fetch) {
372
+ info.fetchOk = fetch.ok;
373
+ info.fetchErr = fetch.ok ? '' : fetch.err.trim().slice(0, 200);
374
+ }
375
+ return info;
376
+ }
377
+ /** 插件清单:基础清单 + git 信息(并行),upstream 优先取真实 remote。 */
378
+ async function pluginList() {
379
+ const entries = inventoryBasic();
380
+ await Promise.all(entries.map(async (entry) => {
381
+ const dir = typeof entry.linkDir === 'string' ? entry.linkDir : '';
382
+ if ((entry.kind === 'link' || entry.kind === 'preset') && dir && existsSync(join(dir, '.git'))) {
383
+ const git = await gitSnapshot(dir);
384
+ entry.git = git;
385
+ const upstreamMatch = typeof git.remoteUrl === 'string' ? UPSTREAM_RE.exec(git.remoteUrl.trim()) : null;
386
+ if (upstreamMatch)
387
+ entry.upstream = `${upstreamMatch[1]}/${upstreamMatch[2]}`;
388
+ }
389
+ }));
390
+ return entries;
391
+ }
392
+ /** 查询 npm registry 某包最新版(走代理,失败返回 null)。 */
393
+ async function npmLatest(pkg, env) {
394
+ const r = await execAsync('npm', ['view', pkg, 'version', '--json'], profileDir, 30_000, env);
395
+ if (!r.ok)
396
+ return null;
397
+ try {
398
+ const v = JSON.parse(r.out.trim());
399
+ return typeof v === 'string' ? v : null;
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
405
+ // ── 插件市场数据源(本仓库 plugins.json 注册表 + GitHub 主题扫描增量)──
406
+ // 主源:本仓库根目录 plugins.json 的 raw 直链(固定字面量端点,可独立于
407
+ // 插件发版更新注册表)。离线兜底:磁盘缓存 → 随包快照。
408
+ // 增量:后台扫描 GitHub topic:dsh-plugin(星标前 500,排除 fork),与注册表
409
+ // 按 owner/name 去重合并——重复条目仅刷新星标,新条目追加。
410
+ const REGISTRY_MAX_BYTES = 8 * 1024 * 1024;
411
+ const REGISTRY_TTL_MS = 24 * 60 * 60 * 1000;
412
+ const REGISTRY_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
413
+ // 搜索 API 单次查询上限 1000,5 页 × 100 已覆盖主流插件,也留出未认证
414
+ // 搜索 10 次/分钟的限额余量。
415
+ const GITHUB_TOPIC_MAX_PAGES = 5;
416
+ const registryCacheFile = join(updateHome, 'registry-cache.json');
417
+ let registryCache = null;
418
+ let githubTopicScanRunning = false;
419
+ function validRegistry(data) {
420
+ return !!data && typeof data === 'object' && Array.isArray(data.plugins)
421
+ && data.plugins.length > 0
422
+ && data.plugins.every((p) => p && typeof p.name === 'string' && typeof p.url === 'string');
423
+ }
424
+ /** 拉取注册表文本:配置了代理时走 curl(按调用时环境变量代理;Windows 10+
425
+ * 自带 curl.exe,Unix 亦普遍可用),否则直接 fetch。端点由调用方以字面量传入。 */
426
+ async function fetchRegistryText(url) {
427
+ const proxy = await proxyPromise;
428
+ if (proxy) {
429
+ const r = await execAsync('curl', ['-sSLf', '--max-time', '20', '--max-filesize', String(REGISTRY_MAX_BYTES), '-H', 'accept: application/json', url], undefined, 60_000, {
430
+ HTTPS_PROXY: proxy,
431
+ HTTP_PROXY: proxy,
432
+ https_proxy: proxy,
433
+ http_proxy: proxy,
434
+ });
435
+ return r.ok && r.out ? r.out : null;
436
+ }
437
+ const controller = new AbortController();
438
+ const timer = setTimeout(() => controller.abort(), 15_000);
439
+ try {
440
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
441
+ if (!response.ok)
442
+ return null;
443
+ const text = await response.text();
444
+ return text.length > REGISTRY_MAX_BYTES ? null : text;
445
+ }
446
+ catch {
447
+ return null;
448
+ }
449
+ finally {
450
+ clearTimeout(timer);
451
+ }
452
+ }
453
+ /** 网络拉取:本仓库根目录 plugins.json 注册表(raw 直链,字面量端点);
454
+ * 失败返回 null,由磁盘缓存与随包快照兜底。 */
455
+ async function fetchRegistryFromNetwork() {
456
+ const text = await fetchRegistryText('https://raw.githubusercontent.com/Britneycode/dsh-update-center/main/plugins.json');
457
+ if (!text)
458
+ return null;
459
+ try {
460
+ const data = JSON.parse(text);
461
+ if (validRegistry(data))
462
+ return { source: 'registry', data };
463
+ }
464
+ catch { /* 数据无效走兜底 */ }
465
+ return null;
466
+ }
467
+ /** GitHub 搜索 API 拉取一页 topic:dsh-plugin 仓库(按星标降序)。
468
+ * 端点为固定字面量 host(api.github.com)+ 内部整数页码,无外部输入参与。 */
469
+ async function fetchGithubTopicPage(page) {
470
+ const url = new URL('https://api.github.com/search/repositories');
471
+ url.searchParams.set('q', 'topic:dsh-plugin');
472
+ url.searchParams.set('sort', 'stars');
473
+ url.searchParams.set('order', 'desc');
474
+ url.searchParams.set('per_page', '100');
475
+ url.searchParams.set('page', String(page));
476
+ if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') {
477
+ throw new Error(`GitHub topic 端点校验失败: ${url.host}`);
478
+ }
479
+ const controller = new AbortController();
480
+ const timer = setTimeout(() => controller.abort(), 15_000);
481
+ try {
482
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/vnd.github+json' } });
483
+ if (response.status === 403 || response.status === 429)
484
+ return null; // 触发限流,停止翻页
485
+ if (!response.ok)
486
+ return null;
487
+ return await response.json();
488
+ }
489
+ catch {
490
+ return null;
491
+ }
492
+ finally {
493
+ clearTimeout(timer);
494
+ }
495
+ }
496
+ /** 扫描 GitHub topic(排除 fork),返回仓库数组;失败/限流返回空数组。 */
497
+ async function fetchGithubTopicRepos() {
498
+ const repos = [];
499
+ for (let page = 1; page <= GITHUB_TOPIC_MAX_PAGES; page++) {
500
+ const payload = await fetchGithubTopicPage(page);
501
+ const items = Array.isArray(payload?.items) ? payload.items : null;
502
+ if (!items)
503
+ break;
504
+ repos.push(...items.filter((repo) => repo && repo.fork !== true));
505
+ if (items.length < 100)
506
+ break;
507
+ }
508
+ return repos;
509
+ }
510
+ /** npm 关键词搜索(keywords:dsh-plugin,每页 250,最多 4 页拿全)。
511
+ * 端点为固定字面量 host(registry.npmjs.org)+ 内部整数偏移。 */
512
+ async function fetchNpmSearchObjects() {
513
+ const objects = [];
514
+ for (let from = 0; from < 1000; from += 250) {
515
+ const url = new URL('https://registry.npmjs.org/-/v1/search');
516
+ url.searchParams.set('text', 'keywords:dsh-plugin');
517
+ url.searchParams.set('size', '250');
518
+ url.searchParams.set('from', String(from));
519
+ if (url.protocol !== 'https:' || url.hostname !== 'registry.npmjs.org') {
520
+ throw new Error(`npm search 端点校验失败: ${url.host}`);
521
+ }
522
+ const controller = new AbortController();
523
+ const timer = setTimeout(() => controller.abort(), 15_000);
524
+ try {
525
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
526
+ if (!response.ok)
527
+ break;
528
+ const payload = await response.json();
529
+ const items = Array.isArray(payload?.objects) ? payload.objects : [];
530
+ if (!items.length)
531
+ break;
532
+ objects.push(...items);
533
+ const total = Number(payload?.total ?? 0);
534
+ if (from + items.length >= total)
535
+ break;
536
+ }
537
+ catch {
538
+ break;
539
+ }
540
+ finally {
541
+ clearTimeout(timer);
542
+ }
543
+ }
544
+ return objects;
545
+ }
546
+ /** 后台 GitHub 主题扫描:与当前注册表按 owner/name 去重合并(重复条目仅刷新
547
+ * 星标,新条目追加进「GitHub 发现」分类),并用 npm 关键词搜索补包名映射;
548
+ * 结果写回本地磁盘缓存。 */
549
+ async function enrichRegistryWithGithubTopics() {
550
+ if (githubTopicScanRunning || !registryCache)
551
+ return;
552
+ githubTopicScanRunning = true;
553
+ try {
554
+ const repos = await fetchGithubTopicRepos();
555
+ if (!repos.length)
556
+ return;
557
+ const { added, starsUpdated } = mergeGithubTopic(registryCache.data, repos);
558
+ const npmObjects = await fetchNpmSearchObjects();
559
+ const mapped = applyNpmMapping(registryCache.data, npmObjects);
560
+ if (!added && !starsUpdated && !mapped)
561
+ return;
562
+ try {
563
+ writeJsonAtomic(registryCacheFile, { fetchedAt: registryCache.at, data: registryCache.data });
564
+ }
565
+ catch { /* 写盘失败仅影响下次启动的缓存 */ }
566
+ logger?.info?.('[%s] GitHub 主题扫描:+%d 新插件,%d 星标刷新,%d 个 npm 映射(共 %d)', name, added, starsUpdated, mapped, registryCache.data.plugins.length);
567
+ }
568
+ finally {
569
+ githubTopicScanRunning = false;
570
+ }
571
+ }
572
+ /** 常驻条目(data/extra-plugins.json,GitHub repo 形状):无条件并入任何
573
+ * 来源的清单(按 owner/name 去重),保证本插件等 0 星新条目始终可见。 */
574
+ function applyBundledExtras(data) {
575
+ if (!data || !Array.isArray(data.plugins))
576
+ return;
577
+ const extras = readJson(join(pluginRoot, 'data', 'extra-plugins.json'));
578
+ if (!Array.isArray(extras))
579
+ return;
580
+ mergeGithubTopic(data, extras);
581
+ }
582
+ /** 缓存有效期内直接使用;过期自动拉网(含备源);网络不可用回退缓存与内置快照。 */
583
+ async function loadRegistry(force) {
584
+ if (!force && registryCache)
585
+ return { ok: true, source: registryCache.source, data: registryCache.data };
586
+ const disk = readJson(registryCacheFile);
587
+ const diskFetchedAt = typeof disk?.fetchedAt === 'number' ? disk.fetchedAt : 0;
588
+ const diskUsable = !!(disk?.data && validRegistry(disk.data));
589
+ if (!force && diskUsable && Date.now() - diskFetchedAt < REGISTRY_TTL_MS) {
590
+ applyBundledExtras(disk.data);
591
+ registryCache = { at: diskFetchedAt, source: 'disk', data: disk.data };
592
+ return { ok: true, source: 'disk', data: disk.data };
593
+ }
594
+ const network = await fetchRegistryFromNetwork();
595
+ if (network) {
596
+ applyBundledExtras(network.data);
597
+ try {
598
+ writeJsonAtomic(registryCacheFile, { fetchedAt: Date.now(), data: network.data });
599
+ }
600
+ catch { /* 磁盘缓存写失败不影响内存使用 */ }
601
+ registryCache = { at: Date.now(), source: network.source, data: network.data };
602
+ void enrichRegistryWithGithubTopics();
603
+ return { ok: true, source: network.source, data: network.data };
604
+ }
605
+ if (diskUsable) {
606
+ applyBundledExtras(disk.data);
607
+ registryCache = { at: diskFetchedAt, source: 'disk', data: disk.data };
608
+ return { ok: true, source: 'disk', data: disk.data };
609
+ }
610
+ const snapshot = readJson(join(pluginRoot, 'data', 'registry-snapshot.json'));
611
+ if (validRegistry(snapshot)) {
612
+ applyBundledExtras(snapshot);
613
+ registryCache = { at: Date.now(), source: 'snapshot', data: snapshot };
614
+ return { ok: true, source: 'snapshot', data: snapshot };
615
+ }
616
+ return { ok: false, source: 'none', data: null };
617
+ }
618
+ /**
619
+ * 解析安装来源(防抢注,参照 dsh-market):
620
+ * 优先 npm 包(秒级安装),但要求 npm 包的 repository 与清单的 GitHub
621
+ * owner/repo 一致;不一致或查不到时回退 github:owner/repo。
622
+ */
623
+ async function resolveInstallSpec(entry) {
624
+ const npmName = typeof entry.npm === 'string' && entry.npm.trim() ? entry.npm.trim() : '';
625
+ const ownerRepo = entry.owner && entry.name ? `${entry.owner}/${entry.name}` : '';
626
+ if (npmName && ownerRepo) {
627
+ const proxy = await proxyPromise;
628
+ const networkEnv = proxy
629
+ ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy }
630
+ : undefined;
631
+ const r = await execAsync('npm', ['view', npmName, 'repository', '--json'], profileDir, 30_000, networkEnv);
632
+ if (r.ok) {
633
+ try {
634
+ const repository = JSON.parse(r.out.trim());
635
+ const repoUrl = String(typeof repository === 'string' ? repository : (repository?.url ?? '')).toLowerCase().replace(/\.git$/, '');
636
+ const [owner, repo] = ownerRepo.toLowerCase().split('/');
637
+ if (repoUrl.includes(`/${owner}/${repo}`)) {
638
+ return { ok: true, spec: npmName, via: 'npm' };
639
+ }
640
+ }
641
+ catch { /* 解析失败按不匹配处理 */ }
642
+ }
643
+ return { ok: true, spec: `github:${ownerRepo}`, via: 'github' };
644
+ }
645
+ if (npmName)
646
+ return { ok: true, spec: npmName, via: 'npm' };
647
+ if (ownerRepo)
648
+ return { ok: true, spec: `github:${ownerRepo}`, via: 'github' };
649
+ return { ok: false, error: '清单条目缺少 npm 包名与 GitHub 来源,无法安装' };
650
+ }
651
+ // ── 禁用/启用:走 profile cordis.patch.yml 的 id 定向 disabled 覆盖 ──
652
+ const profilePatchFile = join(profileDir, 'cordis.patch.yml');
653
+ const DISABLE_BLOCK_BEGIN = '# >>> update-center managed disables';
654
+ const DISABLE_BLOCK_END = '# <<< update-center managed disables';
655
+ const disablesStateFile = join(updateHome, 'disables.json');
656
+ function readDisables() {
657
+ const state = readJson(disablesStateFile);
658
+ return state && typeof state === 'object' ? state : {};
659
+ }
660
+ function writeDisables(state) {
661
+ writeJsonAtomic(disablesStateFile, state);
662
+ }
663
+ /** 读取已安装 bundle 的 cordis.patch.yml,提取 insert 条目 id。 */
664
+ function bundleInsertIds(depName) {
665
+ const dir = join(profileDir, 'node_modules', ...depName.split('/'));
666
+ const pkg = readJson(join(dir, 'package.json'));
667
+ if (!pkg)
668
+ return [];
669
+ const patchRel = typeof pkg.dsh?.bundle?.patch === 'string' ? pkg.dsh.bundle.patch : './cordis.patch.yml';
670
+ try {
671
+ const text = readFileSync(resolve(dir, patchRel), 'utf8');
672
+ return [...text.matchAll(/^\s*-\s*id:\s*'?([^\s'"]+)'?/gm)].map((m) => m[1]);
673
+ }
674
+ catch {
675
+ return [];
676
+ }
677
+ }
678
+ /** 用禁用状态重写 profile patch 中的受管区块(带标记,原子写,先备份)。 */
679
+ function renderDisableBlock() {
680
+ const state = readDisables();
681
+ const lines = [DISABLE_BLOCK_BEGIN];
682
+ for (const [depName, ids] of Object.entries(state)) {
683
+ for (const id of ids) {
684
+ lines.push(`- id: ${id}`, ' disabled: true', ` # ${depName}`);
685
+ }
686
+ }
687
+ lines.push(DISABLE_BLOCK_END);
688
+ return lines.join('\n');
689
+ }
690
+ function syncDisableBlock() {
691
+ let text = '';
692
+ try {
693
+ text = readFileSync(profilePatchFile, 'utf8');
694
+ }
695
+ catch { /* profile patch 不存在时按空文件处理 */ }
696
+ const begin = text.indexOf(DISABLE_BLOCK_BEGIN);
697
+ const end = text.indexOf(DISABLE_BLOCK_END);
698
+ if (begin >= 0 && end > begin) {
699
+ text = text.slice(0, begin) + text.slice(end + DISABLE_BLOCK_END.length);
700
+ }
701
+ text = `${text.replace(/\s*$/, '')}\n\n${renderDisableBlock()}\n`;
702
+ try {
703
+ copyFileSync(profilePatchFile, `${profilePatchFile}.bak-update-center`);
704
+ }
705
+ catch { /* 首次写入时源文件不存在,无需备份 */ }
706
+ writeFileSync(profilePatchFile, text, 'utf8');
707
+ }
708
+ function setDisabled(depName, disabled) {
709
+ const state = readDisables();
710
+ if (disabled) {
711
+ const plugin = inventoryBasic().find((entry) => entry.name === depName);
712
+ if (!plugin)
713
+ return { ok: false, result: `未找到插件: ${depName}` };
714
+ if (plugin.kind === 'preset')
715
+ return { ok: false, result: 'preset 插件暂不支持禁用' };
716
+ const ids = bundleInsertIds(depName);
717
+ if (!ids.length)
718
+ return { ok: false, result: `无法定位 ${depName} 的装载条目 id(可能不是 bundle 插件)` };
719
+ state[depName] = ids;
720
+ }
721
+ else {
722
+ if (!(depName in state))
723
+ return { ok: false, result: `${depName} 未被禁用` };
724
+ delete state[depName];
725
+ }
726
+ writeDisables(state);
727
+ syncDisableBlock();
728
+ return { ok: true, result: disabled ? `${depName} 已禁用,重启 dsh web 后生效` : `${depName} 已恢复启用,重启 dsh web 后生效` };
729
+ }
730
+ /** 检查更新(dsh fetch + link 插件 fetch + npm latest 查询,全部并行)。 */
731
+ async function checkAll() {
732
+ const proxy = await proxyPromise;
733
+ const networkEnv = proxy
734
+ ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy }
735
+ : undefined;
736
+ const repo = await repoStatus();
737
+ let fetchOk = true;
738
+ let fetchErr = '';
739
+ let latestRemote = null;
740
+ if (repoDir) {
741
+ const f = await execAsync('git', ['fetch', remote, branch], repoDir, 120_000, networkEnv);
742
+ fetchOk = f.ok;
743
+ fetchErr = f.ok ? '' : f.err.trim().slice(0, 300);
744
+ if (f.ok) {
745
+ const [repo2, log] = await Promise.all([
746
+ repoStatus(),
747
+ execAsync('git', ['log', '--oneline', '-5', `${remote}/${branch}`], repoDir, 10_000),
748
+ ]);
749
+ Object.assign(repo, repo2);
750
+ latestRemote = log.ok ? log.out.trim().split('\n').filter(Boolean) : null;
751
+ }
752
+ }
753
+ const plugins = await pluginList();
754
+ await Promise.all(plugins.map(async (p) => {
755
+ const linkDir = typeof p.linkDir === 'string' ? p.linkDir : '';
756
+ if ((p.kind === 'link' || p.kind === 'preset') && linkDir && existsSync(join(linkDir, '.git'))) {
757
+ const git = await gitSnapshot(linkDir, { fetch: true, env: networkEnv });
758
+ const upstreamMatch = typeof git.remoteUrl === 'string' ? UPSTREAM_RE.exec(git.remoteUrl.trim()) : null;
759
+ if (upstreamMatch)
760
+ p.upstream = `${upstreamMatch[1]}/${upstreamMatch[2]}`;
761
+ // 展示名去掉远端前缀(origin/main → main)
762
+ if (typeof git.branch === 'string' && git.branch.includes('/')) {
763
+ git.branch = git.branch.slice(git.branch.indexOf('/') + 1);
764
+ }
765
+ p.git = git;
766
+ }
767
+ else if (p.kind === 'npm') {
768
+ p.latest = await npmLatest(String(p.name), networkEnv);
769
+ }
770
+ }));
771
+ const repoGit = (repo.git ?? {});
772
+ const repoUpdates = typeof repoGit.behind === 'number' ? repoGit.behind : 0;
773
+ const pluginUpdates = plugins.filter((plugin) => {
774
+ const git = (plugin.git ?? {});
775
+ return (typeof git.behind === 'number' && git.behind > 0)
776
+ || (plugin.kind === 'npm' && plugin.latest && plugin.latest !== plugin.version);
777
+ }).length;
778
+ const failedChecks = (fetchOk ? 0 : 1) + plugins.filter((plugin) => {
779
+ const git = (plugin.git ?? {});
780
+ return (plugin.kind === 'npm' && plugin.latest === null)
781
+ || ((plugin.kind === 'link' || plugin.kind === 'preset') && git.fetchOk === false);
782
+ }).length;
783
+ return {
784
+ repo: { ...repo, fetchOk, fetchErr },
785
+ plugins,
786
+ summary: { repoUpdates, pluginUpdates, totalUpdates: (repoUpdates > 0 ? 1 : 0) + pluginUpdates, failedChecks },
787
+ ts: Date.now(),
788
+ };
789
+ }
790
+ /** /status 快照(repo + plugins),带 TTL 缓存。 */
791
+ async function statusData(fresh) {
792
+ if (!fresh && statusCache && Date.now() - statusCache.at < STATUS_TTL_MS)
793
+ return statusCache;
794
+ const [repo, plugins] = await Promise.all([repoStatus(), pluginList()]);
795
+ statusCache = { at: Date.now(), repo, plugins };
796
+ return statusCache;
797
+ }
798
+ // ── host API(settings.section 面板后端)──
799
+ async function readBody(req) {
800
+ const chunks = [];
801
+ let total = 0;
802
+ for await (const c of req) {
803
+ const chunk = Buffer.from(c);
804
+ total += chunk.length;
805
+ if (total > 16_384)
806
+ throw new Error('request body too large');
807
+ chunks.push(chunk);
808
+ }
809
+ return Buffer.concat(chunks).toString('utf8');
810
+ }
811
+ /** POST 动作仅接受同源请求(Origin 缺失视为非浏览器客户端,放行)。 */
812
+ function sameOrigin(req) {
813
+ const origin = req?.headers?.origin;
814
+ if (!origin)
815
+ return true;
816
+ try {
817
+ const parsed = new URL(String(origin));
818
+ return typeof req.headers.host === 'string' && parsed.host === req.headers.host;
819
+ }
820
+ catch {
821
+ return false;
822
+ }
823
+ }
824
+ ctx.effect(() => ctx.webServer.register({
825
+ kind: 'prefix',
826
+ path: '/update-center/api',
827
+ handler: async (req, res) => {
828
+ const send = (code, obj) => {
829
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
830
+ res.end(JSON.stringify(obj));
831
+ };
832
+ try {
833
+ const url = new URL(req.url ?? '/', 'http://localhost');
834
+ const path = url.pathname.replace(/^\/update-center\/api/, '') || '/';
835
+ if (req.method === 'GET' && path === '/status') {
836
+ const fresh = url.searchParams.get('fresh') === '1';
837
+ const snapshot = await statusData(fresh);
838
+ return send(200, { ok: true, ...snapshot, job: latestJob(), ts: Date.now() });
839
+ }
840
+ if (req.method === 'GET' && path === '/job') {
841
+ const id = url.searchParams.get('id')?.trim() ?? '';
842
+ const job = id ? jobById(id) : latestJob();
843
+ return send(job ? 200 : 404, job ? { ok: true, job } : { ok: false, error: 'job not found' });
844
+ }
845
+ if (req.method === 'GET' && path === '/market') {
846
+ const registry = await loadRegistry(false);
847
+ if (!registry.ok)
848
+ return send(200, { ok: false, error: '插件清单不可用:网络失败且没有本地快照。' });
849
+ const installed = inventoryBasic().map((entry) => String(entry.name));
850
+ return send(200, {
851
+ ok: true,
852
+ source: registry.source,
853
+ updated: registry.data.updated ?? '',
854
+ count: registry.data.plugins.length,
855
+ categories: registry.data.categories ?? {},
856
+ plugins: registry.data.plugins,
857
+ installed,
858
+ });
859
+ }
860
+ if (req.method !== 'POST' || !sameOrigin(req)) {
861
+ return send(403, { ok: false, error: 'forbidden' });
862
+ }
863
+ if (path === '/market/refresh') {
864
+ const registry = await loadRegistry(true);
865
+ if (!registry.ok)
866
+ return send(200, { ok: false, error: '插件清单拉取失败:网络不可用且没有本地快照。' });
867
+ const installed = inventoryBasic().map((entry) => String(entry.name));
868
+ return send(200, {
869
+ ok: true,
870
+ source: registry.source,
871
+ updated: registry.data.updated ?? '',
872
+ count: registry.data.plugins.length,
873
+ categories: registry.data.categories ?? {},
874
+ plugins: registry.data.plugins,
875
+ installed,
876
+ });
877
+ }
878
+ if (path === '/install') {
879
+ const body = JSON.parse(await readBody(req));
880
+ const name = String(body?.name ?? '').trim();
881
+ const registry = await loadRegistry(false);
882
+ const entries = Array.isArray(registry.data?.plugins) ? registry.data.plugins : [];
883
+ const entry = entries.find((p) => p.name === name);
884
+ if (!entry)
885
+ return send(200, { ok: false, result: `插件市场中没有这个条目: ${name}` });
886
+ const installedNow = inventoryBasic().some((e) => e.name === entry.name || (typeof entry.npm === 'string' && !!entry.npm && e.name === entry.npm));
887
+ if (installedNow)
888
+ return send(200, { ok: false, result: `${name} 已经安装过了。` });
889
+ const resolved = await resolveInstallSpec(entry);
890
+ if (!resolved.ok)
891
+ return send(200, { ok: false, result: resolved.error });
892
+ return send(200, await startJob({
893
+ action: 'install',
894
+ target: entry.name,
895
+ installSpec: resolved.spec,
896
+ via: resolved.via,
897
+ profileDir,
898
+ }));
899
+ }
900
+ if (path === '/uninstall') {
901
+ const body = JSON.parse(await readBody(req));
902
+ const packageName = String(body?.name ?? '').trim();
903
+ const plugin = inventoryBasic().find((entry) => entry.kind === 'npm' && entry.name === packageName);
904
+ if (!plugin) {
905
+ return send(200, { ok: false, result: `未找到可卸载的 npm 插件: ${packageName}。link/preset 插件请直接编辑 profile 依赖。` });
906
+ }
907
+ return send(200, await startJob({
908
+ action: 'remove',
909
+ target: packageName,
910
+ packageName,
911
+ profileDir,
912
+ }));
913
+ }
914
+ if (path === '/disable' || path === '/enable') {
915
+ const body = JSON.parse(await readBody(req));
916
+ const result = setDisabled(String(body?.name ?? '').trim(), path === '/disable');
917
+ return send(200, result);
918
+ }
919
+ if (path === '/check') {
920
+ return send(200, { ok: true, ...(await checkAll()) });
921
+ }
922
+ if (path === '/update-all') {
923
+ // 基于实时检查结果构建批量任务;dsh 本体更新有独立的构建语义,不并入批量。
924
+ const check = await checkAll();
925
+ const checkPlugins = Array.isArray(check.plugins) ? check.plugins : [];
926
+ const jobs = [];
927
+ for (const p of checkPlugins) {
928
+ const git = (p.git ?? {});
929
+ if (p.kind === 'npm' && p.latest && p.latest !== p.version) {
930
+ jobs.push({
931
+ action: 'npm',
932
+ target: p.name,
933
+ packageName: p.name,
934
+ expectedVersion: p.latest,
935
+ profileDir,
936
+ });
937
+ }
938
+ else if ((p.kind === 'link' || p.kind === 'preset')
939
+ && typeof git.behind === 'number' && git.behind > 0
940
+ && git.dirty !== true
941
+ && typeof p.linkDir === 'string') {
942
+ jobs.push({
943
+ action: 'link',
944
+ target: p.name,
945
+ packageName: p.name,
946
+ kind: p.kind,
947
+ dir: p.linkDir,
948
+ profileDir,
949
+ });
950
+ }
951
+ }
952
+ if (!jobs.length) {
953
+ return send(200, { ok: false, result: '当前没有可更新的插件(有未提交改动的本地插件已跳过;dsh 本体请在上方单独更新)。' });
954
+ }
955
+ return send(200, await startJob({
956
+ action: 'batch',
957
+ target: `${jobs.length} 个插件`,
958
+ jobs,
959
+ profileDir,
960
+ }));
961
+ }
962
+ if (path === '/update-dsh') {
963
+ const body = JSON.parse(await readBody(req));
964
+ return send(200, await startJob({
965
+ action: 'dsh',
966
+ target: 'dsh',
967
+ full: body?.full === true,
968
+ remote,
969
+ branch,
970
+ }));
971
+ }
972
+ if (path === '/update-npm') {
973
+ const body = JSON.parse(await readBody(req));
974
+ const packageName = String(body?.name ?? '').trim();
975
+ const plugin = inventoryBasic().find((entry) => entry.kind === 'npm' && entry.name === packageName);
976
+ if (!plugin)
977
+ return send(200, { ok: false, result: `未找到可更新的 npm 插件: ${packageName}` });
978
+ const proxy = await proxyPromise;
979
+ const networkEnv = proxy
980
+ ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy }
981
+ : undefined;
982
+ const expectedVersion = await npmLatest(packageName, networkEnv);
983
+ if (!expectedVersion)
984
+ return send(200, { ok: false, result: `无法获取 ${packageName} 的最新版本` });
985
+ return send(200, await startJob({
986
+ action: 'npm',
987
+ target: packageName,
988
+ packageName,
989
+ expectedVersion,
990
+ profileDir,
991
+ }));
992
+ }
993
+ if (path === '/update-link') {
994
+ const body = JSON.parse(await readBody(req));
995
+ const packageName = String(body?.name ?? '').trim();
996
+ const plugin = inventoryBasic().find((entry) => (entry.kind === 'link' || entry.kind === 'preset') && entry.name === packageName);
997
+ if (!plugin || typeof plugin.linkDir !== 'string') {
998
+ return send(200, { ok: false, result: `未找到可更新的本地插件: ${packageName}` });
999
+ }
1000
+ return send(200, await startJob({
1001
+ action: 'link',
1002
+ target: packageName,
1003
+ packageName,
1004
+ kind: plugin.kind,
1005
+ dir: plugin.linkDir,
1006
+ }));
1007
+ }
1008
+ return send(404, { ok: false, error: 'not found: ' + path });
1009
+ }
1010
+ catch (e) {
1011
+ return send(500, { ok: false, error: String(e instanceof Error ? e.message : e) });
1012
+ }
1013
+ },
1014
+ }), 'dsh-update-center: api');
1015
+ // ── 工具:给 agent 的只读状态查询 ──
1016
+ ctx.effect(() => ctx.tools.register(defineTool({
1017
+ name: 'update_center_status',
1018
+ description: '更新中心:查看 dsh 版本与更新状态、已安装插件清单(npm/link 分类)。要执行更新请使用 GUI 设置页的更新中心面板。',
1019
+ parameters: {},
1020
+ output: {
1021
+ schema: { type: 'string' },
1022
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
1023
+ },
1024
+ async execute() {
1025
+ const [repo, plugins] = await Promise.all([repoStatus(), pluginList()]);
1026
+ return JSON.stringify({ repo, plugins }, null, 2);
1027
+ },
1028
+ })), 'dsh-update-center: status tool');
1029
+ // ── 市场清单后台自动刷新(12 小时一次;失败静默,保留现有缓存)──
1030
+ ctx.effect(() => {
1031
+ const timer = setInterval(() => {
1032
+ void fetchRegistryFromNetwork().then((fetched) => {
1033
+ if (!fetched)
1034
+ return;
1035
+ try {
1036
+ writeJsonAtomic(registryCacheFile, { fetchedAt: Date.now(), data: fetched.data });
1037
+ }
1038
+ catch { /* 写盘失败仅影响下次启动的缓存 */ }
1039
+ registryCache = { at: Date.now(), source: fetched.source, data: fetched.data };
1040
+ logger?.info?.('[%s] 市场清单已自动刷新(source=%s, count=%s)', name, fetched.source, fetched.data?.count ?? '?');
1041
+ void enrichRegistryWithGithubTopics();
1042
+ });
1043
+ }, REGISTRY_REFRESH_INTERVAL_MS);
1044
+ return () => clearInterval(timer);
1045
+ }, 'dsh-update-center: registry auto refresh');
1046
+ logger?.info?.('[%s] 更新中心启动(repo=%s profile=%s)', name, repoDir || '(未找到)', profileDir);
1047
+ }
1048
+ //# sourceMappingURL=index.js.map