@yottameta/yotta-skills-plugin 0.26.0 → 0.27.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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/yotta-skills/SKILL.md +15 -6
- package/skills/yotta-skills/assets/view.html +12 -5
- package/skills/yotta-skills/bin/yotta-skills.js +74 -12
- package/skills/yotta-skills/lib/hub-adopt.js +16 -0
- package/skills/yotta-skills/lib/hub-view-server.js +14 -2
- package/skills/yotta-skills/lib/hub.js +619 -4
- package/skills/yotta-skills/lib/registry-fetch.js +7 -0
- package/skills/yotta-skills/lib/skills-scan.js +41 -0
- package/skills/yotta-skills/references/hub.md +29 -1
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
|
@@ -21,6 +21,20 @@ const STATE_FILE = '.yotta-hub.json';
|
|
|
21
21
|
const LINKS_FILE = '.yotta-links.json';
|
|
22
22
|
const AUDIT_FILE = '.yotta-hub-audit.jsonl';
|
|
23
23
|
const SKILL_FILE = 'SKILL.md';
|
|
24
|
+
const TRASH_RETENTION_DAYS = 7;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hub 家族范围 = 全家清单(skills.json,27)+ 特殊家族(5)。
|
|
28
|
+
* 特殊家族不进一次性安装清单;仅参与 Hub 安装 / 更新 / 收敛 / 体检,
|
|
29
|
+
* 版本跟随各自 npm latest(由安装管线在运行时解析)。
|
|
30
|
+
*/
|
|
31
|
+
const HUB_FAMILY_EXTRAS = [
|
|
32
|
+
{ slug: 'yotta-dev-mcp', name: '元开', pkg: '@yottameta/yotta-dev-mcp', version: 'latest' },
|
|
33
|
+
{ slug: 'yotta-partner', name: '元伴', pkg: '@yottameta/yotta-partner', version: 'latest' },
|
|
34
|
+
{ slug: 'yotta-present', name: '元呈', pkg: '@yottameta/yotta-present', version: 'latest' },
|
|
35
|
+
{ slug: 'yotta-skills', name: '元阁', pkg: '@yottameta/yotta-skills', version: 'latest' },
|
|
36
|
+
{ slug: 'yotta-verify-mcp', name: '元信MCP', pkg: '@yottameta/yotta-verify-mcp', version: 'latest' },
|
|
37
|
+
];
|
|
24
38
|
|
|
25
39
|
const HASH_SKIP = new Set([
|
|
26
40
|
'.git', 'node_modules', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
@@ -175,6 +189,161 @@ function hashTree(dir) {
|
|
|
175
189
|
return hash.digest('hex');
|
|
176
190
|
}
|
|
177
191
|
|
|
192
|
+
/** 统计目录树中的普通文件数与总字节数(不跟随链接;跨卷复制校验用)。 */
|
|
193
|
+
function treeStats(dir) {
|
|
194
|
+
let files = 0;
|
|
195
|
+
let bytes = 0;
|
|
196
|
+
const walk = (current) => {
|
|
197
|
+
for (const name of fs.readdirSync(current)) {
|
|
198
|
+
const full = path.join(current, name);
|
|
199
|
+
const stat = fs.lstatSync(full);
|
|
200
|
+
if (stat.isSymbolicLink()) continue;
|
|
201
|
+
if (stat.isDirectory()) walk(full);
|
|
202
|
+
else if (stat.isFile()) {
|
|
203
|
+
files += 1;
|
|
204
|
+
bytes += stat.size;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(path.resolve(dir));
|
|
209
|
+
return { files, bytes };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 重建链接条目:目录优先用 junction(Windows)/ dir(POSIX),文件用 file。 */
|
|
213
|
+
function copyLinkEntry(src, dest) {
|
|
214
|
+
const target = fs.readlinkSync(src);
|
|
215
|
+
let kinds;
|
|
216
|
+
if (process.platform === 'win32') {
|
|
217
|
+
let isDir = null;
|
|
218
|
+
try {
|
|
219
|
+
isDir = fs.statSync(src).isDirectory();
|
|
220
|
+
} catch (_) {
|
|
221
|
+
isDir = null;
|
|
222
|
+
}
|
|
223
|
+
kinds = isDir === false ? ['file', 'junction'] : ['junction', 'file'];
|
|
224
|
+
} else {
|
|
225
|
+
let isDir = false;
|
|
226
|
+
try {
|
|
227
|
+
isDir = fs.statSync(src).isDirectory();
|
|
228
|
+
} catch (_) {
|
|
229
|
+
isDir = false;
|
|
230
|
+
}
|
|
231
|
+
kinds = [isDir ? 'dir' : 'file'];
|
|
232
|
+
}
|
|
233
|
+
let lastError = null;
|
|
234
|
+
for (const kind of kinds) {
|
|
235
|
+
try {
|
|
236
|
+
fs.symlinkSync(target, dest, kind);
|
|
237
|
+
return;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
lastError = error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw lastError || new Error('无法重建链接:' + src);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 逐条目复制目录树(保真:含链接、空目录与全部普通文件)。 */
|
|
246
|
+
function copyEntryFaithful(src, dest) {
|
|
247
|
+
const stat = fs.lstatSync(src);
|
|
248
|
+
if (stat.isSymbolicLink()) {
|
|
249
|
+
copyLinkEntry(src, dest);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (stat.isDirectory()) {
|
|
253
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
254
|
+
for (const name of fs.readdirSync(src)) {
|
|
255
|
+
const child = path.join(src, name);
|
|
256
|
+
const childStat = fs.lstatSync(child);
|
|
257
|
+
if (childStat.isSymbolicLink()) copyLinkEntry(child, path.join(dest, name));
|
|
258
|
+
else if (childStat.isDirectory()) copyEntryFaithful(child, path.join(dest, name));
|
|
259
|
+
else if (childStat.isFile()) fs.copyFileSync(child, path.join(dest, name));
|
|
260
|
+
else throw new Error('不支持的条目类型:' + child);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (stat.isFile()) {
|
|
265
|
+
fs.copyFileSync(src, dest);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
throw new Error('不支持的条目类型:' + src);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 删除条目:链接只删链接本身,不触碰目标。 */
|
|
272
|
+
function removeEntryFaithful(entryPath) {
|
|
273
|
+
const stat = fs.lstatSync(entryPath);
|
|
274
|
+
if (stat.isSymbolicLink()) {
|
|
275
|
+
removeDirLink(entryPath);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 校验复制结果:链接比对目标,目录比对 treeHash + 文件数 + 字节数。 */
|
|
282
|
+
function verifyEntryCopy(src, dest) {
|
|
283
|
+
const srcStat = fs.lstatSync(src);
|
|
284
|
+
const destStat = fs.lstatSync(dest);
|
|
285
|
+
if (srcStat.isSymbolicLink() !== destStat.isSymbolicLink()) {
|
|
286
|
+
return { ok: false, reason: '条目类型不一致' };
|
|
287
|
+
}
|
|
288
|
+
if (srcStat.isSymbolicLink()) {
|
|
289
|
+
if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) {
|
|
290
|
+
return { ok: false, reason: '链接目标不一致' };
|
|
291
|
+
}
|
|
292
|
+
return { ok: true };
|
|
293
|
+
}
|
|
294
|
+
if (hashTree(src) !== hashTree(dest)) {
|
|
295
|
+
return { ok: false, reason: 'treeHash 不一致' };
|
|
296
|
+
}
|
|
297
|
+
const from = treeStats(src);
|
|
298
|
+
const to = treeStats(dest);
|
|
299
|
+
if (from.files !== to.files || from.bytes !== to.bytes) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: '文件数 / 字节数不一致(源 ' + from.files + ' 个 / ' + from.bytes + ' 字节;副本 ' +
|
|
303
|
+
to.files + ' 个 / ' + to.bytes + ' 字节)',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { ok: true };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 跨卷安全移动:优先 rename;EXDEV(跨盘)时回退「复制 → 校验 → 删除源」。
|
|
311
|
+
* 任一步失败都不静默丢数据:源完整则回滚副本;源已不完整则保留完整副本并报出路径。
|
|
312
|
+
*/
|
|
313
|
+
function moveEntryAcrossDevices(src, dest, options) {
|
|
314
|
+
const opts = options || {};
|
|
315
|
+
const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
|
|
316
|
+
try {
|
|
317
|
+
renameEntry(src, dest);
|
|
318
|
+
return { method: 'rename' };
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (!error || error.code !== 'EXDEV') throw error;
|
|
321
|
+
}
|
|
322
|
+
copyEntryFaithful(src, dest);
|
|
323
|
+
let check;
|
|
324
|
+
try {
|
|
325
|
+
check = verifyEntryCopy(src, dest);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
check = { ok: false, reason: error.message };
|
|
328
|
+
}
|
|
329
|
+
if (!check.ok) {
|
|
330
|
+
try { removeEntryFaithful(dest); } catch (_) { /* 保留现场供排查 */ }
|
|
331
|
+
throw new Error('跨卷复制校验失败(' + check.reason + ');源目录未动,副本已清理');
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
removeEntryFaithful(src);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
let intact = false;
|
|
337
|
+
try { intact = verifyEntryCopy(dest, src).ok; } catch (_) { intact = false; }
|
|
338
|
+
if (intact) {
|
|
339
|
+
try { removeEntryFaithful(dest); } catch (_) { /* 保留现场供排查 */ }
|
|
340
|
+
throw new Error('跨卷复制完成但源删除失败:' + error.message + ';源目录完整,副本已回滚');
|
|
341
|
+
}
|
|
342
|
+
throw new Error('跨卷复制完成但源删除失败:' + error.message + ';完整副本保留在 ' + dest);
|
|
343
|
+
}
|
|
344
|
+
return { method: 'copy' };
|
|
345
|
+
}
|
|
346
|
+
|
|
178
347
|
function scanHubSkills(hubDir) {
|
|
179
348
|
const root = path.resolve(hubDir);
|
|
180
349
|
const result = [];
|
|
@@ -226,10 +395,141 @@ function manifestSlugs(manifest) {
|
|
|
226
395
|
return set;
|
|
227
396
|
}
|
|
228
397
|
|
|
398
|
+
/** 家族 slug 集合:接受清单数组 / Set / {skills} 结构,并始终并入特殊家族 5 个。 */
|
|
399
|
+
function familySlugSet(manifest) {
|
|
400
|
+
const set = new Set();
|
|
401
|
+
if (manifest instanceof Set) {
|
|
402
|
+
for (const slug of manifest) set.add(String(slug));
|
|
403
|
+
} else {
|
|
404
|
+
const list = Array.isArray(manifest) ? manifest : (manifest && manifest.skills) || [];
|
|
405
|
+
for (const item of list) {
|
|
406
|
+
if (typeof item === 'string') set.add(item);
|
|
407
|
+
else if (item && item.slug) set.add(item.slug);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const item of HUB_FAMILY_EXTRAS) set.add(item.slug);
|
|
411
|
+
return set;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function compareVersions(left, right) {
|
|
415
|
+
return scanLib.compareSemver(left, right);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readSkillMeta(dir) {
|
|
419
|
+
try {
|
|
420
|
+
const text = fs.readFileSync(path.join(dir, SKILL_FILE), 'utf8');
|
|
421
|
+
const fm = scanLib.parseFrontmatter(text) || {};
|
|
422
|
+
return {
|
|
423
|
+
name: String(fm.name || '').trim(),
|
|
424
|
+
version: fm.version ? String(fm.version).trim() : '',
|
|
425
|
+
};
|
|
426
|
+
} catch (_) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveTrashRoot(hubDir, opts) {
|
|
432
|
+
const options = opts || {};
|
|
433
|
+
const explicit = options.trashRoot || options.trashDir || process.env.YOTTA_SKILLS_TRASH;
|
|
434
|
+
if (explicit) return path.resolve(explicit);
|
|
435
|
+
return path.join(path.dirname(path.resolve(hubDir)), 'trash');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function trashRunStamp(now) {
|
|
439
|
+
const d = now instanceof Date ? now : new Date();
|
|
440
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
441
|
+
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' +
|
|
442
|
+
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '-' + process.pid;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function safeHostName(value) {
|
|
446
|
+
const cleaned = String(value || '')
|
|
447
|
+
.replace(/[\\/:*?"<>|\s]+/g, '-')
|
|
448
|
+
.replace(/^-+|-+$/g, '');
|
|
449
|
+
return cleaned || 'host';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 清理回收站中超过保留期的条目(best-effort;返回清理数量)。 */
|
|
453
|
+
function pruneTrash(trashRoot, options) {
|
|
454
|
+
const opts = options || {};
|
|
455
|
+
const days = Number(opts.days) > 0 ? Number(opts.days) : TRASH_RETENTION_DAYS;
|
|
456
|
+
const now = opts.now instanceof Date ? opts.now.getTime() : Date.now();
|
|
457
|
+
let pruned = 0;
|
|
458
|
+
let entries;
|
|
459
|
+
try {
|
|
460
|
+
entries = fs.readdirSync(trashRoot, { withFileTypes: true });
|
|
461
|
+
} catch (_) {
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
for (const entry of entries) {
|
|
465
|
+
if (!entry.isDirectory()) continue;
|
|
466
|
+
const full = path.join(trashRoot, entry.name);
|
|
467
|
+
let stat;
|
|
468
|
+
try {
|
|
469
|
+
stat = fs.statSync(full);
|
|
470
|
+
} catch (_) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (now - stat.mtimeMs > days * 24 * 60 * 60 * 1000) {
|
|
474
|
+
try {
|
|
475
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
476
|
+
pruned += 1;
|
|
477
|
+
} catch (_) { /* keep for next run */ }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return pruned;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 列出宿主目录中某个家族技能的副本:
|
|
485
|
+
* - 精确同名(slug)的真目录 / 链接;
|
|
486
|
+
* - `slug__*` 前缀命名、frontmatter name === slug 的重命名副本。
|
|
487
|
+
*/
|
|
488
|
+
function listFamilyCopies(targetDir, slug) {
|
|
489
|
+
const copies = [];
|
|
490
|
+
let entries;
|
|
491
|
+
try {
|
|
492
|
+
entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
493
|
+
} catch (_) {
|
|
494
|
+
return copies;
|
|
495
|
+
}
|
|
496
|
+
for (const entry of entries) {
|
|
497
|
+
if (entry.name.startsWith('.')) continue;
|
|
498
|
+
const isExact = entry.name === slug;
|
|
499
|
+
const isRenamed = entry.name.startsWith(slug + '__');
|
|
500
|
+
if (!isExact && !isRenamed) continue;
|
|
501
|
+
const full = path.join(targetDir, entry.name);
|
|
502
|
+
let stat;
|
|
503
|
+
try {
|
|
504
|
+
stat = fs.lstatSync(full);
|
|
505
|
+
} catch (_) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (stat.isSymbolicLink()) {
|
|
509
|
+
copies.push({ name: entry.name, dir: full, kind: 'link', version: '' });
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (!stat.isDirectory()) {
|
|
513
|
+
if (isExact) copies.push({ name: entry.name, dir: full, kind: 'file', version: '' });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const meta = readSkillMeta(full);
|
|
517
|
+
if (isRenamed && (!meta || meta.name !== slug)) continue;
|
|
518
|
+
copies.push({
|
|
519
|
+
name: entry.name,
|
|
520
|
+
dir: full,
|
|
521
|
+
kind: 'directory',
|
|
522
|
+
version: meta ? (meta.version || '') : '',
|
|
523
|
+
metaName: meta ? meta.name : '',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return copies;
|
|
527
|
+
}
|
|
528
|
+
|
|
229
529
|
function syncHubState(hubDir, options) {
|
|
230
530
|
const opts = options || {};
|
|
231
531
|
const previous = readHubState(hubDir);
|
|
232
|
-
const family =
|
|
532
|
+
const family = familySlugSet(opts.manifest);
|
|
233
533
|
const scanned = scanHubSkills(hubDir);
|
|
234
534
|
const upstream = skillsCliLock.readLock({ homeDir: opts.homeDir, env: opts.env });
|
|
235
535
|
const now = nowIso();
|
|
@@ -340,8 +640,21 @@ function linkSkills(options) {
|
|
|
340
640
|
? opts.slugs
|
|
341
641
|
: scanHubSkills(hubDir).map((item) => item.slug);
|
|
342
642
|
const hubBySlug = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
|
|
643
|
+
const family = familySlugSet(opts.familySlugs || opts.manifest);
|
|
644
|
+
const familyFallback = !opts.familySlugs && !opts.manifest;
|
|
343
645
|
const links = readLinkState(hubDir).links.slice();
|
|
344
646
|
const results = [];
|
|
647
|
+
const trashRoot = resolveTrashRoot(hubDir, opts);
|
|
648
|
+
const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
|
|
649
|
+
const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
|
|
650
|
+
const stamp = trashRunStamp(opts.now);
|
|
651
|
+
const hostName = safeHostName(opts.label || path.basename(targetDir));
|
|
652
|
+
let prunedTrash = 0;
|
|
653
|
+
if (!dryRun) {
|
|
654
|
+
try {
|
|
655
|
+
prunedTrash = pruneTrash(trashRoot, { days: opts.trashRetentionDays });
|
|
656
|
+
} catch (_) { prunedTrash = 0; }
|
|
657
|
+
}
|
|
345
658
|
|
|
346
659
|
for (const slug of slugs) {
|
|
347
660
|
const skill = hubBySlug.get(slug);
|
|
@@ -351,6 +664,230 @@ function linkSkills(options) {
|
|
|
351
664
|
}
|
|
352
665
|
const target = path.join(targetDir, slug);
|
|
353
666
|
const current = classifyTarget(target, hubDir);
|
|
667
|
+
const isFamily = family.has(slug) || (familyFallback && slug.startsWith('yotta-'));
|
|
668
|
+
|
|
669
|
+
// ── 家族技能:链接时唯一性收敛(产品口径) ──────────────────────────
|
|
670
|
+
if (isFamily) {
|
|
671
|
+
const copies = listFamilyCopies(targetDir, slug);
|
|
672
|
+
const exactCopy = copies.find((copy) => copy.name === slug) || null;
|
|
673
|
+
const renamedCopies = copies.filter((copy) => copy.name !== slug);
|
|
674
|
+
const exactHealthy = current.kind === 'link' && current.inHub &&
|
|
675
|
+
current.targetExists !== false && samePath(current.target, skill.dir);
|
|
676
|
+
const moves = [];
|
|
677
|
+
const skipped = [];
|
|
678
|
+
let block = null;
|
|
679
|
+
let replaceExactLink = false;
|
|
680
|
+
|
|
681
|
+
const evaluate = (copy, isExact) => {
|
|
682
|
+
if (!copy) return;
|
|
683
|
+
const cls = classifyTarget(copy.dir, hubDir);
|
|
684
|
+
if (cls.kind === 'link') {
|
|
685
|
+
const pointsAtHub = cls.inHub && cls.targetExists !== false && samePath(cls.target, skill.dir);
|
|
686
|
+
if (pointsAtHub && isExact) return; // 已是目标链接,保留
|
|
687
|
+
if (cls.inHub) {
|
|
688
|
+
moves.push({ name: copy.name, dir: copy.dir, version: copy.version || '' });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (isExact) {
|
|
692
|
+
if (force) {
|
|
693
|
+
replaceExactLink = true;
|
|
694
|
+
} else {
|
|
695
|
+
block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
skipped.push(copy.name + '(外部链接,保留)');
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (cls.kind === 'directory') {
|
|
703
|
+
const meta = readSkillMeta(copy.dir);
|
|
704
|
+
if (!meta || meta.name !== slug) {
|
|
705
|
+
if (isExact) block = { status: 'conflict', note: '目标已存在真目录(frontmatter name 与技能不符,拒绝收敛)' };
|
|
706
|
+
else skipped.push(copy.name + '(name 不符,保留)');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const version = meta.version || '';
|
|
710
|
+
if (!skill.version) {
|
|
711
|
+
block = { status: 'skipped', note: 'Hub 副本版本无法解析;跳过收敛(fail-safe)' };
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (!version) {
|
|
715
|
+
block = { status: 'skipped', note: copy.name + ' 版本无法解析;跳过收敛(fail-safe)' };
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (compareVersions(version, skill.version) > 0) {
|
|
719
|
+
block = {
|
|
720
|
+
status: 'skipped',
|
|
721
|
+
note: copy.name + ' v' + version + ' 高于 Hub v' + skill.version + ';先运行 yotta-skills hub update 再链接',
|
|
722
|
+
};
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
moves.push({ name: copy.name, dir: copy.dir, version });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (cls.kind === 'file') {
|
|
729
|
+
if (isExact) block = { status: 'conflict', note: '目标已存在普通文件(拒绝覆盖)' };
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
evaluate(exactCopy, true);
|
|
733
|
+
for (const copy of renamedCopies) evaluate(copy, false);
|
|
734
|
+
|
|
735
|
+
if (block) {
|
|
736
|
+
if (exactHealthy) {
|
|
737
|
+
results.push({ slug, status: 'linked', target, note: '已链接到 hub;仍有未收敛副本:' + block.note });
|
|
738
|
+
} else {
|
|
739
|
+
results.push({ slug, status: block.status, target, note: block.note });
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (moves.length === 0 && exactHealthy) {
|
|
744
|
+
results.push({
|
|
745
|
+
slug,
|
|
746
|
+
status: 'linked',
|
|
747
|
+
target,
|
|
748
|
+
note: '已链接到 hub' + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
|
|
749
|
+
});
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (moves.length > 0 && dryRun) {
|
|
753
|
+
results.push({
|
|
754
|
+
slug,
|
|
755
|
+
status: 'would-converge',
|
|
756
|
+
target,
|
|
757
|
+
note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)' +
|
|
758
|
+
(replaceExactLink ? '并替换指向 Hub 之外的链接' : '') + '后建立链接:' +
|
|
759
|
+
moves.map((item) => item.name).join('、'),
|
|
760
|
+
moves: moves.map((item) => ({ from: item.dir, name: item.name, version: item.version || null })),
|
|
761
|
+
trashDir: trashRoot,
|
|
762
|
+
});
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (moves.length === 0 && dryRun) {
|
|
766
|
+
results.push(replaceExactLink
|
|
767
|
+
? { slug, status: 'would-replace', target, note: '将替换指向 Hub 之外的链接并建立链接' }
|
|
768
|
+
: { slug, status: 'would-link', target, note: '将建立链接' });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (moves.length === 0) {
|
|
772
|
+
try {
|
|
773
|
+
if (replaceExactLink) removeDirLink(target);
|
|
774
|
+
makeLink(skill.dir, target);
|
|
775
|
+
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
776
|
+
const record = {
|
|
777
|
+
slug,
|
|
778
|
+
agent: opts.agentId || null,
|
|
779
|
+
label: opts.label || null,
|
|
780
|
+
dir: targetDir,
|
|
781
|
+
target,
|
|
782
|
+
hubDir: skill.dir,
|
|
783
|
+
linkedAt: nowIso(),
|
|
784
|
+
backup: null,
|
|
785
|
+
};
|
|
786
|
+
if (index >= 0) links[index] = record;
|
|
787
|
+
else links.push(record);
|
|
788
|
+
results.push({
|
|
789
|
+
slug,
|
|
790
|
+
status: 'linked',
|
|
791
|
+
target,
|
|
792
|
+
note: (replaceExactLink
|
|
793
|
+
? '已替换指向 Hub 之外的链接并建立链接(仅移除链接本身,目标目录未动)'
|
|
794
|
+
: '已建立链接') + (skipped.length ? '(保留:' + skipped.join('、') + ')' : ''),
|
|
795
|
+
});
|
|
796
|
+
appendAudit(hubDir, {
|
|
797
|
+
event: 'link',
|
|
798
|
+
slug,
|
|
799
|
+
target,
|
|
800
|
+
hubDir: skill.dir,
|
|
801
|
+
backup: null,
|
|
802
|
+
...(replaceExactLink ? { replacedExternalLink: true } : {}),
|
|
803
|
+
});
|
|
804
|
+
} catch (error) {
|
|
805
|
+
results.push({
|
|
806
|
+
slug,
|
|
807
|
+
status: 'error',
|
|
808
|
+
target,
|
|
809
|
+
note: (replaceExactLink ? '替换失败:' : '') + error.message +
|
|
810
|
+
'(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const moved = [];
|
|
816
|
+
try {
|
|
817
|
+
for (const item of moves) {
|
|
818
|
+
const dest = path.join(trashRoot, stamp, hostName, item.name);
|
|
819
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
820
|
+
const transfer = moveEntryAcrossDevices(item.dir, dest, { renameEntry });
|
|
821
|
+
moved.push({
|
|
822
|
+
from: item.dir,
|
|
823
|
+
to: dest,
|
|
824
|
+
name: item.name,
|
|
825
|
+
version: item.version || null,
|
|
826
|
+
method: transfer.method,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (!exactHealthy) {
|
|
830
|
+
if (replaceExactLink) removeDirLink(target);
|
|
831
|
+
makeLink(skill.dir, target);
|
|
832
|
+
}
|
|
833
|
+
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
834
|
+
const record = {
|
|
835
|
+
slug,
|
|
836
|
+
agent: opts.agentId || null,
|
|
837
|
+
label: opts.label || null,
|
|
838
|
+
dir: targetDir,
|
|
839
|
+
target,
|
|
840
|
+
hubDir: skill.dir,
|
|
841
|
+
linkedAt: nowIso(),
|
|
842
|
+
backup: null,
|
|
843
|
+
converged: moved.map((item) => item.to),
|
|
844
|
+
};
|
|
845
|
+
if (index >= 0) links[index] = record;
|
|
846
|
+
else links.push(record);
|
|
847
|
+
const copiedCount = moved.filter((item) => item.method === 'copy').length;
|
|
848
|
+
results.push({
|
|
849
|
+
slug,
|
|
850
|
+
status: 'linked',
|
|
851
|
+
target,
|
|
852
|
+
note: '已收敛 ' + moved.length + ' 份旧副本(回收站保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接' +
|
|
853
|
+
(replaceExactLink ? '(已替换指向 Hub 之外的链接;仅移除链接本身)' : '') +
|
|
854
|
+
(copiedCount ? '(其中 ' + copiedCount + ' 份跨卷复制,校验通过)' : '') +
|
|
855
|
+
(skipped.length ? ';保留:' + skipped.join('、') : ''),
|
|
856
|
+
moved,
|
|
857
|
+
trashDir: trashRoot,
|
|
858
|
+
});
|
|
859
|
+
appendAudit(hubDir, {
|
|
860
|
+
event: 'converge',
|
|
861
|
+
slug,
|
|
862
|
+
target,
|
|
863
|
+
hubDir: skill.dir,
|
|
864
|
+
hubVersion: skill.version,
|
|
865
|
+
moved,
|
|
866
|
+
trashDir: trashRoot,
|
|
867
|
+
...(replaceExactLink ? { replacedExternalLink: true } : {}),
|
|
868
|
+
});
|
|
869
|
+
} catch (error) {
|
|
870
|
+
let restored = true;
|
|
871
|
+
for (const item of moved.slice().reverse()) {
|
|
872
|
+
try {
|
|
873
|
+
fs.mkdirSync(path.dirname(item.from), { recursive: true });
|
|
874
|
+
moveEntryAcrossDevices(item.to, item.from, { renameEntry });
|
|
875
|
+
} catch (_) { restored = false; }
|
|
876
|
+
}
|
|
877
|
+
results.push({
|
|
878
|
+
slug,
|
|
879
|
+
status: 'error',
|
|
880
|
+
target,
|
|
881
|
+
restored,
|
|
882
|
+
note: '收敛失败:' + error.message +
|
|
883
|
+
(restored ? '(已恢复原目录)' : '(恢复失败,请查看回收站:' + trashRoot + ')'),
|
|
884
|
+
trashDir: trashRoot,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── 非家族技能:保持原行为(同名冲突默认跳过,不自动删) ────────────
|
|
354
891
|
let backup = null;
|
|
355
892
|
if (current.kind === 'link' && current.inHub && current.targetExists !== false && samePath(current.target, skill.dir)) {
|
|
356
893
|
results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
|
|
@@ -393,7 +930,7 @@ function linkSkills(options) {
|
|
|
393
930
|
continue;
|
|
394
931
|
}
|
|
395
932
|
try {
|
|
396
|
-
|
|
933
|
+
makeLink(skill.dir, target);
|
|
397
934
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
398
935
|
const record = {
|
|
399
936
|
slug,
|
|
@@ -421,10 +958,10 @@ function linkSkills(options) {
|
|
|
421
958
|
if (!dryRun) {
|
|
422
959
|
writeLinkState(hubDir, { links });
|
|
423
960
|
try {
|
|
424
|
-
syncHubState(hubDir, { homeDir: opts.homeDir, env: opts.env });
|
|
961
|
+
syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
|
|
425
962
|
} catch (_) { /* link state is already durable; state refresh is best-effort */ }
|
|
426
963
|
}
|
|
427
|
-
return { hubDir, targetDir, dryRun, results };
|
|
964
|
+
return { hubDir, targetDir, dryRun, results, trashRoot, prunedTrash };
|
|
428
965
|
}
|
|
429
966
|
|
|
430
967
|
function unlinkSkills(options) {
|
|
@@ -514,6 +1051,71 @@ function status(options) {
|
|
|
514
1051
|
};
|
|
515
1052
|
}
|
|
516
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* doctor 只读检查:宿主目录中的家族技能副本是否唯一 / 版本是否落后。
|
|
1056
|
+
* 只报告不修改;真正的收敛发生在 hub link 执行时。
|
|
1057
|
+
*/
|
|
1058
|
+
function singleSourceChecks(opts, add) {
|
|
1059
|
+
const hubDir = path.resolve(opts.hubDir);
|
|
1060
|
+
if (!fs.existsSync(hubDir)) return;
|
|
1061
|
+
const family = familySlugSet(opts.manifest);
|
|
1062
|
+
let discovery = opts.discovery;
|
|
1063
|
+
if (!discovery) {
|
|
1064
|
+
try {
|
|
1065
|
+
discovery = require('./agent-discovery').discoverHosts({ homeDir: opts.homeDir, env: opts.env });
|
|
1066
|
+
} catch (_) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const hubSkills = new Map(scanHubSkills(hubDir).map((item) => [item.slug, item]));
|
|
1071
|
+
for (const host of discovery.hosts || []) {
|
|
1072
|
+
if (!host || !host.exists) continue;
|
|
1073
|
+
const groups = new Map();
|
|
1074
|
+
let entries;
|
|
1075
|
+
try {
|
|
1076
|
+
entries = fs.readdirSync(host.dir, { withFileTypes: true });
|
|
1077
|
+
} catch (_) {
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
for (const entry of entries) {
|
|
1081
|
+
if (entry.name.startsWith('.')) continue;
|
|
1082
|
+
if (!entry.name.startsWith('yotta-')) continue; // 家族 slug 统一前缀,避免读外部技能
|
|
1083
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
1084
|
+
const dir = path.join(host.dir, entry.name);
|
|
1085
|
+
const meta = readSkillMeta(dir);
|
|
1086
|
+
if (!meta || !meta.name || !family.has(meta.name)) continue;
|
|
1087
|
+
if (!groups.has(meta.name)) groups.set(meta.name, []);
|
|
1088
|
+
groups.get(meta.name).push({
|
|
1089
|
+
name: entry.name,
|
|
1090
|
+
dir,
|
|
1091
|
+
version: meta.version || '',
|
|
1092
|
+
kind: entry.isSymbolicLink() ? 'link' : 'directory',
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
for (const [slug, copies] of groups) {
|
|
1096
|
+
if (copies.length > 1) {
|
|
1097
|
+
add('single_source:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
|
|
1098
|
+
host.label + ' 中 ' + slug + ' 存在 ' + copies.length + ' 份副本(按 name 引用有歧义):' +
|
|
1099
|
+
copies.map((copy) => copy.name).join('、'),
|
|
1100
|
+
'运行 yotta-skills hub link --agent ' + (host.agentId || '<id>') + ' 收敛(旧副本移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)');
|
|
1101
|
+
}
|
|
1102
|
+
const hubSkill = hubSkills.get(slug);
|
|
1103
|
+
if (!hubSkill || !hubSkill.version) continue;
|
|
1104
|
+
const single = copies.length === 1 ? copies[0] : null;
|
|
1105
|
+
if (!single || single.kind !== 'directory' || !single.version) continue;
|
|
1106
|
+
const diff = compareVersions(single.version, hubSkill.version);
|
|
1107
|
+
if (diff > 0) {
|
|
1108
|
+
add('single_source_version:' + (host.agentId || host.label) + ':' + slug, false, 'warning',
|
|
1109
|
+
host.label + ' 的 ' + slug + ' v' + single.version + ' 高于 Hub v' + hubSkill.version,
|
|
1110
|
+
'先运行 yotta-skills hub update(或该技能自身更新)再链接');
|
|
1111
|
+
} else if (diff < 0) {
|
|
1112
|
+
add('single_source_version:' + (host.agentId || host.label) + ':' + slug, true, 'info',
|
|
1113
|
+
host.label + ' 的 ' + slug + ' v' + single.version + ' 低于 Hub v' + hubSkill.version + '(链接时自动收敛)');
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
517
1119
|
function doctor(options) {
|
|
518
1120
|
const opts = options || {};
|
|
519
1121
|
const hubDir = path.resolve(opts.hubDir);
|
|
@@ -562,6 +1164,10 @@ function doctor(options) {
|
|
|
562
1164
|
link.status === 'ok' ? null : '运行 hub link --all 重建,或 hub unlink 清理断链');
|
|
563
1165
|
}
|
|
564
1166
|
|
|
1167
|
+
try {
|
|
1168
|
+
singleSourceChecks(opts, add);
|
|
1169
|
+
} catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
|
|
1170
|
+
|
|
565
1171
|
const errors = checks.filter((check) => !check.ok && check.severity === 'error');
|
|
566
1172
|
const warnings = checks.filter((check) => !check.ok && check.severity === 'warning');
|
|
567
1173
|
return {
|
|
@@ -581,6 +1187,8 @@ module.exports = {
|
|
|
581
1187
|
STATE_FILE,
|
|
582
1188
|
LINKS_FILE,
|
|
583
1189
|
AUDIT_FILE,
|
|
1190
|
+
TRASH_RETENTION_DAYS,
|
|
1191
|
+
HUB_FAMILY_EXTRAS,
|
|
584
1192
|
resolveHubDir,
|
|
585
1193
|
hubPaths,
|
|
586
1194
|
readHubState,
|
|
@@ -589,8 +1197,15 @@ module.exports = {
|
|
|
589
1197
|
writeLinkState,
|
|
590
1198
|
appendAudit,
|
|
591
1199
|
hashTree,
|
|
1200
|
+
moveEntryAcrossDevices,
|
|
592
1201
|
scanHubSkills,
|
|
593
1202
|
syncHubState,
|
|
1203
|
+
familySlugSet,
|
|
1204
|
+
compareVersions,
|
|
1205
|
+
readSkillMeta,
|
|
1206
|
+
resolveTrashRoot,
|
|
1207
|
+
pruneTrash,
|
|
1208
|
+
listFamilyCopies,
|
|
594
1209
|
createDirLink,
|
|
595
1210
|
removeDirLink,
|
|
596
1211
|
classifyTarget,
|