@morehao/dsh-skills-console 0.2.4 → 0.3.1

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,10 +2,11 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { StateReadOnlyError } from './state.js';
4
4
  import { contentHash } from './hash.js';
5
- import { createGit } from './git.js';
6
5
  import { ensureLink, inspectTarget, removeLink } from './links.js';
7
6
  import { buildLedger, readLock, scanCanonical } from './inventory.js';
8
- import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
7
+ import { readLockFile, updateLock } from './lock.js';
8
+ import { readBackup, restoreBackup, trashSkill } from './trash.js';
9
+ import { parseSkillFrontmatter } from './skill-md.js';
9
10
  import {
10
11
  checkSkill,
11
12
  fetchDefaultBranch,
@@ -18,6 +19,7 @@ import {
18
19
  skillTreeSha,
19
20
  snapshotId,
20
21
  } from './updates.js';
22
+ import { fetchAtomCommit, snapshotFromTarball } from './remote.js';
21
23
 
22
24
  const QUEUE_LIMIT = 8;
23
25
 
@@ -33,25 +35,46 @@ function nowIso() {
33
35
  return new Date().toISOString();
34
36
  }
35
37
 
36
- // skills CLI 对任意 GitHub 仓库走 git clone;没装 git 时它把错误打在 stdout(spinner)里
37
- const GIT_MISSING_RE = /spawn git ENOENT|git: command not found|not recognized as an internal or external command/i;
38
+ /** 路由参数里的技能名:在拼任何路径之前先校验,拒绝穿越/分隔符/控制字符。 */
39
+ function assertSafeSkillName(name) {
40
+ if (
41
+ typeof name !== 'string' ||
42
+ name.length === 0 ||
43
+ name.length > 120 ||
44
+ name.includes('/') ||
45
+ name.includes('\\') ||
46
+ name.includes('..') ||
47
+ // eslint-disable-next-line no-control-regex
48
+ /[\u0000-\u001f\u007f]/.test(name)
49
+ ) {
50
+ throw new ServiceError('error.skills.not-found', `技能名非法: ${String(name)}`);
51
+ }
52
+ return name;
53
+ }
38
54
 
39
- /** 把 CLI 的 add 失败翻译成可执行错误;git 缺失时不要只给一句 spawn ENOENT。 */
40
- function cliAddFailure(result, action) {
41
- const detail = {
42
- code: result.code,
43
- stderr: (result.stderr || '').slice(-2000),
44
- stdout: (result.stdout || '').slice(-2000),
45
- };
46
- const output = `${result.stdout || ''}\n${result.stderr || ''}`;
47
- if (GIT_MISSING_RE.test(output)) {
48
- return new ServiceError(
49
- 'error.skills.git-missing',
50
- '安装/更新技能需要本机有 git(skills CLI 会 clone 仓库);请安装 git 后重试',
51
- detail,
52
- );
55
+ /** Native installer 的失败 → 现有通用错误码(不新增前端交互,只换 toast 文案)。 */
56
+ function installerFailure(error, source, action = '安装') {
57
+ const code = error?.code || null;
58
+ const status = error?.status ?? null;
59
+ const detail = { reason: code, status, ...(error?.available ? { available: error.available } : {}) };
60
+ if (code === 'bad-source') return new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
61
+ if (code === 'no-skills') return new ServiceError('error.skills.no-skills', error.message, detail);
62
+ if (code === 'skill-not-found') {
63
+ return new ServiceError('error.skills.no-skills', error.message, detail);
64
+ }
65
+ if (code === 'timeout') {
66
+ return new ServiceError('error.skills.network', '下载技能包超时,请检查网络或代理后重试', detail);
67
+ }
68
+ if (code === 'tarball-too-large') {
69
+ return new ServiceError('error.skills.install-failed', '技能包超过大小上限,已中止安装', detail);
70
+ }
71
+ if (code === 'http-error' && status) {
72
+ return upstreamError(source, { status, error: error.message });
53
73
  }
54
- return new ServiceError('error.skills.cli-failed', `skills ${action} 失败`, detail);
74
+ if (['lock-unsupported', 'lock-corrupt', 'lock-unreadable', 'lock-conflict', 'lock-write-failed'].includes(code)) {
75
+ return new ServiceError('error.skills.install-failed', `${action}失败:lock 不可写或已被其他进程修改(${code})`, detail);
76
+ }
77
+ return new ServiceError('error.skills.install-failed', `${action}失败:${error?.message || error}`, detail);
55
78
  }
56
79
 
57
80
  /** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
@@ -89,51 +112,6 @@ function upstreamError(source, failure) {
89
112
  return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
90
113
  }
91
114
 
92
- /** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
93
- function installedNamesFrom(stdout) {
94
- try {
95
- const parsed = JSON.parse(stdout);
96
- const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
97
- const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
98
- return [...new Set(names)];
99
- } catch {
100
- return [];
101
- }
102
- }
103
-
104
- /** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
105
- function parseSkillFrontmatter(text) {
106
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
107
- if (!match) return {};
108
- const lines = match[1].split(/\r?\n/);
109
- const fields = {};
110
- for (let index = 0; index < lines.length; index += 1) {
111
- const line = lines[index];
112
- if (!line || /^\s/.test(line)) continue;
113
- const colon = line.indexOf(':');
114
- if (colon <= 0) continue;
115
- const key = line.slice(0, colon).trim();
116
- if (key !== 'name' && key !== 'description') continue;
117
- let value = line.slice(colon + 1).trim();
118
- const block = /^([|>])[+-]?$/.exec(value);
119
- if (block) {
120
- const folded = block[1] === '>';
121
- const rows = [];
122
- while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
123
- rows.push(lines[index + 1].replace(/^\s+/, ''));
124
- index += 1;
125
- }
126
- value = folded ? rows.join(' ') : rows.join('\n');
127
- } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
128
- const quote = value[0];
129
- value = value.slice(1, -1);
130
- if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
131
- }
132
- fields[key] = value.trim();
133
- }
134
- return fields;
135
- }
136
-
137
115
  /** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
138
116
  async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
139
117
  const results = new Array(entries.length);
@@ -196,10 +174,19 @@ function createQueue(logger) {
196
174
 
197
175
 
198
176
 
199
- export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
177
+ export function createService({
178
+ paths,
179
+ store,
180
+ registry,
181
+ installer = null,
182
+ logger,
183
+ env = process.env,
184
+ fetchImpl = fetch,
185
+ visibleProvider = async () => ({ skills: [], complete: true }),
186
+ workspacesProvider = async () => [],
187
+ }) {
200
188
  const enqueue = createQueue(logger);
201
189
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
202
- const git = createGit({ env, logger });
203
190
  // 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
204
191
  const probeCache = new Map();
205
192
  const PROBE_TTL_MS = 60_000;
@@ -213,19 +200,34 @@ export function createService({ paths, store, registry, cli, logger, env = proce
213
200
  if (store.readOnly) throw new StateReadOnlyError(store.problem);
214
201
  }
215
202
 
216
- /** 所有会调 CLI 的写操作共用;缺失时给 400,不要把裸 CliUnavailableError 漏成 500。 */
217
- async function requireUsableCli() {
218
- const capabilities = await cli.capabilities();
203
+ /** `/state.cli` 兼容字段:`usable` 由 native installer 与 lock 的可写性决定。 */
204
+ function nativeCapabilities() {
205
+ const capabilities = installer?.readiness?.() || {
206
+ usable: false,
207
+ source: 'missing',
208
+ version: null,
209
+ json: false,
210
+ fullDepth: false,
211
+ problem: 'installer-missing',
212
+ };
213
+ return capabilities;
214
+ }
215
+
216
+ /** 安装/更新/删除写操作的就绪门禁:只看 native installer 与 lock 的可写性。 */
217
+ async function requireInstaller() {
218
+ const capabilities = nativeCapabilities();
219
219
  if (!capabilities.usable) {
220
220
  throw new ServiceError(
221
- 'error.skills.cli-missing',
222
- 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用',
221
+ 'error.skills.installer-unavailable',
222
+ '本地安装器不可用(lock 版本/权限异常),写操作已禁用',
223
223
  capabilities,
224
224
  );
225
225
  }
226
226
  return capabilities;
227
227
  }
228
228
 
229
+
230
+
229
231
  function detectedTargets() {
230
232
  return registry.detected();
231
233
  }
@@ -308,16 +310,23 @@ export function createService({ paths, store, registry, cli, logger, env = proce
308
310
  * 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
309
311
  * 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
310
312
  *
311
- * T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
313
+ * 数据源:REST 优先;403/429 限流时用 Atom 取 commit,再下 codeload tarball 本地扫描,
314
+ * 不启动任何外部进程。`allowUnchanged=false` 供更新计划使用:即使 commit 没变也要拿完整树。
312
315
  */
313
- async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
316
+ async function readSnapshot(
317
+ subscription,
318
+ { force = false, withCommit = true, persistRef = false, allowUnchanged = true } = {},
319
+ ) {
314
320
  const source = subscription.source;
315
321
  try {
316
322
  const { branch, failure } = await branchOf(subscription, { persist: persistRef });
317
323
  if (!branch) {
318
- if (failure?.rateLimited || failure?.status === 404) {
319
- const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
320
- if (viaGit) return viaGit;
324
+ if (failure?.rateLimited) {
325
+ return await atomSnapshot(subscription, {
326
+ ref: subscription.ref || null,
327
+ allowUnchanged,
328
+ rateLimit: failure,
329
+ });
321
330
  }
322
331
  throw upstreamError(source, failure);
323
332
  }
@@ -329,16 +338,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
329
338
  fetchImpl,
330
339
  });
331
340
  if (result.error) {
332
- // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
341
+ // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流降级
333
342
  if (result.rateLimited) {
334
- const viaGit = await gitSnapshot(subscription, { ref: branch });
335
- if (viaGit) return viaGit;
336
- throw upstreamError(source, {
337
- status: result.status ?? null,
338
- rateLimited: true,
339
- resetAt: result.resetAt ?? null,
340
- error: result.error,
341
- });
343
+ return await atomSnapshot(subscription, { ref: branch, allowUnchanged, rateLimit: result });
342
344
  }
343
345
  throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
344
346
  source,
@@ -346,7 +348,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
346
348
  status: result.status ?? null,
347
349
  });
348
350
  }
349
- if (result.notModified) return { notModified: true, branch };
351
+ if (result.notModified) return { notModified: true, branch, via: 'rest' };
350
352
  const entries = listSkillsFromTree({
351
353
  tree: result.tree,
352
354
  rootSha: result.rootSha,
@@ -381,161 +383,91 @@ export function createService({ paths, store, registry, cli, logger, env = proce
381
383
  }
382
384
 
383
385
  /**
384
- * T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
385
- * 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
386
+ * 限流兜底:Atom 取 commit,与上次一致就短路;否则 codeload tarball 解压扫描。
387
+ * 失败统一翻成 T1 的四类错误(限流保留原始 reset 信息)。
386
388
  */
387
- async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
389
+ async function atomSnapshot(subscription, { ref = null, allowUnchanged = true, rateLimit = null } = {}) {
388
390
  const source = subscription.source;
389
- const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
390
- if (!git.enabled()) return null;
391
391
  const startedAt = Date.now();
392
- let url;
392
+ let atom;
393
393
  try {
394
- url = git.url(source);
394
+ atom = await fetchAtomCommit({ source, ref: ref || null, token: tokenOf, fetchImpl });
395
395
  } catch (error) {
396
- logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
397
- return null;
396
+ logger('atom.failed', { source, kind: error.code || 'atom-failed', message: error.message });
397
+ throw upstreamError(source, {
398
+ status: rateLimit?.status ?? error.status ?? null,
399
+ rateLimited: Boolean(rateLimit?.rateLimited),
400
+ resetAt: rateLimit?.resetAt ?? null,
401
+ error: error.message,
402
+ });
398
403
  }
399
- if (!(await git.isAvailable())) {
400
- logger('git.fallback', { source, kind: 'git-missing', purpose });
401
- return null;
404
+ const branch = ref || subscription.ref || null;
405
+ const previous = subscription.snapshot || null;
406
+ if (allowUnchanged && previous?.commit && previous.commit === atom.commit && Array.isArray(previous.entries)) {
407
+ logger('atom.unchanged', { source, commit: atom.commit, ms: Date.now() - startedAt });
408
+ return { branch, changed: false, notModified: true, via: 'atom', snapshot: previous };
402
409
  }
410
+ let scan;
403
411
  try {
404
- let branch = ref || null;
405
- if (!branch) {
406
- const head = await git.lsRemote({ url });
407
- branch = head.branch || 'main';
408
- }
409
- const tree = await git.tree({ url, ref: branch });
410
- const entries = listSkillsFromTree({
411
- tree: tree.entries,
412
- rootSha: tree.rootSha,
413
- subpath: subscription.subpath,
414
- repoName: source.split('/').pop(),
415
- });
416
- const changed =
417
- !subscription.snapshot ||
418
- subscription.snapshot.rootSha !== tree.rootSha ||
419
- JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
420
- logger(event, {
412
+ scan = await snapshotFromTarball({
421
413
  source,
414
+ commit: atom.commit,
422
415
  ref: branch,
423
- commit: tree.commit,
424
- entries: entries.length,
425
- changed,
426
- ms: Date.now() - startedAt,
416
+ subpath: subscription.subpath,
417
+ token: tokenOf,
418
+ fetchImpl,
427
419
  });
428
- return {
429
- branch,
430
- changed,
431
- via: 'git',
432
- tree: tree.entries,
433
- rootSha: tree.rootSha,
434
- snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
435
- };
436
420
  } catch (error) {
437
- logger('git.fallback', {
438
- source,
439
- kind: error.kind || 'clone-failed',
440
- message: error.message,
441
- purpose,
442
- ms: Date.now() - startedAt,
421
+ logger('codeload.failed', { source, kind: error.code || 'scan-failed', message: error.message });
422
+ throw upstreamError(source, {
423
+ status: rateLimit?.status ?? error.status ?? null,
424
+ rateLimited: Boolean(rateLimit?.rateLimited),
425
+ resetAt: rateLimit?.resetAt ?? null,
426
+ error: error.message,
443
427
  });
444
- return null;
445
428
  }
429
+ const changed =
430
+ !previous ||
431
+ previous.rootSha !== scan.rootSha ||
432
+ JSON.stringify(previous.entries) !== JSON.stringify(scan.snapshot.entries);
433
+ logger('atom.snapshot', {
434
+ source,
435
+ ref: branch,
436
+ commit: atom.commit,
437
+ entries: scan.snapshot.entries.length,
438
+ changed,
439
+ ms: Date.now() - startedAt,
440
+ });
441
+ return { ...scan, branch, changed };
446
442
  }
447
443
 
448
444
  /**
449
- * T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
450
- * 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
445
+ * commit 短路:Atom feed 的 HEAD 与订阅快照一致 → 没变化,不请求 REST。
446
+ * Atom 不可用时返回 false,照常走 REST(只增不减)。
451
447
  */
452
448
  async function remoteCommitUnchanged(subscription) {
453
449
  const commit = subscription.snapshot?.commit;
454
- if (!commit || !git.enabled()) return false;
455
- if (!(await git.isAvailable())) return false;
450
+ if (!commit) return false;
456
451
  try {
457
- const head = await git.lsRemote({ url: git.url(subscription.source) });
458
- if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
459
- return head.commit === commit;
452
+ const atom = await fetchAtomCommit({
453
+ source: subscription.source,
454
+ ref: subscription.ref || null,
455
+ token: tokenOf,
456
+ fetchImpl,
457
+ });
458
+ return atom.commit === commit;
460
459
  } catch (error) {
461
- logger('git.fallback', {
460
+ logger('atom.failed', {
462
461
  source: subscription.source,
463
- kind: error.kind || 'clone-failed',
462
+ kind: error.code || 'atom-failed',
464
463
  purpose: 'refresh',
465
464
  });
466
465
  return false;
467
466
  }
468
467
  }
469
468
 
470
- /**
471
- * 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
472
- * 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
473
- * 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
474
- */
475
- async function probeViaGit({ source, ref, subpath }) {
476
- const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
477
- if (!result) return null;
478
- return { branch: result.branch, via: 'git', snapshot: result.snapshot };
479
- }
480
-
481
- /**
482
- * T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
483
- * 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
484
- * `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
485
- *
486
- * best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
487
- */
488
- async function backfillBaselines({ names, source, lock, ref }) {
489
- if (!git.enabled() || names.length === 0) return [];
490
- const targets = [];
491
- for (const name of names) {
492
- const lockEntry = lock.skills?.[name];
493
- if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
494
- const install = store.findInstall(name);
495
- if (!install || install.treeSha) continue;
496
- targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
497
- }
498
- if (targets.length === 0) return [];
499
- if (!(await git.isAvailable())) {
500
- logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
501
- return [];
502
- }
503
- const startedAt = Date.now();
504
- try {
505
- const url = git.url(source);
506
- let useRef = ref || null;
507
- if (!useRef) useRef = (await git.lsRemote({ url })).commit;
508
- // 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
509
- const tree = await git.tree({ url, ref: useRef });
510
- const updated = [];
511
- for (const target of targets) {
512
- const treeSha = target.skillPath
513
- ? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
514
- : null;
515
- if (!treeSha) {
516
- logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
517
- continue;
518
- }
519
- store.update((draft) => {
520
- const row = draft.installs.find((item) => item.name === target.name);
521
- if (row && !row.treeSha) row.treeSha = treeSha;
522
- });
523
- updated.push({ name: target.name, treeSha });
524
- }
525
- if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
526
- return updated;
527
- } catch (error) {
528
- logger('git.error', {
529
- source,
530
- kind: error.kind || 'clone-failed',
531
- message: error.message,
532
- purpose: 'baseline',
533
- });
534
- return [];
535
- }
536
- }
537
-
538
- function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
469
+ function recordLink(name, target, linkPath, mode, created) {
470
+ if (store.readOnly) return;
539
471
  const existing = state().links.find((item) => item.skill === name && item.target === target.target);
540
472
  if (existing) return;
541
473
  if (!created) return;
@@ -680,7 +612,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
680
612
  const visible = await visibleSkills(cwd);
681
613
  const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
682
614
  const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
683
- const capabilities = await cli.capabilities();
615
+ const capabilities = nativeCapabilities();
684
616
  return {
685
617
  ok: true,
686
618
  readOnly: store.readOnly,
@@ -708,7 +640,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
708
640
  },
709
641
 
710
642
  async diagnostics() {
711
- const capabilities = await cli.capabilities({ force: true });
643
+ const capabilities = nativeCapabilities();
712
644
  const lock = readLock(paths);
713
645
  reconcileTracking(lock);
714
646
  const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
@@ -823,6 +755,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
823
755
  },
824
756
 
825
757
  async setMode({ name, mode }) {
758
+ assertSafeSkillName(name);
826
759
  assertWritable();
827
760
  if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
828
761
  const install = store.findInstall(name);
@@ -865,9 +798,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
865
798
 
866
799
  async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
867
800
  assertWritable();
868
- await requireUsableCli();
869
- // 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
801
+ await requireInstaller();
870
802
  const requested = (skills || []).filter(Boolean);
803
+ for (const name of requested) assertSafeSkillName(name);
871
804
 
872
805
  return enqueue('install', async () => {
873
806
  const subscription = subscriptionId
@@ -875,72 +808,94 @@ export function createService({ paths, store, registry, cli, logger, env = proce
875
808
  : null;
876
809
  if (subscriptionId && !subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
877
810
  const resolvedSource = subscription ? subscription.source : source;
878
- if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
879
-
880
- const snapshot = subscription?.snapshot || null;
881
- const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
882
- const pin = commit || snapshot?.commit || subscription?.ref || null;
883
- const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
884
-
885
- const result = await cli.add({ source: sourceArg, skills: planned });
886
- if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
887
- if (result.code !== 0) throw cliAddFailure(result, 'add');
888
- // 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
889
- const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
890
- if (names.length === 0) {
891
- throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
892
- stdout: result.stdout.slice(-2000),
811
+ if (!parseSource(resolvedSource)) {
812
+ throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
813
+ }
814
+
815
+ // 有快照就沿用(含 commit/entries);否则现场解析默认分支、commit 与技能清单。
816
+ let snapshot = subscription?.snapshot || null;
817
+ let branch = subscription?.ref || null;
818
+ if (!commit && !snapshot?.commit) {
819
+ const subject = subscription || { source: resolvedSource, ref: null, subpath: null, snapshot: null };
820
+ const read = await readSnapshot(subject, { withCommit: true, force: true });
821
+ snapshot = read.snapshot;
822
+ branch = read.branch;
823
+ }
824
+
825
+ const pin = commit || snapshot?.commit || branch || subscription?.ref || null;
826
+ if (!pin) {
827
+ throw new ServiceError('error.skills.upstream-failed', `无法解析 ${resolvedSource} 的 commit,请稍后重试`, {
828
+ source: resolvedSource,
893
829
  });
894
830
  }
831
+ const sourceArg = `${resolvedSource}#${pin}`;
832
+
833
+ let result;
834
+ try {
835
+ result = await installer.install({
836
+ source: resolvedSource,
837
+ // pin 是 commit 时按 commit 下载;只有分支时让 installer 用分支
838
+ commit: commit || snapshot?.commit || null,
839
+ ref: pin,
840
+ subpath: subscription?.subpath || null,
841
+ names: requested,
842
+ expectedEntries: snapshot?.entries || null,
843
+ });
844
+ } catch (error) {
845
+ logger('install.failed', { source: sourceArg, code: error?.code || null });
846
+ throw installerFailure(error, resolvedSource, '安装');
847
+ }
895
848
 
896
- const lock = readLock(paths);
897
849
  const installed = [];
898
- const warnings = [];
899
- for (const name of names) {
900
- const canonicalPath = path.join(paths.canonical, name);
850
+ const warnings = [...(result.warnings || [])];
851
+ const recordedAt = nowIso();
852
+ for (const item of result.installed) {
853
+ const canonicalPath = path.join(paths.canonical, item.name);
901
854
  if (!fs.existsSync(canonicalPath)) {
902
- warnings.push({ name, warning: 'add 成功但 canonical 里没有该目录(可能被目录发现提前终止)' });
855
+ warnings.push({ name: item.name, warning: '安装成功但 canonical 里没有该目录,未能记账' });
903
856
  continue;
904
857
  }
905
- const lockEntry = lock.skills?.[name];
906
- if (!lockEntry) {
907
- warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
908
- }
909
- const expected = snapshot?.entries?.find((entry) => entry.name === name);
910
- const lockHash = lockEntry?.skillFolderHash || null;
858
+ const installedHash = item.contentHash || contentHash(canonicalPath);
859
+ const expected = snapshot?.entries?.find((entry) => entry.name === item.name) || null;
860
+ const lockHash = item.lockHash ?? item.treeSha ?? null;
911
861
  const shape = hashShape(lockHash);
912
- // 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
913
- // (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
914
- if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
915
- warnings.push({
916
- name,
917
- warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
918
- expected: expected.treeSha,
919
- actual: lockHash,
920
- });
921
- }
922
- const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
923
862
  const record = {
924
- name,
863
+ name: item.name,
925
864
  subscriptionId: subscription ? subscription.id : null,
926
- skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
865
+ skillPath: item.skillPath,
927
866
  ref: pin,
928
- commit: snapshot?.commit || null,
929
- // 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
930
- treeSha: expected?.treeSha || null,
867
+ commit: commit || snapshot?.commit || null,
868
+ // native installer 只写 40 位 tree SHA;旧 lock 的 64 位形态仍由 checkSkill 兼容
869
+ treeSha: item.treeSha || expected?.treeSha || null,
931
870
  installedHash,
932
- installedAt: nowIso(),
933
- updatedAt: nowIso(),
871
+ installedAt: recordedAt,
872
+ updatedAt: recordedAt,
934
873
  mode: 'on',
935
874
  scopeProjects: scopeProjects || [],
936
- backup: null,
875
+ backup: item.backup
876
+ ? { key: item.backup, hash: item.backupHash || null, treeSha: item.backupTreeSha || null }
877
+ : null,
937
878
  };
938
879
  store.update((draft) => {
939
- const index = draft.installs.findIndex((item) => item.name === name);
940
- if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
941
- else draft.installs.push(record);
880
+ const index = draft.installs.findIndex((row) => row.name === item.name);
881
+ if (index >= 0) {
882
+ draft.installs[index] = {
883
+ ...draft.installs[index],
884
+ ...record,
885
+ installedAt: draft.installs[index].installedAt || record.installedAt,
886
+ };
887
+ } else {
888
+ draft.installs.push(record);
889
+ }
890
+ });
891
+ installed.push({
892
+ name: item.name,
893
+ lockHash,
894
+ lockHashShape: shape,
895
+ treeSha: record.treeSha,
896
+ installedHash,
897
+ backup: item.backup || null,
942
898
  });
943
- installed.push({ name, lockHash, lockHashShape: shape, installedHash });
944
899
  }
945
900
 
946
901
  const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
@@ -952,15 +907,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
952
907
  linkResult.failed.push(...one.failed);
953
908
  }
954
909
 
955
- // T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
956
- // 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
957
- const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
958
- for (const item of installed) {
959
- const filled = baselines.find((row) => row.name === item.name);
960
- if (filled) item.treeSha = filled.treeSha;
961
- }
962
-
963
- logger('install', { source: sourceArg, skills: names, installed: installed.length });
910
+ logger('install', { source: sourceArg, skills: installed.map((item) => item.name), installed: installed.length });
964
911
  return {
965
912
  ok: true,
966
913
  installed,
@@ -972,15 +919,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
972
919
  },
973
920
 
974
921
  async remove({ name }) {
922
+ assertSafeSkillName(name);
975
923
  assertWritable();
976
- await requireUsableCli(); // 快照与体检之前就拒绝,避免留下孤儿 trash
924
+ await requireInstaller(); // 快照与体检之前就拒绝,避免留下孤儿 trash
977
925
  return enqueue(`remove:${name}`, async () => {
978
926
  const canonicalPath = path.join(paths.canonical, name);
979
927
  if (!fs.existsSync(canonicalPath)) {
980
928
  throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
981
929
  }
982
930
 
983
- // ① 体检:CLI 的 remove 会把所有 agent 目录下的同名条目 rm -rf,不区分是谁建的
931
+ // ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)
984
932
  const conflicts = [];
985
933
  for (const target of detectedTargets()) {
986
934
  const linkPath = path.join(target.dir, name);
@@ -998,13 +946,35 @@ export function createService({ paths, store, registry, cli, logger, env = proce
998
946
  );
999
947
  }
1000
948
 
1001
- // ② 快照进回收站(默认开,保留 7 天)
949
+ // ② 快照进回收站;lock 写失败时用它把 canonical 恢复回来
1002
950
  const hash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
1003
951
  const trash = trashSkill({ paths, skill: name, fromDir: canonicalPath, hash });
1004
952
 
1005
- // ③ 调 CLI:绝不带 -a
1006
- const result = await cli.remove({ names: [name] });
1007
- const cliOk = result.code === 0 && !result.timedOut;
953
+ // ③ installer 删 lock 条目 + canonical;失败时不留“目录没了 lock 还在”的半状态
954
+ try {
955
+ await installer.remove({ name });
956
+ } catch (error) {
957
+ if (!fs.existsSync(canonicalPath)) {
958
+ try {
959
+ restoreBackup({
960
+ backup: { contentDir: path.join(trash.dir, 'content') },
961
+ toDir: canonicalPath,
962
+ });
963
+ } catch (restoreError) {
964
+ logger('remove.restore.failed', { name, message: restoreError.message });
965
+ }
966
+ }
967
+ const failure = installerFailure(error, name, '删除');
968
+ logger('remove.failed', { name, code: error?.code || null });
969
+ const kept = fs.existsSync(canonicalPath);
970
+ return {
971
+ ok: false,
972
+ code: failure.code,
973
+ message: `${failure.message};${kept ? '真身仍在 canonical,回收站已留快照' : '已从回收站恢复真身'}`,
974
+ detail: failure.detail,
975
+ trash: trash.key,
976
+ };
977
+ }
1008
978
 
1009
979
  // ④ 清自家链接
1010
980
  const cleaned = [];
@@ -1020,16 +990,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1020
990
  delete draft.checks[name];
1021
991
  });
1022
992
 
1023
- logger('remove', { name, cliOk, trash: trash.key });
1024
- if (!cliOk) {
1025
- return {
1026
- ok: false,
1027
- code: 'error.skills.cli-failed',
1028
- message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
1029
- detail: { code: result.code, stderr: result.stderr.slice(-2000) },
1030
- trash: trash.key,
1031
- };
1032
- }
993
+ logger('remove', { name, trash: trash.key });
1033
994
  return { ok: true, name, trash: trash.key, removedLinks: cleaned };
1034
995
  });
1035
996
  },
@@ -1049,24 +1010,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1049
1010
  return cached.pending || cached.value;
1050
1011
  }
1051
1012
  const pending = (async () => {
1052
- // 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
1053
- const viaGit = await probeViaGit({
1054
- source: canonicalSource,
1055
- ref: requestedRef,
1056
- subpath: requestedSubpath,
1057
- });
1058
- const probe =
1059
- viaGit ||
1060
- (await readSnapshot(
1061
- {
1062
- id: null,
1063
- source: canonicalSource,
1064
- ref: requestedRef,
1065
- subpath: requestedSubpath,
1066
- snapshot: null,
1067
- },
1068
- { force: true, withCommit: false },
1069
- ));
1013
+ // REST 优先;限流时 readSnapshot 内部用 Atom + codeload 兜底
1014
+ const probe = await readSnapshot(
1015
+ {
1016
+ id: null,
1017
+ source: canonicalSource,
1018
+ ref: requestedRef,
1019
+ subpath: requestedSubpath,
1020
+ snapshot: null,
1021
+ },
1022
+ { force: true, withCommit: false },
1023
+ );
1070
1024
  // 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
1071
1025
  const enrichRef = probe.snapshot?.commit || probe.branch;
1072
1026
  const enriched = await enrichSkillEntries({
@@ -1197,19 +1151,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1197
1151
  assertWritable();
1198
1152
  const subscription = state().subscriptions.find((item) => item.id === id);
1199
1153
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
1200
- // T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
1154
+ // 短路:最常见的情况是「什么都没变」。先用 Atom 比 commit,一致就一次 REST 都不发。
1201
1155
  if (await remoteCommitUnchanged(subscription)) {
1202
1156
  const at = nowIso();
1203
1157
  store.update((draft) => {
1204
1158
  const row = draft.subscriptions.find((item) => item.id === id);
1205
1159
  if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
1206
1160
  });
1207
- logger('subscription.refresh', { id, notModified: true, via: 'git' });
1161
+ logger('subscription.refresh', { id, notModified: true, via: 'atom' });
1208
1162
  return {
1209
1163
  ok: true,
1210
1164
  changed: false,
1211
1165
  notModified: true,
1212
- via: 'git',
1166
+ via: 'atom',
1213
1167
  subscription: state().subscriptions.find((item) => item.id === id),
1214
1168
  };
1215
1169
  }
@@ -1267,11 +1221,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1267
1221
 
1268
1222
  const results = [];
1269
1223
  for (const { subscription, installs: groupInstalls } of groups.values()) {
1270
- // T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
1224
+ // 与 readSnapshot 同一套语义:限流时 Atom/codeload 兜底,失败才逐个标 unknown
1271
1225
  // withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
1272
1226
  let probe;
1273
1227
  try {
1274
- probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
1228
+ probe = await readSnapshot(subscription, { withCommit: false, persistRef: true, allowUnchanged: false });
1275
1229
  } catch (error) {
1276
1230
  for (const install of groupInstalls) {
1277
1231
  results.push({
@@ -1336,8 +1290,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1336
1290
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
1337
1291
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
1338
1292
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
1339
- // 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
1340
- const probe = await readSnapshot(subscription, { force: true });
1293
+ // 与 checkUpdates 共用一套取数语义;计划必须拿到完整树,所以不允许 commit 相同就短路
1294
+ const probe = await readSnapshot(subscription, { force: true, allowUnchanged: false });
1341
1295
  const ref = probe.branch;
1342
1296
  const lock = readLock(paths);
1343
1297
  const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
@@ -1368,7 +1322,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1368
1322
 
1369
1323
  async applyUpdate({ name, token, overwrite = false }) {
1370
1324
  assertWritable();
1371
- await requireUsableCli();
1325
+ await requireInstaller();
1372
1326
  return enqueue(`apply:${name}`, async () => {
1373
1327
  const plan = await this.planUpdate({ name });
1374
1328
  if (token && token !== plan.token) {
@@ -1376,7 +1330,6 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1376
1330
  }
1377
1331
  const install = store.findInstall(name);
1378
1332
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
1379
- const canonicalPath = path.join(paths.canonical, name);
1380
1333
  if (plan.localModified && !overwrite) {
1381
1334
  throw new ServiceError('error.skills.locally-modified', '本地有改动,更新会覆盖它们;确认后带 overwrite 重试', {
1382
1335
  plan,
@@ -1384,57 +1337,79 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1384
1337
  }
1385
1338
 
1386
1339
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
1387
- const backup = fs.existsSync(canonicalPath)
1388
- ? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
1389
- : null;
1340
+ if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法更新');
1390
1341
 
1391
- const sourceArg = plan.toCommit
1392
- ? `${subscription.source}#${plan.toCommit}`
1393
- : `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
1394
- const result = await cli.add({ source: sourceArg, skills: [name] });
1395
- const lock = readLock(paths);
1396
- const lockHash = lock.skills?.[name]?.skillFolderHash || null;
1397
- const verified = Boolean(lockHash) && lockHash === plan.targetTreeSha;
1398
- const present = fs.existsSync(canonicalPath);
1399
-
1400
- if (result.code !== 0 || !present || !verified) {
1401
- if (backup) restoreBackup({ backup, toDir: canonicalPath });
1402
- const failure = result.code !== 0 ? cliAddFailure(result, 'add') : null;
1403
- const gitMissing = failure?.code === 'error.skills.git-missing';
1404
- logger('updates.apply.failed', { name, code: result.code, verified, present });
1405
- const suffix = backup ? ';已回滚到上一版' : ';没有可回滚的备份';
1342
+ let result;
1343
+ try {
1344
+ result = await installer.install({
1345
+ source: subscription.source,
1346
+ commit: plan.toCommit || null,
1347
+ ref: plan.toCommit || plan.ref || subscription.ref || null,
1348
+ subpath: subscription.subpath,
1349
+ names: [name],
1350
+ expectedEntries: plan.targetTreeSha ? [{ name, treeSha: plan.targetTreeSha }] : [],
1351
+ strict: true,
1352
+ });
1353
+ } catch (error) {
1354
+ logger('updates.apply.failed', { name, reason: error?.code || null });
1406
1355
  return {
1407
1356
  ok: false,
1408
- code: gitMissing ? failure.code : 'error.skills.update-failed',
1409
- message: gitMissing ? `${failure.message}${suffix}` : `更新失败${suffix}`,
1410
- detail: {
1411
- cliCode: result.code,
1412
- verified,
1413
- present,
1414
- stderr: (result.stderr || '').slice(-2000),
1415
- ...(failure?.detail || {}),
1416
- },
1417
- backup: backup?.key || null,
1357
+ code: 'error.skills.update-failed',
1358
+ message: '更新失败;安装器已回滚到操作前状态',
1359
+ detail: { reason: error?.code || null, status: error?.status ?? null },
1360
+ backup: null,
1418
1361
  };
1419
1362
  }
1420
1363
 
1421
- const installedHash = contentHash(canonicalPath);
1364
+ const item = result.installed?.find((row) => row.name === name);
1365
+ if (!item) {
1366
+ // 理论不可达(names 已选中且 discovery 成功);绝不把「没装上」报成成功
1367
+ logger('updates.apply.failed', { name, reason: 'missing-installed-item' });
1368
+ return {
1369
+ ok: false,
1370
+ code: 'error.skills.update-failed',
1371
+ message: '更新失败;安装器没有返回该技能的结果',
1372
+ detail: { reason: 'missing-installed-item' },
1373
+ backup: null,
1374
+ };
1375
+ }
1376
+ const installedHash = item.contentHash || contentHash(path.join(paths.canonical, name));
1422
1377
  store.update((draft) => {
1423
- const row = draft.installs.find((item) => item.name === name);
1378
+ const row = draft.installs.find((entry) => entry.name === name);
1424
1379
  if (!row) return;
1425
- row.commit = plan.toCommit;
1426
- // 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
1427
- row.treeSha = plan.targetTreeSha || row.treeSha || null;
1380
+ row.commit = plan.toCommit || null;
1381
+ row.treeSha = item.treeSha || plan.targetTreeSha || null;
1428
1382
  row.installedHash = installedHash;
1429
1383
  row.updatedAt = nowIso();
1430
- row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
1431
- draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
1384
+ row.backup = item.backup
1385
+ ? {
1386
+ key: item.backup,
1387
+ hash: item.backupHash || null,
1388
+ treeSha: item.backupTreeSha || null,
1389
+ commit: install.commit || null,
1390
+ }
1391
+ : row.backup;
1392
+ draft.checks[name] = {
1393
+ name,
1394
+ status: 'current',
1395
+ detail: null,
1396
+ wouldCommit: null,
1397
+ checkedAt: nowIso(),
1398
+ installCommit: plan.toCommit || null,
1399
+ };
1432
1400
  });
1433
1401
 
1434
1402
  // 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
1435
1403
  const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
1436
- logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: backup?.key });
1437
- return { ok: true, name, fromCommit: plan.fromCommit, toCommit: plan.toCommit, backup: backup?.key || null, relinked };
1404
+ logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: item.backup || null });
1405
+ return {
1406
+ ok: true,
1407
+ name,
1408
+ fromCommit: plan.fromCommit,
1409
+ toCommit: plan.toCommit || null,
1410
+ backup: item.backup || null,
1411
+ relinked,
1412
+ };
1438
1413
  });
1439
1414
  },
1440
1415
 
@@ -1446,7 +1421,26 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1446
1421
  const backup = readBackup({ paths, key: install.backup.key });
1447
1422
  if (!backup) throw new ServiceError('error.skills.no-backup', '备份目录已不存在');
1448
1423
  const canonicalPath = path.join(paths.canonical, name);
1424
+ // 先读 lock 再动盘:lock 损坏时直接失败,避免只回滚一半
1425
+ const lockBefore = readLockFile(paths.lockFile);
1426
+ const lockEntry = lockBefore.data?.skills?.[name] || null;
1449
1427
  restoreBackup({ backup, toDir: canonicalPath });
1428
+ if (lockEntry) {
1429
+ updateLock({
1430
+ lockPath: paths.lockFile,
1431
+ expectedDigest: lockBefore.digest,
1432
+ mutate: (draft) => {
1433
+ const previous = draft.skills[name];
1434
+ if (!previous) return;
1435
+ draft.skills[name] = {
1436
+ ...previous,
1437
+ ...(install.backup.treeSha ? { skillFolderHash: install.backup.treeSha } : {}),
1438
+ ...(install.backup.commit ? { ref: install.backup.commit } : {}),
1439
+ updatedAt: nowIso(),
1440
+ };
1441
+ },
1442
+ });
1443
+ }
1450
1444
  const installedHash = contentHash(canonicalPath);
1451
1445
  store.update((draft) => {
1452
1446
  const row = draft.installs.find((item) => item.name === name);