@morehao/dsh-skills-console 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +35,48 @@ function nowIso() {
33
35
  return new Date().toISOString();
34
36
  }
35
37
 
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
+ }
54
+
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 });
73
+ }
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);
78
+ }
79
+
36
80
  /** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
37
81
  function formatClock(ms) {
38
82
  const at = new Date(ms);
@@ -68,51 +112,6 @@ function upstreamError(source, failure) {
68
112
  return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
69
113
  }
70
114
 
71
- /** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
72
- function installedNamesFrom(stdout) {
73
- try {
74
- const parsed = JSON.parse(stdout);
75
- const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
76
- const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
77
- return [...new Set(names)];
78
- } catch {
79
- return [];
80
- }
81
- }
82
-
83
- /** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
84
- function parseSkillFrontmatter(text) {
85
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
86
- if (!match) return {};
87
- const lines = match[1].split(/\r?\n/);
88
- const fields = {};
89
- for (let index = 0; index < lines.length; index += 1) {
90
- const line = lines[index];
91
- if (!line || /^\s/.test(line)) continue;
92
- const colon = line.indexOf(':');
93
- if (colon <= 0) continue;
94
- const key = line.slice(0, colon).trim();
95
- if (key !== 'name' && key !== 'description') continue;
96
- let value = line.slice(colon + 1).trim();
97
- const block = /^([|>])[+-]?$/.exec(value);
98
- if (block) {
99
- const folded = block[1] === '>';
100
- const rows = [];
101
- while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
102
- rows.push(lines[index + 1].replace(/^\s+/, ''));
103
- index += 1;
104
- }
105
- value = folded ? rows.join(' ') : rows.join('\n');
106
- } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
107
- const quote = value[0];
108
- value = value.slice(1, -1);
109
- if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
110
- }
111
- fields[key] = value.trim();
112
- }
113
- return fields;
114
- }
115
-
116
115
  /** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
117
116
  async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
118
117
  const results = new Array(entries.length);
@@ -175,10 +174,19 @@ function createQueue(logger) {
175
174
 
176
175
 
177
176
 
178
- 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
+ }) {
179
188
  const enqueue = createQueue(logger);
180
189
  const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
181
- const git = createGit({ env, logger });
182
190
  // 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
183
191
  const probeCache = new Map();
184
192
  const PROBE_TTL_MS = 60_000;
@@ -192,6 +200,34 @@ export function createService({ paths, store, registry, cli, logger, env = proce
192
200
  if (store.readOnly) throw new StateReadOnlyError(store.problem);
193
201
  }
194
202
 
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
+ if (!capabilities.usable) {
220
+ throw new ServiceError(
221
+ 'error.skills.installer-unavailable',
222
+ '本地安装器不可用(lock 版本/权限异常),写操作已禁用',
223
+ capabilities,
224
+ );
225
+ }
226
+ return capabilities;
227
+ }
228
+
229
+
230
+
195
231
  function detectedTargets() {
196
232
  return registry.detected();
197
233
  }
@@ -274,16 +310,23 @@ export function createService({ paths, store, registry, cli, logger, env = proce
274
310
  * 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
275
311
  * 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
276
312
  *
277
- * T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
313
+ * 数据源:REST 优先;403/429 限流时用 Atom 取 commit,再下 codeload tarball 本地扫描,
314
+ * 不启动任何外部进程。`allowUnchanged=false` 供更新计划使用:即使 commit 没变也要拿完整树。
278
315
  */
279
- 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
+ ) {
280
320
  const source = subscription.source;
281
321
  try {
282
322
  const { branch, failure } = await branchOf(subscription, { persist: persistRef });
283
323
  if (!branch) {
284
- if (failure?.rateLimited || failure?.status === 404) {
285
- const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
286
- if (viaGit) return viaGit;
324
+ if (failure?.rateLimited) {
325
+ return await atomSnapshot(subscription, {
326
+ ref: subscription.ref || null,
327
+ allowUnchanged,
328
+ rateLimit: failure,
329
+ });
287
330
  }
288
331
  throw upstreamError(source, failure);
289
332
  }
@@ -295,16 +338,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
295
338
  fetchImpl,
296
339
  });
297
340
  if (result.error) {
298
- // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
341
+ // 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流降级
299
342
  if (result.rateLimited) {
300
- const viaGit = await gitSnapshot(subscription, { ref: branch });
301
- if (viaGit) return viaGit;
302
- throw upstreamError(source, {
303
- status: result.status ?? null,
304
- rateLimited: true,
305
- resetAt: result.resetAt ?? null,
306
- error: result.error,
307
- });
343
+ return await atomSnapshot(subscription, { ref: branch, allowUnchanged, rateLimit: result });
308
344
  }
309
345
  throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
310
346
  source,
@@ -312,7 +348,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
312
348
  status: result.status ?? null,
313
349
  });
314
350
  }
315
- if (result.notModified) return { notModified: true, branch };
351
+ if (result.notModified) return { notModified: true, branch, via: 'rest' };
316
352
  const entries = listSkillsFromTree({
317
353
  tree: result.tree,
318
354
  rootSha: result.rootSha,
@@ -347,161 +383,91 @@ export function createService({ paths, store, registry, cli, logger, env = proce
347
383
  }
348
384
 
349
385
  /**
350
- * T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
351
- * 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
386
+ * 限流兜底:Atom 取 commit,与上次一致就短路;否则 codeload tarball 解压扫描。
387
+ * 失败统一翻成 T1 的四类错误(限流保留原始 reset 信息)。
352
388
  */
353
- async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
389
+ async function atomSnapshot(subscription, { ref = null, allowUnchanged = true, rateLimit = null } = {}) {
354
390
  const source = subscription.source;
355
- const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
356
- if (!git.enabled()) return null;
357
391
  const startedAt = Date.now();
358
- let url;
392
+ let atom;
359
393
  try {
360
- url = git.url(source);
394
+ atom = await fetchAtomCommit({ source, ref: ref || null, token: tokenOf, fetchImpl });
361
395
  } catch (error) {
362
- logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
363
- 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
+ });
364
403
  }
365
- if (!(await git.isAvailable())) {
366
- logger('git.fallback', { source, kind: 'git-missing', purpose });
367
- 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 };
368
409
  }
410
+ let scan;
369
411
  try {
370
- let branch = ref || null;
371
- if (!branch) {
372
- const head = await git.lsRemote({ url });
373
- branch = head.branch || 'main';
374
- }
375
- const tree = await git.tree({ url, ref: branch });
376
- const entries = listSkillsFromTree({
377
- tree: tree.entries,
378
- rootSha: tree.rootSha,
379
- subpath: subscription.subpath,
380
- repoName: source.split('/').pop(),
381
- });
382
- const changed =
383
- !subscription.snapshot ||
384
- subscription.snapshot.rootSha !== tree.rootSha ||
385
- JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
386
- logger(event, {
412
+ scan = await snapshotFromTarball({
387
413
  source,
414
+ commit: atom.commit,
388
415
  ref: branch,
389
- commit: tree.commit,
390
- entries: entries.length,
391
- changed,
392
- ms: Date.now() - startedAt,
416
+ subpath: subscription.subpath,
417
+ token: tokenOf,
418
+ fetchImpl,
393
419
  });
394
- return {
395
- branch,
396
- changed,
397
- via: 'git',
398
- tree: tree.entries,
399
- rootSha: tree.rootSha,
400
- snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
401
- };
402
420
  } catch (error) {
403
- logger('git.fallback', {
404
- source,
405
- kind: error.kind || 'clone-failed',
406
- message: error.message,
407
- purpose,
408
- 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,
409
427
  });
410
- return null;
411
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 };
412
442
  }
413
443
 
414
444
  /**
415
- * T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
416
- * 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
445
+ * commit 短路:Atom feed 的 HEAD 与订阅快照一致 → 没变化,不请求 REST。
446
+ * Atom 不可用时返回 false,照常走 REST(只增不减)。
417
447
  */
418
448
  async function remoteCommitUnchanged(subscription) {
419
449
  const commit = subscription.snapshot?.commit;
420
- if (!commit || !git.enabled()) return false;
421
- if (!(await git.isAvailable())) return false;
450
+ if (!commit) return false;
422
451
  try {
423
- const head = await git.lsRemote({ url: git.url(subscription.source) });
424
- if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
425
- 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;
426
459
  } catch (error) {
427
- logger('git.fallback', {
460
+ logger('atom.failed', {
428
461
  source: subscription.source,
429
- kind: error.kind || 'clone-failed',
462
+ kind: error.code || 'atom-failed',
430
463
  purpose: 'refresh',
431
464
  });
432
465
  return false;
433
466
  }
434
467
  }
435
468
 
436
- /**
437
- * 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
438
- * 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
439
- * 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
440
- */
441
- async function probeViaGit({ source, ref, subpath }) {
442
- const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
443
- if (!result) return null;
444
- return { branch: result.branch, via: 'git', snapshot: result.snapshot };
445
- }
446
-
447
- /**
448
- * T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
449
- * 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
450
- * `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
451
- *
452
- * best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
453
- */
454
- async function backfillBaselines({ names, source, lock, ref }) {
455
- if (!git.enabled() || names.length === 0) return [];
456
- const targets = [];
457
- for (const name of names) {
458
- const lockEntry = lock.skills?.[name];
459
- if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
460
- const install = store.findInstall(name);
461
- if (!install || install.treeSha) continue;
462
- targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
463
- }
464
- if (targets.length === 0) return [];
465
- if (!(await git.isAvailable())) {
466
- logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
467
- return [];
468
- }
469
- const startedAt = Date.now();
470
- try {
471
- const url = git.url(source);
472
- let useRef = ref || null;
473
- if (!useRef) useRef = (await git.lsRemote({ url })).commit;
474
- // 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
475
- const tree = await git.tree({ url, ref: useRef });
476
- const updated = [];
477
- for (const target of targets) {
478
- const treeSha = target.skillPath
479
- ? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
480
- : null;
481
- if (!treeSha) {
482
- logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
483
- continue;
484
- }
485
- store.update((draft) => {
486
- const row = draft.installs.find((item) => item.name === target.name);
487
- if (row && !row.treeSha) row.treeSha = treeSha;
488
- });
489
- updated.push({ name: target.name, treeSha });
490
- }
491
- if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
492
- return updated;
493
- } catch (error) {
494
- logger('git.error', {
495
- source,
496
- kind: error.kind || 'clone-failed',
497
- message: error.message,
498
- purpose: 'baseline',
499
- });
500
- return [];
501
- }
502
- }
503
-
504
- function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
469
+ function recordLink(name, target, linkPath, mode, created) {
470
+ if (store.readOnly) return;
505
471
  const existing = state().links.find((item) => item.skill === name && item.target === target.target);
506
472
  if (existing) return;
507
473
  if (!created) return;
@@ -646,7 +612,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
646
612
  const visible = await visibleSkills(cwd);
647
613
  const visibleNames = new Map(visible.skills.map((item) => [item.name, item.source || item.provider || 'unknown']));
648
614
  const ledger = buildLedger({ paths, state: state(), registry, lock, visibleNames });
649
- const capabilities = await cli.capabilities();
615
+ const capabilities = nativeCapabilities();
650
616
  return {
651
617
  ok: true,
652
618
  readOnly: store.readOnly,
@@ -674,7 +640,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
674
640
  },
675
641
 
676
642
  async diagnostics() {
677
- const capabilities = await cli.capabilities({ force: true });
643
+ const capabilities = nativeCapabilities();
678
644
  const lock = readLock(paths);
679
645
  reconcileTracking(lock);
680
646
  const rows = buildLedger({ paths, state: state(), registry, lock, visibleNames: new Map() }).rows;
@@ -789,6 +755,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
789
755
  },
790
756
 
791
757
  async setMode({ name, mode }) {
758
+ assertSafeSkillName(name);
792
759
  assertWritable();
793
760
  if (mode !== 'on' && mode !== 'off') throw new ServiceError('error.skills.bad-mode', 'mode 只能是 on / off');
794
761
  const install = store.findInstall(name);
@@ -831,12 +798,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
831
798
 
832
799
  async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
833
800
  assertWritable();
834
- const capabilities = await cli.capabilities();
835
- if (!capabilities.usable) {
836
- throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
837
- }
838
- // 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
801
+ await requireInstaller();
839
802
  const requested = (skills || []).filter(Boolean);
803
+ for (const name of requested) assertSafeSkillName(name);
840
804
 
841
805
  return enqueue('install', async () => {
842
806
  const subscription = subscriptionId
@@ -844,77 +808,94 @@ export function createService({ paths, store, registry, cli, logger, env = proce
844
808
  : null;
845
809
  if (subscriptionId && !subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
846
810
  const resolvedSource = subscription ? subscription.source : source;
847
- if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
848
-
849
- const snapshot = subscription?.snapshot || null;
850
- const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
851
- const pin = commit || snapshot?.commit || subscription?.ref || null;
852
- const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
853
-
854
- const result = await cli.add({ source: sourceArg, skills: planned });
855
- if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
856
- if (result.code !== 0) {
857
- throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
858
- code: result.code,
859
- stderr: result.stderr.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,
860
829
  });
861
830
  }
862
- // 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
863
- const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
864
- if (names.length === 0) {
865
- throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
866
- stdout: result.stdout.slice(-2000),
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,
867
843
  });
844
+ } catch (error) {
845
+ logger('install.failed', { source: sourceArg, code: error?.code || null });
846
+ throw installerFailure(error, resolvedSource, '安装');
868
847
  }
869
848
 
870
- const lock = readLock(paths);
871
849
  const installed = [];
872
- const warnings = [];
873
- for (const name of names) {
874
- 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);
875
854
  if (!fs.existsSync(canonicalPath)) {
876
- warnings.push({ name, warning: 'add 成功但 canonical 里没有该目录(可能被目录发现提前终止)' });
855
+ warnings.push({ name: item.name, warning: '安装成功但 canonical 里没有该目录,未能记账' });
877
856
  continue;
878
857
  }
879
- const lockEntry = lock.skills?.[name];
880
- if (!lockEntry) {
881
- warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
882
- }
883
- const expected = snapshot?.entries?.find((entry) => entry.name === name);
884
- 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;
885
861
  const shape = hashShape(lockHash);
886
- // 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
887
- // (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
888
- if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
889
- warnings.push({
890
- name,
891
- warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
892
- expected: expected.treeSha,
893
- actual: lockHash,
894
- });
895
- }
896
- const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
897
862
  const record = {
898
- name,
863
+ name: item.name,
899
864
  subscriptionId: subscription ? subscription.id : null,
900
- skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
865
+ skillPath: item.skillPath,
901
866
  ref: pin,
902
- commit: snapshot?.commit || null,
903
- // 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
904
- 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,
905
870
  installedHash,
906
- installedAt: nowIso(),
907
- updatedAt: nowIso(),
871
+ installedAt: recordedAt,
872
+ updatedAt: recordedAt,
908
873
  mode: 'on',
909
874
  scopeProjects: scopeProjects || [],
910
- backup: null,
875
+ backup: item.backup
876
+ ? { key: item.backup, hash: item.backupHash || null, treeSha: item.backupTreeSha || null }
877
+ : null,
911
878
  };
912
879
  store.update((draft) => {
913
- const index = draft.installs.findIndex((item) => item.name === name);
914
- if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
915
- 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,
916
898
  });
917
- installed.push({ name, lockHash, lockHashShape: shape, installedHash });
918
899
  }
919
900
 
920
901
  const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
@@ -926,15 +907,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
926
907
  linkResult.failed.push(...one.failed);
927
908
  }
928
909
 
929
- // T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
930
- // 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
931
- const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
932
- for (const item of installed) {
933
- const filled = baselines.find((row) => row.name === item.name);
934
- if (filled) item.treeSha = filled.treeSha;
935
- }
936
-
937
- logger('install', { source: sourceArg, skills: names, installed: installed.length });
910
+ logger('install', { source: sourceArg, skills: installed.map((item) => item.name), installed: installed.length });
938
911
  return {
939
912
  ok: true,
940
913
  installed,
@@ -946,14 +919,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
946
919
  },
947
920
 
948
921
  async remove({ name }) {
922
+ assertSafeSkillName(name);
949
923
  assertWritable();
924
+ await requireInstaller(); // 快照与体检之前就拒绝,避免留下孤儿 trash
950
925
  return enqueue(`remove:${name}`, async () => {
951
926
  const canonicalPath = path.join(paths.canonical, name);
952
927
  if (!fs.existsSync(canonicalPath)) {
953
928
  throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
954
929
  }
955
930
 
956
- // ① 体检:CLI 的 remove 会把所有 agent 目录下的同名条目 rm -rf,不区分是谁建的
931
+ // ① 体检:只动自己建的链接;真实目录/外来链接先拦下(前端弹窗依赖 detail.conflicts)
957
932
  const conflicts = [];
958
933
  for (const target of detectedTargets()) {
959
934
  const linkPath = path.join(target.dir, name);
@@ -971,13 +946,35 @@ export function createService({ paths, store, registry, cli, logger, env = proce
971
946
  );
972
947
  }
973
948
 
974
- // ② 快照进回收站(默认开,保留 7 天)
949
+ // ② 快照进回收站;lock 写失败时用它把 canonical 恢复回来
975
950
  const hash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
976
951
  const trash = trashSkill({ paths, skill: name, fromDir: canonicalPath, hash });
977
952
 
978
- // ③ 调 CLI:绝不带 -a
979
- const result = await cli.remove({ names: [name] });
980
- 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
+ }
981
978
 
982
979
  // ④ 清自家链接
983
980
  const cleaned = [];
@@ -993,16 +990,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
993
990
  delete draft.checks[name];
994
991
  });
995
992
 
996
- logger('remove', { name, cliOk, trash: trash.key });
997
- if (!cliOk) {
998
- return {
999
- ok: false,
1000
- code: 'error.skills.cli-failed',
1001
- message: 'skills remove 失败;已断开本插件建立的软链,真身可能仍在 canonical',
1002
- detail: { code: result.code, stderr: result.stderr.slice(-2000) },
1003
- trash: trash.key,
1004
- };
1005
- }
993
+ logger('remove', { name, trash: trash.key });
1006
994
  return { ok: true, name, trash: trash.key, removedLinks: cleaned };
1007
995
  });
1008
996
  },
@@ -1022,24 +1010,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1022
1010
  return cached.pending || cached.value;
1023
1011
  }
1024
1012
  const pending = (async () => {
1025
- // 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
1026
- const viaGit = await probeViaGit({
1027
- source: canonicalSource,
1028
- ref: requestedRef,
1029
- subpath: requestedSubpath,
1030
- });
1031
- const probe =
1032
- viaGit ||
1033
- (await readSnapshot(
1034
- {
1035
- id: null,
1036
- source: canonicalSource,
1037
- ref: requestedRef,
1038
- subpath: requestedSubpath,
1039
- snapshot: null,
1040
- },
1041
- { force: true, withCommit: false },
1042
- ));
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
+ );
1043
1024
  // 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
1044
1025
  const enrichRef = probe.snapshot?.commit || probe.branch;
1045
1026
  const enriched = await enrichSkillEntries({
@@ -1170,19 +1151,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1170
1151
  assertWritable();
1171
1152
  const subscription = state().subscriptions.find((item) => item.id === id);
1172
1153
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
1173
- // T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
1154
+ // 短路:最常见的情况是「什么都没变」。先用 Atom 比 commit,一致就一次 REST 都不发。
1174
1155
  if (await remoteCommitUnchanged(subscription)) {
1175
1156
  const at = nowIso();
1176
1157
  store.update((draft) => {
1177
1158
  const row = draft.subscriptions.find((item) => item.id === id);
1178
1159
  if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
1179
1160
  });
1180
- logger('subscription.refresh', { id, notModified: true, via: 'git' });
1161
+ logger('subscription.refresh', { id, notModified: true, via: 'atom' });
1181
1162
  return {
1182
1163
  ok: true,
1183
1164
  changed: false,
1184
1165
  notModified: true,
1185
- via: 'git',
1166
+ via: 'atom',
1186
1167
  subscription: state().subscriptions.find((item) => item.id === id),
1187
1168
  };
1188
1169
  }
@@ -1240,11 +1221,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1240
1221
 
1241
1222
  const results = [];
1242
1223
  for (const { subscription, installs: groupInstalls } of groups.values()) {
1243
- // T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
1224
+ // 与 readSnapshot 同一套语义:限流时 Atom/codeload 兜底,失败才逐个标 unknown
1244
1225
  // withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
1245
1226
  let probe;
1246
1227
  try {
1247
- probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
1228
+ probe = await readSnapshot(subscription, { withCommit: false, persistRef: true, allowUnchanged: false });
1248
1229
  } catch (error) {
1249
1230
  for (const install of groupInstalls) {
1250
1231
  results.push({
@@ -1309,8 +1290,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1309
1290
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
1310
1291
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
1311
1292
  if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
1312
- // 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
1313
- const probe = await readSnapshot(subscription, { force: true });
1293
+ // 与 checkUpdates 共用一套取数语义;计划必须拿到完整树,所以不允许 commit 相同就短路
1294
+ const probe = await readSnapshot(subscription, { force: true, allowUnchanged: false });
1314
1295
  const ref = probe.branch;
1315
1296
  const lock = readLock(paths);
1316
1297
  const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
@@ -1341,6 +1322,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1341
1322
 
1342
1323
  async applyUpdate({ name, token, overwrite = false }) {
1343
1324
  assertWritable();
1325
+ await requireInstaller();
1344
1326
  return enqueue(`apply:${name}`, async () => {
1345
1327
  const plan = await this.planUpdate({ name });
1346
1328
  if (token && token !== plan.token) {
@@ -1348,7 +1330,6 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1348
1330
  }
1349
1331
  const install = store.findInstall(name);
1350
1332
  if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
1351
- const canonicalPath = path.join(paths.canonical, name);
1352
1333
  if (plan.localModified && !overwrite) {
1353
1334
  throw new ServiceError('error.skills.locally-modified', '本地有改动,更新会覆盖它们;确认后带 overwrite 重试', {
1354
1335
  plan,
@@ -1356,48 +1337,79 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1356
1337
  }
1357
1338
 
1358
1339
  const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
1359
- const backup = fs.existsSync(canonicalPath)
1360
- ? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
1361
- : null;
1340
+ if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法更新');
1362
1341
 
1363
- const sourceArg = plan.toCommit
1364
- ? `${subscription.source}#${plan.toCommit}`
1365
- : `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
1366
- const result = await cli.add({ source: sourceArg, skills: [name] });
1367
- const lock = readLock(paths);
1368
- const lockHash = lock.skills?.[name]?.skillFolderHash || null;
1369
- const verified = Boolean(lockHash) && lockHash === plan.targetTreeSha;
1370
- const present = fs.existsSync(canonicalPath);
1371
-
1372
- if (result.code !== 0 || !present || !verified) {
1373
- if (backup) restoreBackup({ backup, toDir: canonicalPath });
1374
- logger('updates.apply.failed', { name, code: result.code, verified, present });
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 });
1375
1355
  return {
1376
1356
  ok: false,
1377
1357
  code: 'error.skills.update-failed',
1378
- message: backup ? '更新失败,已回滚到上一版' : '更新失败,没有可回滚的备份',
1379
- detail: { cliCode: result.code, verified, present, stderr: result.stderr.slice(-2000) },
1380
- backup: backup?.key || null,
1358
+ message: '更新失败;安装器已回滚到操作前状态',
1359
+ detail: { reason: error?.code || null, status: error?.status ?? null },
1360
+ backup: null,
1381
1361
  };
1382
1362
  }
1383
1363
 
1384
- 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));
1385
1377
  store.update((draft) => {
1386
- const row = draft.installs.find((item) => item.name === name);
1378
+ const row = draft.installs.find((entry) => entry.name === name);
1387
1379
  if (!row) return;
1388
- row.commit = plan.toCommit;
1389
- // 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
1390
- row.treeSha = plan.targetTreeSha || row.treeSha || null;
1380
+ row.commit = plan.toCommit || null;
1381
+ row.treeSha = item.treeSha || plan.targetTreeSha || null;
1391
1382
  row.installedHash = installedHash;
1392
1383
  row.updatedAt = nowIso();
1393
- row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
1394
- 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
+ };
1395
1400
  });
1396
1401
 
1397
1402
  // 复验软链:更新重建的是目录,链接理论上不受影响,断一条就补一条
1398
1403
  const relinked = linkSkill(name, ['dsh-global'], { scopeProjects: install.scopeProjects || [] });
1399
- logger('updates.apply', { name, from: plan.fromCommit, to: plan.toCommit, backup: backup?.key });
1400
- 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
+ };
1401
1413
  });
1402
1414
  },
1403
1415
 
@@ -1409,7 +1421,26 @@ export function createService({ paths, store, registry, cli, logger, env = proce
1409
1421
  const backup = readBackup({ paths, key: install.backup.key });
1410
1422
  if (!backup) throw new ServiceError('error.skills.no-backup', '备份目录已不存在');
1411
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;
1412
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
+ }
1413
1444
  const installedHash = contentHash(canonicalPath);
1414
1445
  store.update((draft) => {
1415
1446
  const row = draft.installs.find((item) => item.name === name);