@morehao/dsh-skills-console 0.5.4 → 0.5.6

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * 插件自更新:检查 npm 上的最新版本,并按需把**本插件自己**重装到最新版。
3
+ *
4
+ * 与 `updates.js` 的区别:那是「技能」的上游检测(走 GitHub Trees),这里是
5
+ * 「插件包自身」的版本检查(走 npm registry),两条链路互不相干。
6
+ *
7
+ * 为什么不用宿主 `pluginManager.inspect` 查最新版:`inspect` 对**已安装的包名**会直接
8
+ * 拒绝 `already-installed`(它只答「我马上要装的 spec 指向什么」,不是「查已装包的最新版」),
9
+ * 宿主 `listBundles` 也不为第三方插件解析最新版(`installTarget` 只给官方目录条目)。
10
+ * 所以最新版只能自己查 registry —— 但**走宿主 `pluginManager.registries()` 拿到的 registry
11
+ * 计划**(继承 profile 的镜像 / 私有源 / token 配置),拿不到才退回官方 registry。
12
+ *
13
+ * 设计取舍(对齐本机同类插件 archive-manager / ds-bot 的轻量做法,但更保守):
14
+ * - 依次问 registry 计划里的 `<base><pkg>/latest`,8s 超时,只取 `version` 字段;失败不抛,返回 null。
15
+ * - 版本比较手写(本仓 runtime 依赖只有 tar,不引入 semver);结构非法一律判「不更新」。
16
+ * - 安装只走宿主 `pluginManager.installBundle`(桌面端官方能力);拿不到就抛
17
+ * `error.plugin-update.manual`,把手动命令放进 detail 让前端展示/复制。
18
+ * - 不落盘、不加锁:一次点击只触发一次,`applying` 由路由层单飞守住;重启由用户决定。
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+
22
+ import { ServiceError } from './service.js';
23
+
24
+ export const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
25
+ const FETCH_TIMEOUT_MS = 8000;
26
+ const CACHE_TTL_MS = 5 * 60 * 1000;
27
+ const RELEASE_CACHE_TTL_MS = 10 * 60 * 1000;
28
+
29
+ /** 插件身份:包名与当前版本。从 package.json 读一次,避免和 page 头硬编码漂移。 */
30
+ export const PLUGIN_META = (() => {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
33
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
34
+ const match = /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(repo || ''));
35
+ return {
36
+ name: String(pkg.name || ''),
37
+ version: String(pkg.version || ''),
38
+ repo: match ? match[1] : null,
39
+ };
40
+ } catch {
41
+ return { name: '', version: '', repo: null };
42
+ }
43
+ })();
44
+
45
+ /** 解析 semver:`v1.2.3` / `1.2.3-beta.1` / `1.2.3+build`。非法返回 undefined。 */
46
+ function parseSemver(value) {
47
+ const text = String(value ?? '').trim();
48
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
49
+ if (match === null) return undefined;
50
+ return {
51
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
52
+ prerelease: match[4] ? match[4].split('.') : [],
53
+ };
54
+ }
55
+
56
+ function comparePrerelease(left, right) {
57
+ if (left.length === 0 || right.length === 0) {
58
+ return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
59
+ }
60
+ const length = Math.max(left.length, right.length);
61
+ for (let index = 0; index < length; index += 1) {
62
+ const a = left[index];
63
+ const b = right[index];
64
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
65
+ if (a === b) continue;
66
+ const aNumeric = /^\d+$/.test(a);
67
+ const bNumeric = /^\d+$/.test(b);
68
+ if (aNumeric && bNumeric) {
69
+ const an = BigInt(a);
70
+ const bn = BigInt(b);
71
+ if (an !== bn) return an > bn ? 1 : -1;
72
+ continue;
73
+ }
74
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
75
+ return a > b ? 1 : -1;
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ /** `candidate` 是否严格新于 `current`。任一方不是合法 semver 都判 false(宁可不更新)。 */
81
+ export function isNewerVersion(current, candidate) {
82
+ const a = parseSemver(current);
83
+ const b = parseSemver(candidate);
84
+ if (a === undefined || b === undefined) return false;
85
+ for (let index = 0; index < 3; index += 1) {
86
+ if (b.core[index] !== a.core[index]) return b.core[index] > a.core[index];
87
+ }
88
+ return comparePrerelease(b.prerelease, a.prerelease) > 0;
89
+ }
90
+
91
+ /** npm 包名转成 registry 路径:`@scope/name` → `@scope%2Fname`。 */
92
+ function encodePackageName(name) {
93
+ return name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
94
+ }
95
+
96
+ /** 手动更新命令(前端展示/复制用),按已发布包 + 当前 profile 拼。 */
97
+ function manualCommand(meta, profileName) {
98
+ const spec = `${meta.name}@latest`;
99
+ return profileName
100
+ ? `dsh plugin --profile ${profileName} add ${spec}`
101
+ : `dsh plugin add ${spec}`;
102
+ }
103
+
104
+ /** registry 归一成 `https://host/` 形式;非法返回 null。 */
105
+ function normalizeRegistry(value) {
106
+ if (typeof value !== 'string' || value.trim() === '') return null;
107
+ const text = value.trim().replace(/\/+$/, '');
108
+ try {
109
+ const url = new URL(`${text}/`);
110
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
111
+ return url.href;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 由宿主 `registries()` 结果拼出要问的 registry 顺序:
119
+ * `resolved`(pnpm 实际用的)→ `registry` → `fallbackRegistries`,去重;空则回退官方 registry。
120
+ */
121
+ function registryPlan(registries) {
122
+ const ordered = [];
123
+ const push = (value) => {
124
+ const normalized = normalizeRegistry(value);
125
+ if (normalized && !ordered.includes(normalized)) ordered.push(normalized);
126
+ };
127
+ if (registries && typeof registries === 'object') {
128
+ push(registries.resolved);
129
+ push(registries.registry);
130
+ for (const item of Array.isArray(registries.fallbackRegistries) ? registries.fallbackRegistries : []) push(item);
131
+ }
132
+ if (ordered.length === 0) ordered.push(OFFICIAL_REGISTRY);
133
+ return ordered;
134
+ }
135
+
136
+ /**
137
+ * @param {object} deps
138
+ * @param {() => any} deps.getPluginManager 取宿主 `pluginManager`(可能 undefined)
139
+ * @param {() => string | null} [deps.getProfileName] 取当前 profile 名(仅用于手动命令)
140
+ * @param {typeof fetch} [deps.fetchImpl] 测试注入
141
+ * @param {() => number} [deps.now] 测试注入
142
+ */
143
+ export function createPluginUpdate({ getPluginManager, getProfileName, fetchImpl = globalThis.fetch, now = Date.now, meta = PLUGIN_META } = {}) {
144
+ let cache = null; // { version, expiresAt }
145
+ let applying = false; // 路由层单飞:同一时刻只允许一次安装
146
+
147
+ /**
148
+ * 依次问 registry 计划里的 `<base><pkg>/latest`;第一个给出合法版本的为准。
149
+ * 全部失败返回 null(不抛,交给视图标 checkFailed)。
150
+ */
151
+ async function latestVersion() {
152
+ if (!meta.name) return null;
153
+ if (cache && now() < cache.expiresAt) return cache.version;
154
+ const plan = registryPlan(await readRegistries());
155
+ for (const base of plan) {
156
+ try {
157
+ const response = await fetchImpl(`${base}${encodePackageName(meta.name)}/latest`, {
158
+ headers: { accept: 'application/json' },
159
+ redirect: 'error',
160
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
161
+ });
162
+ if (!response.ok) continue;
163
+ const value = await response.json();
164
+ const version = typeof value?.version === 'string' ? value.version.trim() : '';
165
+ if (parseSemver(version) === undefined) continue;
166
+ cache = { version, expiresAt: now() + CACHE_TTL_MS, registry: base };
167
+ return version;
168
+ } catch {
169
+ // 这个 registry 不通 / 超时:试下一个
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** 从宿主 `pluginManager.registries()` 取 registry 配置;拿不到返回 null(走官方 registry)。 */
176
+ async function readRegistries() {
177
+ const service = rawService();
178
+ if (service === null || typeof service.registries !== 'function') return null;
179
+ try {
180
+ return await service.registries();
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** 宿主的 `pluginManager` 服务(任意能力);拿不到返回 null。 */
187
+ function rawService() {
188
+ try {
189
+ const value = typeof getPluginManager === 'function' ? getPluginManager() : undefined;
190
+ return value && typeof value === 'object' ? value : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /** 能做安装的 `pluginManager`(需 `installBundle`);拿不到返回 null。 */
197
+ function manager() {
198
+ const service = rawService();
199
+ return service && typeof service.installBundle === 'function' ? service : null;
200
+ }
201
+
202
+ function profileName() {
203
+ try {
204
+ const value = typeof getProfileName === 'function' ? getProfileName() : null;
205
+ return typeof value === 'string' && value ? value : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** 组一份给前端的视图;`canAutoUpdate=false` 时前端只展示手动命令。 */
212
+ function view({ latest, checkFailed = false } = {}) {
213
+ const current = meta.version;
214
+ const updateAvailable = latest !== null && latest !== undefined && isNewerVersion(current, latest);
215
+ return {
216
+ packageName: meta.name,
217
+ currentVersion: current,
218
+ latestVersion: latest ?? null,
219
+ updateAvailable,
220
+ checkFailed,
221
+ canAutoUpdate: manager() !== null,
222
+ manualCommand: manualCommand(meta, profileName()),
223
+ applying,
224
+ };
225
+ }
226
+
227
+ /** GET:读最新版本并返回视图。网络失败不抛,用 checkFailed 标记。 */
228
+ async function check({ fresh = false } = {}) {
229
+ if (fresh) cache = null;
230
+ const latest = await latestVersion();
231
+ return view({ latest, checkFailed: latest === null });
232
+ }
233
+
234
+ /**
235
+ * POST:把本插件重装到最新版。
236
+ * - 先再查一次,确认确实落后,避免拿过期信息装一个「已是最新」;
237
+ * - 没有 pluginManager → 抛 manual,带手动命令;
238
+ * - 安装中重复点击 → 抛 busy。
239
+ */
240
+ async function apply() {
241
+ if (applying) {
242
+ throw new ServiceError('error.plugin-update.busy', '插件正在更新,请稍候', null, 409);
243
+ }
244
+ const latest = await latestVersion();
245
+ if (latest === null) {
246
+ throw new ServiceError('error.plugin-update.check-failed', '暂时无法获取最新版本,请稍后再试', manualCommand(meta, profileName()), 503);
247
+ }
248
+ if (!isNewerVersion(meta.version, latest)) {
249
+ return { ...view({ latest }), updatedVersion: null };
250
+ }
251
+ const pm = manager();
252
+ if (pm === null) {
253
+ throw new ServiceError(
254
+ 'error.plugin-update.manual',
255
+ '当前环境不支持自动更新,请手动执行命令',
256
+ manualCommand(meta, profileName()),
257
+ 409,
258
+ );
259
+ }
260
+ applying = true;
261
+ try {
262
+ // 用查到最新版的那个 registry 去装,和「问」保持一致;没有就用宿主默认(不传 options)
263
+ const registry = cache?.version === latest ? cache.registry : undefined;
264
+ const result = await pm.installBundle(
265
+ `${meta.name}@${latest}`,
266
+ registry ? { registry } : undefined,
267
+ );
268
+ if (result?.error || !['applied', 'restart-required', 'overridden'].includes(result?.application)) {
269
+ const message = result?.error?.diagnostic || result?.error?.message || result?.error?.code || '更新失败';
270
+ throw new ServiceError('error.plugin-update.install-failed', String(message).slice(0, 600), manualCommand(meta, profileName()), 500);
271
+ }
272
+ cache = { version: latest, expiresAt: now() + CACHE_TTL_MS, registry };
273
+ return {
274
+ ...view({ latest }),
275
+ updateAvailable: false,
276
+ updatedVersion: latest,
277
+ restartRequired: true,
278
+ };
279
+ } finally {
280
+ applying = false;
281
+ }
282
+ }
283
+
284
+ // GitHub Release 正文缓存:key = version
285
+ const releaseCache = new Map();
286
+
287
+ /**
288
+ * 拉某个版本的 GitHub Release 正文(发布说明)。
289
+ * - tag 用 `vX.Y.Z`(本仓发布约定),请求 `api.github.com/repos/<repo>/releases/tags/vX`;
290
+ * - 失败(无 repo / 网络 / 404 / 超时)返回 null,不抛;前端据此退回「查看发布说明」链接;
291
+ * - 只读 GitHub 公开 API,不读本机任何文件。
292
+ */
293
+ async function releaseNotes(version) {
294
+ const target = String(version || '').trim();
295
+ if (!meta.repo || parseSemver(target) === undefined) return null;
296
+ const cached = releaseCache.get(target);
297
+ if (cached && now() < cached.expiresAt) return cached.value;
298
+ const tag = `v${target.replace(/^v/, '')}`;
299
+ try {
300
+ const response = await fetchImpl(`https://api.github.com/repos/${meta.repo}/releases/tags/${tag}`, {
301
+ headers: { accept: 'application/vnd.github+json' },
302
+ redirect: 'error',
303
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
304
+ });
305
+ const value = response.ok
306
+ ? await response.json().then((release) => ({
307
+ version: target,
308
+ body: typeof release?.body === 'string' ? release.body : '',
309
+ htmlUrl: typeof release?.html_url === 'string' ? release.html_url : null,
310
+ name: typeof release?.name === 'string' ? release.name : null,
311
+ }))
312
+ : null;
313
+ releaseCache.set(target, { value, expiresAt: now() + RELEASE_CACHE_TTL_MS });
314
+ return value;
315
+ } catch {
316
+ // 失败也短缓存,避免连点重复打网络;10 分钟后自然重试
317
+ releaseCache.set(target, { value: null, expiresAt: now() + RELEASE_CACHE_TTL_MS });
318
+ return null;
319
+ }
320
+ }
321
+
322
+ return { check, apply, releaseNotes };
323
+ }
@@ -18,6 +18,7 @@ import {
18
18
  backupInvocation,
19
19
  hashWithoutInvocation,
20
20
  inspectPark,
21
+ matchesLegacyHash,
21
22
  parkSkill,
22
23
  parseInvocation,
23
24
  planManualRestore,
@@ -906,6 +907,65 @@ export function createService({
906
907
  return records;
907
908
  }
908
909
 
910
+ /**
911
+ * 一次性重基线:v0.6.x 之前的内容哈希按进程 locale 的 collation 排序,换启动环境
912
+ * (或 Node / ICU 版本)后同一份内容会算出不同 digest,被误报成「本地已改」。
913
+ *
914
+ * 只改写**能证明没被改过**的基线:新口径对不上,但某个旧 collation 能精确复现
915
+ * 记录值。内容真的动过的技能一条都不碰 —— 继续按「本地已改」让用户确认覆盖。
916
+ * 停用(parked)期间的技能也跳过:那时 SKILL.md 已改名、目录里多了 marker,
917
+ * 基线由 unpark 重放。
918
+ */
919
+ function rebaselineHashes() {
920
+ const rebaselined = [];
921
+ const unresolved = [];
922
+ if (store.readOnly) return { rebaselined, unresolved, skipped: 'read-only' };
923
+ for (const install of state().installs) {
924
+ if (typeof install.installedHash !== 'string' || !install.installedHash) continue;
925
+ const dir = canonicalDir(install.name);
926
+ let stat;
927
+ try {
928
+ stat = fs.statSync(dir);
929
+ } catch {
930
+ continue;
931
+ }
932
+ if (!stat.isDirectory()) continue;
933
+ if (inspectPark(dir).parked) continue;
934
+ const current = hashWithoutInvocation(dir, install.manual);
935
+ if (!current || current === install.installedHash) continue;
936
+ if (matchesLegacyHash(dir, install.manual, install.installedHash)) {
937
+ rebaselined.push({ name: install.name, from: install.installedHash, to: current });
938
+ } else {
939
+ unresolved.push(install.name);
940
+ }
941
+ }
942
+ if (rebaselined.length > 0) {
943
+ const next = new Map(rebaselined.map((item) => [item.name, item.to]));
944
+ store.update((draft) => {
945
+ for (const row of draft.installs) {
946
+ if (next.has(row.name)) row.installedHash = next.get(row.name);
947
+ }
948
+ });
949
+ logger('state.hash-order.rebaseline', {
950
+ names: rebaselined.map((item) => item.name),
951
+ count: rebaselined.length,
952
+ });
953
+ }
954
+ return { rebaselined, unresolved };
955
+ }
956
+
957
+ // 每个进程只跑一次:写盘后新基线自洽,不必(也不该)每次请求都试旧口径。
958
+ let hashBaselineChecked = false;
959
+ function ensureHashBaseline() {
960
+ if (hashBaselineChecked || store.readOnly || enqueue.pending() > 0) return;
961
+ hashBaselineChecked = true;
962
+ try {
963
+ rebaselineHashes();
964
+ } catch (error) {
965
+ logger('state.hash-order.rebaseline.failed', { message: error?.message || String(error) });
966
+ }
967
+ }
968
+
909
969
  function canonicalDir(name) {
910
970
  return path.join(paths.canonical, name);
911
971
  }
@@ -1119,6 +1179,8 @@ export function createService({
1119
1179
  logger('state.migrate.failed', { message: error.message });
1120
1180
  }
1121
1181
  }
1182
+ // 旧版内容哈希按 locale 排序,升级后先做一次性重基线,别把「排序变了」显示成「本地已改」
1183
+ ensureHashBaseline();
1122
1184
  return store.state;
1123
1185
  },
1124
1186
 
@@ -1138,6 +1200,9 @@ export function createService({
1138
1200
  return reconcileInvocations();
1139
1201
  },
1140
1202
 
1203
+ /** 诊断/测试用:手动跑一次旧哈希口径的重基线(启动时已自动跑过一次)。 */
1204
+ rebaselineHashes,
1205
+
1141
1206
  async getState({ cwd, workspace } = {}) {
1142
1207
  const lock = readLock(paths);
1143
1208
  reconcileTracking(lock);
@@ -1449,6 +1514,8 @@ export function createService({
1449
1514
  inputBytes: prompt.inputBytes,
1450
1515
  truncated: prompt.truncated,
1451
1516
  sourceBytes: prompt.sourceBytes,
1517
+ maxTokens: result.maxTokens,
1518
+ finish: result.finish,
1452
1519
  });
1453
1520
  introStore.put(name, language, entry);
1454
1521
  logger('intro.generate', {
@@ -1456,6 +1523,9 @@ export function createService({
1456
1523
  lang: language,
1457
1524
  provider: result.provider,
1458
1525
  model: result.model,
1526
+ effort: result.reasoningEffort || null,
1527
+ maxTokens: result.maxTokens ?? null,
1528
+ finish: result.finish || null,
1459
1529
  inputBytes: prompt.inputBytes,
1460
1530
  truncated: prompt.truncated,
1461
1531
  durationMs: Date.now() - startedAt,
@@ -1471,7 +1541,12 @@ export function createService({
1471
1541
  };
1472
1542
  } catch (error) {
1473
1543
  if (!(error instanceof ServiceError)) {
1474
- logger('intro.generate.failed', { name, message: error?.message, code: error?.code });
1544
+ logger('intro.generate.failed', {
1545
+ name,
1546
+ message: error?.message,
1547
+ code: error?.code,
1548
+ detail: error?.detail || null,
1549
+ });
1475
1550
  }
1476
1551
  throw error;
1477
1552
  }
@@ -1583,6 +1658,8 @@ export function createService({
1583
1658
  inputBytes: prompt.inputBytes,
1584
1659
  truncated: prompt.truncated,
1585
1660
  sourceBytes: prompt.sourceBytes,
1661
+ maxTokens: result.maxTokens,
1662
+ finish: result.finish,
1586
1663
  });
1587
1664
  introStore.put(target.cacheKey, language, entry);
1588
1665
  logger('intro.generate.remote', {
@@ -1592,6 +1669,9 @@ export function createService({
1592
1669
  commit: target.commit,
1593
1670
  provider: result.provider,
1594
1671
  model: result.model,
1672
+ effort: result.reasoningEffort || null,
1673
+ maxTokens: result.maxTokens ?? null,
1674
+ finish: result.finish || null,
1595
1675
  inputBytes: prompt.inputBytes,
1596
1676
  truncated: prompt.truncated,
1597
1677
  durationMs: Date.now() - startedAt,
@@ -1616,6 +1696,7 @@ export function createService({
1616
1696
  name,
1617
1697
  message: error?.message,
1618
1698
  code: error?.code,
1699
+ detail: error?.detail || null,
1619
1700
  });
1620
1701
  }
1621
1702
  throw error;
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import { createPolicyProvider, installPolicyProviders } from './core/policy.js';
6
6
  import { createService } from './core/service.js';
7
7
  import { normalizeIntroConfig } from './core/intro.js';
8
8
  import { createIntroStore } from './core/intro-store.js';
9
+ import { createPluginUpdate } from './core/plugin-update.js';
9
10
  import { StateStore } from './core/state.js';
10
11
  import { createRoutes, ROUTE_PREFIX } from './routes.js';
11
12
 
@@ -113,7 +114,19 @@ export function apply(ctx, config = {}) {
113
114
  logger('plugin.migrate.failed', { message: error?.message, code: error?.code });
114
115
  }
115
116
 
116
- const handler = createRoutes({ service, logger });
117
+ /**
118
+ * 插件自更新:检查 npm 最新版 / 一键重装自身。
119
+ * `pluginManager` 是桌面端官方能力(installBundle),按可选服务取;拿不到时路由只报手动命令。
120
+ */
121
+ const pluginUpdate = createPluginUpdate({
122
+ getPluginManager: () => ctx.get('pluginManager'),
123
+ getProfileName: () => {
124
+ const profile = ctx.get('profileContext');
125
+ return typeof profile?.name === 'string' ? profile.name : null;
126
+ },
127
+ });
128
+
129
+ const handler = createRoutes({ service, pluginUpdate, logger });
117
130
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }), 'dsh-skills-console:routes');
118
131
  ctx.effect(() => () => policies.dispose(), 'dsh-skills-console:policy');
119
132
 
@@ -137,5 +150,20 @@ export function apply(ctx, config = {}) {
137
150
  logger('plugin.autoCheck.off', { reason: 'timer-service-missing' });
138
151
  }
139
152
 
153
+ // 插件自身的版本检查:启动后延迟一次(有 timer 用它,没有就 setTimeout),只热身缓存不打扰用户。
154
+ // 失败静默——真正的展示交给前端打开刷新按钮/弹窗时再查。
155
+ const warmPluginUpdate = () => {
156
+ void pluginUpdate.check().catch((error) => logger('plugin.update.warm.failed', { message: error?.message }));
157
+ };
158
+ if (timer && typeof timer.timeout === 'function') {
159
+ ctx.effect(() => timer.timeout(warmPluginUpdate, 20 * 1000), 'dsh-skills-console:plugin-update-warm');
160
+ } else {
161
+ ctx.effect(() => {
162
+ const handle = setTimeout(warmPluginUpdate, 20 * 1000);
163
+ handle.unref?.();
164
+ return () => clearTimeout(handle);
165
+ }, 'dsh-skills-console:plugin-update-warm');
166
+ }
167
+
140
168
  logger('plugin.apply', { stateDir: paths.stateDir, canonical: paths.canonical, readOnly: store.readOnly });
141
169
  }
package/lib/routes.js CHANGED
@@ -118,10 +118,20 @@ function matchRoute(method, pathname) {
118
118
  if (segments.length === 2 && segments[1] === 'rollback' && method === 'POST') return { name: 'updates.rollback' };
119
119
  }
120
120
 
121
+ // 插件自身版本检查 / 更新(与技能上游 updates.* 是两回事,见 core/plugin-update.js)
122
+ if (segments.length === 1 && segments[0] === 'plugin-update' && method === 'GET') return { name: 'plugin.update.check' };
123
+ if (segments.length === 2 && segments[0] === 'plugin-update' && segments[1] === 'apply' && method === 'POST') {
124
+ return { name: 'plugin.update.apply' };
125
+ }
126
+ // 拉某版本的发布说明(GitHub Release 正文):GET /plugin-update/release?version=x.y.z
127
+ if (segments.length === 2 && segments[0] === 'plugin-update' && segments[1] === 'release' && method === 'GET') {
128
+ return { name: 'plugin.update.release' };
129
+ }
130
+
121
131
  return null;
122
132
  }
123
133
 
124
- export function createRoutes({ service, logger }) {
134
+ export function createRoutes({ service, pluginUpdate = null, logger }) {
125
135
  return async function handler(req, res) {
126
136
  const url = new URL(req.url || '/', 'http://127.0.0.1');
127
137
  const pathname = url.pathname.startsWith(ROUTE_PREFIX)
@@ -153,7 +163,7 @@ export function createRoutes({ service, logger }) {
153
163
  service.reloadState();
154
164
  const body = method === 'GET' ? {} : await readBody(req);
155
165
  const query = Object.fromEntries(url.searchParams.entries());
156
- const result = await dispatch(route, { body, query, service, signal: abort.signal });
166
+ const result = await dispatch(route, { body, query, service, pluginUpdate, signal: abort.signal });
157
167
  // 生成期间客户端可能已经走了:往已关闭的响应写会抛 ERR_STREAM_WRITE_AFTER_END
158
168
  if (res.writableEnded || res.destroyed) return;
159
169
  sendJson(res, 200, result);
@@ -181,7 +191,7 @@ export function createRoutes({ service, logger }) {
181
191
  };
182
192
  }
183
193
 
184
- async function dispatch(route, { body, query, service, signal = null }) {
194
+ async function dispatch(route, { body, query, service, pluginUpdate = null, signal = null }) {
185
195
  switch (route.name) {
186
196
  case 'state': {
187
197
  const state = await service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
@@ -257,6 +267,19 @@ async function dispatch(route, { body, query, service, signal = null }) {
257
267
  return service.applyUpdate({ name: body.name, token: body.token, overwrite: Boolean(body.overwrite) });
258
268
  case 'updates.rollback':
259
269
  return service.rollback({ name: body.name });
270
+ case 'plugin.update.check': {
271
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
272
+ return pluginUpdate.check({ fresh: query.fresh === '1' || body.fresh === true });
273
+ }
274
+ case 'plugin.update.apply': {
275
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
276
+ return pluginUpdate.apply();
277
+ }
278
+ case 'plugin.update.release': {
279
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
280
+ const version = query.version || body.version;
281
+ return pluginUpdate.releaseNotes(version);
282
+ }
260
283
  default:
261
284
  throw new ServiceError('error.skills.no-route', `unhandled route ${route.name}`);
262
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",