@morehao/dsh-skills-console 0.5.3 → 0.5.5

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.
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- import { contentHashWith } from './hash.js';
5
+ import { contentHashWith, localeComparator } from './hash.js';
6
6
 
7
7
  /**
8
8
  * 「自动 / 仅手动 / 完全停用」的落盘层。
@@ -527,7 +527,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
527
527
  let hashMismatch = false;
528
528
  if (marker?.canonicalHash) {
529
529
  const current = contentHashWith(dir);
530
- hashMismatch = current !== null && current !== marker.canonicalHash;
530
+ // 旧版 marker 里的哈希是按 locale collation 排出来的;换 locale 后同一份内容
531
+ // 也会对不上,先按旧口径复核一次,避免把「排序变了」报成「内容漂移」。
532
+ hashMismatch = current !== null
533
+ && current !== marker.canonicalHash
534
+ && !matchesLegacyHash(dir, null, marker.canonicalHash);
531
535
  }
532
536
  const previousMode = (marker?.previousMode === 'manual' || marker?.previousMode === 'auto')
533
537
  ? marker.previousMode
@@ -547,9 +551,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
547
551
  * 归一化哈希:把插件写入的 `disable-model-invocation: true` 按 state 还原后再算目录哈希。
548
552
  * 只有 origin='plugin' 才归一化;author 声明属于上游内容,必须原样计入。
549
553
  * 解析失败 / 当前值不是 true 时返回 null —— 调用方保守判为「本地有改动」。
554
+ *
555
+ * `compare` 仅供 `matchesLegacyHash` 复核旧口径使用;新基线一律用默认的 locale 无关排序。
550
556
  */
551
- export function hashWithoutInvocation(dir, manual) {
552
- if (!manual || manual.origin !== 'plugin') return contentHashWith(dir);
557
+ export function hashWithoutInvocation(dir, manual, compare) {
558
+ if (!manual || manual.origin !== 'plugin') return contentHashWith(dir, null, compare);
553
559
  const expected = manual.original;
554
560
  if (expected !== 'absent' && expected !== 'false') return null;
555
561
  return contentHashWith(dir, (relative, buffer) => {
@@ -565,7 +571,27 @@ export function hashWithoutInvocation(dir, manual) {
565
571
  if (expected === 'absent') lines.splice(parsed.keyIndex, 1);
566
572
  else lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'false');
567
573
  return Buffer.from(join(parsed, lines), 'utf8');
568
- });
574
+ }, compare);
575
+ }
576
+
577
+ /**
578
+ * 旧基线识别:v0.6.x 之前的内容哈希按进程 locale 的 collation 排序,同一份内容
579
+ * 换 locale / ICU 版本就会得到不同 digest。用几个最常见的 collation 反查,
580
+ * 命中即说明「内容没变、只是排序口径变了」,可以安全地把基线改写成新口径。
581
+ *
582
+ * 只是识别手段:某个旧口径能精确复现记录值,等价于内容逐字节相同。
583
+ */
584
+ export function matchesLegacyHash(dir, manual, digest) {
585
+ if (typeof digest !== 'string' || !digest) return false;
586
+ const defaultLocale = new Intl.Collator().resolvedOptions().locale;
587
+ // 覆盖最常见的几类 collation:拉丁(en)、中文(zh)、日文(ja)。
588
+ // 都不命中就保守地维持「本地已改」,让用户确认 —— 绝不靠猜去改基线。
589
+ const locales = new Set([defaultLocale, 'en', 'zh', 'ja']);
590
+ for (const locale of locales) {
591
+ const compare = locale === defaultLocale ? undefined : localeComparator(locale);
592
+ if (hashWithoutInvocation(dir, manual, compare) === digest) return true;
593
+ }
594
+ return false;
569
595
  }
570
596
 
571
597
  /** 供 Host 层判断「这行到底是不是被 park 了」,不读 state。 */
@@ -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);
@@ -2691,6 +2756,49 @@ export function createService({
2691
2756
  return { ok: true, checks: results };
2692
2757
  },
2693
2758
 
2759
+ /**
2760
+ * 定时上游检测(由 lib/index.js 的 timer 服务按固定间隔调用)。
2761
+ * 只做判定,把结论写进 state.checks;绝不自动安装。
2762
+ * 全程 fail-open:任何异常只记日志,不向定时器抛。
2763
+ */
2764
+ async runAutoCheck({ now = Date.now() } = {}) {
2765
+ const AUTO_CHECK_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000; // 6h 周期,容 1h 抖动
2766
+ try {
2767
+ if (store.readOnly) return { ok: true, skipped: 'readonly' };
2768
+ const settings = state().settings || {};
2769
+ const auto = settings.autoCheck || {};
2770
+ if (auto.enabled === false) return { ok: true, skipped: 'disabled' };
2771
+ const installs = Array.isArray(state().installs) ? state().installs : [];
2772
+ const subscriptions = Array.isArray(state().subscriptions) ? state().subscriptions : [];
2773
+ if (installs.length === 0 || subscriptions.length === 0) return { ok: true, skipped: 'empty' };
2774
+ if (enqueue.pending() > 0) return { ok: true, skipped: 'busy' };
2775
+ const last = auto.lastRunAt ? Date.parse(auto.lastRunAt) : NaN;
2776
+ if (Number.isFinite(last) && now - last < AUTO_CHECK_MIN_INTERVAL_MS) {
2777
+ return { ok: true, skipped: 'throttled' };
2778
+ }
2779
+ const result = await this.checkUpdates({});
2780
+ const checks = Array.isArray(result?.checks) ? result.checks : [];
2781
+ const updatable = checks.filter((item) => item.status === 'update-available').length;
2782
+ const at = nowIso();
2783
+ if (!store.readOnly) {
2784
+ store.update((draft) => {
2785
+ if (!draft.settings) draft.settings = {};
2786
+ const prev = draft.settings.autoCheck || {};
2787
+ draft.settings.autoCheck = {
2788
+ enabled: prev.enabled !== false,
2789
+ lastRunAt: at,
2790
+ lastResult: { count: checks.length, updatable, at },
2791
+ };
2792
+ });
2793
+ }
2794
+ logger('auto.check', { count: checks.length, updatable });
2795
+ return { ok: true, count: checks.length, updatable, lastRunAt: at };
2796
+ } catch (error) {
2797
+ logger('auto.check.failed', { message: error?.message, code: error?.code || null });
2798
+ return { ok: false, error: error?.message || String(error) };
2799
+ }
2800
+ },
2801
+
2694
2802
  async planUpdate({ name }) {
2695
2803
  const install = store.findInstall(name);
2696
2804
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
package/lib/core/state.js CHANGED
@@ -19,7 +19,12 @@ export const STATE_VERSION = 4;
19
19
  export function emptyState() {
20
20
  return {
21
21
  version: STATE_VERSION,
22
- settings: { exportTargets: [], placement: 'settings' },
22
+ settings: {
23
+ exportTargets: [],
24
+ placement: 'settings',
25
+ // 定时上游检测的开关与上次跑批概览(仅 README 展示,不影响写路径)
26
+ autoCheck: { enabled: true, lastRunAt: null, lastResult: null },
27
+ },
23
28
  agents: { custom: [], dismissed: [] },
24
29
  subscriptions: [],
25
30
  installs: [],
@@ -87,6 +92,7 @@ export function upgradeStateV1(value, { now = new Date().toISOString() } = {}) {
87
92
  settings: {
88
93
  exportTargets: legacyExportTargets(value.settings),
89
94
  placement: value.settings?.placement === 'sidebar' ? 'sidebar' : 'settings',
95
+ autoCheck: { enabled: true, lastRunAt: null, lastResult: null },
90
96
  },
91
97
  installs,
92
98
  exports: exportsRows,
@@ -210,6 +216,12 @@ function validateV3(state) {
210
216
  ];
211
217
  if (settings.placement !== 'sidebar') settings.placement = 'settings';
212
218
 
219
+ // autoCheck 是纯增量字段:老 state 没有它时补默认值,类型写坏也只做宽松归一(不 fail)。
220
+ if (!isPlainObject(settings.autoCheck)) settings.autoCheck = { enabled: true, lastRunAt: null, lastResult: null };
221
+ if (typeof settings.autoCheck.enabled !== 'boolean') settings.autoCheck.enabled = true;
222
+ if (typeof settings.autoCheck.lastRunAt !== 'string') settings.autoCheck.lastRunAt = null;
223
+ if (!isPlainObject(settings.autoCheck.lastResult)) settings.autoCheck.lastResult = null;
224
+
213
225
  for (const row of state.installs) {
214
226
  if (!isPlainObject(row) || typeof row.name !== 'string' || !row.name) {
215
227
  return { ok: false, reason: 'state.installs[] has a row without name' };
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,8 +114,56 @@ 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');
132
+
133
+ // 定时上游检测:复用宿主 timer 服务(`@deepseek-ai/cordis-plugin-timer`)。
134
+ // 它是可选服务——宿主没装时静默降级为「只有手动检查」,其余功能不受影响。
135
+ // `timer.interval` 内部走 ctx.effect,插件卸载时会自动 clearInterval,不会泄漏。
136
+ const timer = ctx.get('timer');
137
+ if (timer && typeof timer.interval === 'function') {
138
+ const AUTO_CHECK_MS = 6 * 60 * 60 * 1000;
139
+ ctx.effect(
140
+ () => timer.interval(() => void service.runAutoCheck(), AUTO_CHECK_MS),
141
+ 'dsh-skills-console:auto-check',
142
+ );
143
+ // 启动后给一次延迟首检(避开启动高峰与迁移窗口),内部节流会兜住重复。
144
+ ctx.effect(
145
+ () => timer.timeout(() => void service.runAutoCheck(), 30 * 1000),
146
+ 'dsh-skills-console:auto-check-first',
147
+ );
148
+ logger('plugin.autoCheck', { intervalMs: AUTO_CHECK_MS });
149
+ } else {
150
+ logger('plugin.autoCheck.off', { reason: 'timer-service-missing' });
151
+ }
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
+
119
168
  logger('plugin.apply', { stateDir: paths.stateDir, canonical: paths.canonical, readOnly: store.readOnly });
120
169
  }