@yottameta/yotta-skills-plugin 0.27.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 +6 -4
- package/skills/yotta-skills/bin/yotta-skills.js +1 -1
- package/skills/yotta-skills/lib/hub.js +199 -10
- package/skills/yotta-skills/references/hub.md +5 -0
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"authentication": "ON_INSTALL"
|
|
13
13
|
},
|
|
14
14
|
"category": "Developer Tools",
|
|
15
|
-
"version": "0.27.
|
|
15
|
+
"version": "0.27.1",
|
|
16
16
|
"description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
|
|
17
17
|
"interface": {
|
|
18
18
|
"displayName": "元阁 yotta-skills",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"authentication": "ON_INSTALL"
|
|
13
13
|
},
|
|
14
14
|
"category": "Developer Tools",
|
|
15
|
-
"version": "0.27.
|
|
15
|
+
"version": "0.27.1",
|
|
16
16
|
"description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
|
|
17
17
|
"interface": {
|
|
18
18
|
"displayName": "元阁 yotta-skills",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-skills-plugin",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "YuanGe (元阁) — orchestration and routing for the YottaMeta skill family, packaged as a YottaMeta Agent Plugin and DeepSeek Harness profile bundle.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "yotta-skills",
|
|
4
|
-
"version": "0.27.
|
|
4
|
+
"version": "0.27.1",
|
|
5
5
|
"description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "YottaMeta",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-skills
|
|
3
|
-
version: 0.27.
|
|
3
|
+
version: 0.27.1
|
|
4
4
|
description: 元阁 -- 元阁全家技能的总编排策划 + 编排路由 + 一键安装器 + 技能盘点 + 本机技能 Hub + 运行时 hook 适配。Hub 层:hub hosts 只读发现本机已装智能体与技能目录(文件系统优先,不读元忆);hub install / update 把技能装到 ~/.yottaskills/hub 单点真源(清单 27 + 特殊家族 5,特殊家族跟随各自 npm latest);hub link --all 用 Windows junction / POSIX symlink 分发到全部已发现宿主,元技能在链接时收敛宿主旧副本(移入 ~/.yottaskills/trash 保留 7 天、版本闸门 Hub ≥ 宿主);hub unlink 只删链接、fail-closed;非元技能不参与更新(用户自行处理);hub status 显示来源、版本、链接与异常;view 启动本机技能枢纽面板(默认 127.0.0.1:8789,六视图 + 高级 CLI 页,写操作需页面令牌与确认);兼容 agentskills.io 技能格式、Vercel Labs skills CLI 的 .agents/skills 通用目录与 .skill-lock.json v3(只读)。路由层:--route / route_request 按需求摘要给出候选组合、调用顺序、角色、置信度、依据、已装/缺失状态与安装命令,只建议不自动安装;非元阁家族已装技能按 frontmatter description 机械匹配作并列候选(标注来源与未扫描状态,只读不自动调用);M1 记忆裁决层:usage enable/mark 本地结构化记录 + decide-memory / MCP decide_memory 输出 promote / hold / demote 只读建议(需授权 provider;只建议不删除、不自动写元忆);策划层:按场景给出「该组合哪几个元技能、组合强在哪、怎么组合使用(安装与调用均由用户确认后执行)」;安装层:一条命令把 YottaMeta 已发布的全部 yotta-* 技能装进指定智能体或目录(默认 --pin 锁死清单精确版本);盘点层:--inventory / --reindex 扫描本机已装技能生成/更新注册表,新装技能自动被发现(install/update 后自动 re-index,会话开工只建议跑本地 --reindex;更新检查走手动 --check 或后台 --check --scheduled);运行时适配层:hook capabilities / evaluate / bind / unbind 按宿主能力矩阵执行六个统一事件并留证降级,元信 before_install 已接入安装管线;自包含零依赖,不依赖任何元技能;MCP 按需加载且需用户确认后写入配置(可选:list_installed_skills/describe_skill/reindex/route_request/decide_memory,不常驻,未加载降级 CLI)。支持 --list 清单 / --route 路由 / usage 使用记录 / decide-memory 记忆裁决 / hub 技能 Hub / install / update / update --check(只读检查)/ update --check --scheduled(后台周检)/ update --auto(家族自动更新)/ hook 适配 / --inventory / --reindex / --dry-run 预览 / --pin(默认)/ --range。触发:需要批量安装或更新元阁全家技能、单点安装并分发到多个智能体、查看本机装了哪些智能体与技能目录、接管非元技能、按场景组合多个元技能、路由或判断该用哪些技能、判断哪些技能值得长期记忆、查看或记录技能使用信号、盘点或查看本机已装技能、重扫技能注册表、给某个智能体或目录一次性铺齐 yotta-* 技能、评估宿主 hook 能力、预览安装清单、锁版本安装、或用户说 元阁/装全家/一次装齐/yotta-skills/install-all/更新全家/检查更新/自动更新/hook 适配/该用哪个技能/路由技能/记忆裁决/技能该不该记住/盘点技能/查看已装技能/技能 Hub/单点安装/链接分发/接管技能/技能枢纽面板/图形化面板 等。边界(Do NOT trigger):只做「组合策划 + 静态路由建议 + M1 记忆裁决只读建议 + 清单 + 下载 + 落位 + 汇总 + 盘点 + re-index + Hub 本机真源与链接分发 + hook 适配」,不含技能本体、不做技能内容开发、不 -g 污染全局、不自动安装缺失技能、不静默写宿主配置或全局记忆、不自动删除技能或记忆;家族安装先自举或调用元信装前门禁,DO NOT INSTALL 阻断,非元阁家族包不自动安装。
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
@@ -162,8 +162,10 @@ Hub 把「每个宿主装一份」改成「本机一份真源,按宿主链接
|
|
|
162
162
|
元技能(清单 27 + 特殊家族 5:元开 / 元伴 / 元呈 / 元阁本体 / 元信MCP)
|
|
163
163
|
在 `hub link` 执行时收敛唯一性:宿主里的同名真目录 / `yotta-X__*`
|
|
164
164
|
重命名副本会先移入回收站(`~/.yottaskills/trash/<时间戳>/`,保留 7 天、
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
输出恢复路径;跨卷场景自动回退「复制 → 校验 → 删除源」),再建立指向
|
|
166
|
+
Hub 的链接;宿主版本高于 Hub 时跳过并提示先
|
|
167
|
+
`hub update`;目标条目为指向 Hub 之外的链接时默认跳过,显式 `--force`
|
|
168
|
+
可替换(仅移除链接本身);外部技能同名冲突仍默认跳过、不自动删、不自动更新。
|
|
167
169
|
|
|
168
170
|
```bash
|
|
169
171
|
# 1) 只读发现本机已装智能体与技能目录(文件系统 + 环境变量;不读元忆 / 注册表)
|
|
@@ -221,7 +223,7 @@ npx -y @yottameta/yotta-skills hub unlink --all
|
|
|
221
223
|
| `hub adopt --scan` | 只读预演:扫描各宿主现有技能,输出收编候选、多副本冲突、已在 Hub 状态 |
|
|
222
224
|
| `hub adopt --apply [--include <a,b>] [--force] [--in-place]` | 把选中技能收编进 Hub;输出明示范围与目标 Hub;默认复制保真 + 原目录保留;非元阁来源标注「无更新源」并对严格 YAML 风险只读告警;`--skip-scan` 显式提示跳过装前扫描 |
|
|
223
225
|
| `hub refresh <slug> --from <path>` | 手动同步非元阁技能(重新收编 + 更新哈希 + 台账) |
|
|
224
|
-
| `hub link --agent <id> \| --dir <dir> \| --all` | 把 Hub 技能链接到指定宿主 / 目录 / 全部已发现宿主;元技能链接时收敛旧副本(回收站 7 天);`--dry-run`
|
|
226
|
+
| `hub link --agent <id> \| --dir <dir> \| --all` | 把 Hub 技能链接到指定宿主 / 目录 / 全部已发现宿主;元技能链接时收敛旧副本(回收站 7 天);`--dry-run` 只预览(含将收敛明细);`--force` 可替换指向 Hub 之外的链接(仅移除链接本身) |
|
|
225
227
|
| `hub unlink --agent <id> \| --dir <dir> \| --all` | 只删除链接;lstat + readlink 校验目标在 Hub 内,fail-closed |
|
|
226
228
|
| `hub status [--json]` | 查看 Hub 技能来源、版本、链接宿主、异常链接与宿主发现摘要 |
|
|
227
229
|
| `hub doctor [--json]` | 检查断链 / Hub 目标缺失 / slug 不一致 / 单一真源(只读报告多份副本与版本参差)/ 目录权限;异常时退出码 1 |
|
|
@@ -55,7 +55,7 @@ const { createInstaller, isSafeTarEntry } = require('../lib/install-pipeline');
|
|
|
55
55
|
const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
|
|
56
56
|
|
|
57
57
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
58
|
-
let VERSION = '0.27.
|
|
58
|
+
let VERSION = '0.27.1';
|
|
59
59
|
try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
|
|
60
60
|
|
|
61
61
|
// @generated view-html:start
|
|
@@ -189,6 +189,161 @@ function hashTree(dir) {
|
|
|
189
189
|
return hash.digest('hex');
|
|
190
190
|
}
|
|
191
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
|
+
|
|
192
347
|
function scanHubSkills(hubDir) {
|
|
193
348
|
const root = path.resolve(hubDir);
|
|
194
349
|
const result = [];
|
|
@@ -491,6 +646,7 @@ function linkSkills(options) {
|
|
|
491
646
|
const results = [];
|
|
492
647
|
const trashRoot = resolveTrashRoot(hubDir, opts);
|
|
493
648
|
const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
|
|
649
|
+
const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
|
|
494
650
|
const stamp = trashRunStamp(opts.now);
|
|
495
651
|
const hostName = safeHostName(opts.label || path.basename(targetDir));
|
|
496
652
|
let prunedTrash = 0;
|
|
@@ -520,6 +676,7 @@ function linkSkills(options) {
|
|
|
520
676
|
const moves = [];
|
|
521
677
|
const skipped = [];
|
|
522
678
|
let block = null;
|
|
679
|
+
let replaceExactLink = false;
|
|
523
680
|
|
|
524
681
|
const evaluate = (copy, isExact) => {
|
|
525
682
|
if (!copy) return;
|
|
@@ -532,7 +689,11 @@ function linkSkills(options) {
|
|
|
532
689
|
return;
|
|
533
690
|
}
|
|
534
691
|
if (isExact) {
|
|
535
|
-
|
|
692
|
+
if (force) {
|
|
693
|
+
replaceExactLink = true;
|
|
694
|
+
} else {
|
|
695
|
+
block = { status: 'conflict', note: '目标已存在指向 Hub 之外的链接(默认跳过;确认后可加 --force 替换)' };
|
|
696
|
+
}
|
|
536
697
|
} else {
|
|
537
698
|
skipped.push(copy.name + '(外部链接,保留)');
|
|
538
699
|
}
|
|
@@ -593,7 +754,8 @@ function linkSkills(options) {
|
|
|
593
754
|
slug,
|
|
594
755
|
status: 'would-converge',
|
|
595
756
|
target,
|
|
596
|
-
note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + '
|
|
757
|
+
note: '将收敛 ' + moves.length + ' 份旧副本(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)' +
|
|
758
|
+
(replaceExactLink ? '并替换指向 Hub 之外的链接' : '') + '后建立链接:' +
|
|
597
759
|
moves.map((item) => item.name).join('、'),
|
|
598
760
|
moves: moves.map((item) => ({ from: item.dir, name: item.name, version: item.version || null })),
|
|
599
761
|
trashDir: trashRoot,
|
|
@@ -601,11 +763,14 @@ function linkSkills(options) {
|
|
|
601
763
|
continue;
|
|
602
764
|
}
|
|
603
765
|
if (moves.length === 0 && dryRun) {
|
|
604
|
-
results.push(
|
|
766
|
+
results.push(replaceExactLink
|
|
767
|
+
? { slug, status: 'would-replace', target, note: '将替换指向 Hub 之外的链接并建立链接' }
|
|
768
|
+
: { slug, status: 'would-link', target, note: '将建立链接' });
|
|
605
769
|
continue;
|
|
606
770
|
}
|
|
607
771
|
if (moves.length === 0) {
|
|
608
772
|
try {
|
|
773
|
+
if (replaceExactLink) removeDirLink(target);
|
|
609
774
|
makeLink(skill.dir, target);
|
|
610
775
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
611
776
|
const record = {
|
|
@@ -624,15 +789,25 @@ function linkSkills(options) {
|
|
|
624
789
|
slug,
|
|
625
790
|
status: 'linked',
|
|
626
791
|
target,
|
|
627
|
-
note:
|
|
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 } : {}),
|
|
628
803
|
});
|
|
629
|
-
appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup: null });
|
|
630
804
|
} catch (error) {
|
|
631
805
|
results.push({
|
|
632
806
|
slug,
|
|
633
807
|
status: 'error',
|
|
634
808
|
target,
|
|
635
|
-
note:
|
|
809
|
+
note: (replaceExactLink ? '替换失败:' : '') + error.message +
|
|
810
|
+
'(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
|
|
636
811
|
});
|
|
637
812
|
}
|
|
638
813
|
continue;
|
|
@@ -642,10 +817,19 @@ function linkSkills(options) {
|
|
|
642
817
|
for (const item of moves) {
|
|
643
818
|
const dest = path.join(trashRoot, stamp, hostName, item.name);
|
|
644
819
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
645
|
-
|
|
646
|
-
moved.push({
|
|
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);
|
|
647
832
|
}
|
|
648
|
-
if (!exactHealthy) makeLink(skill.dir, target);
|
|
649
833
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
650
834
|
const record = {
|
|
651
835
|
slug,
|
|
@@ -660,11 +844,14 @@ function linkSkills(options) {
|
|
|
660
844
|
};
|
|
661
845
|
if (index >= 0) links[index] = record;
|
|
662
846
|
else links.push(record);
|
|
847
|
+
const copiedCount = moved.filter((item) => item.method === 'copy').length;
|
|
663
848
|
results.push({
|
|
664
849
|
slug,
|
|
665
850
|
status: 'linked',
|
|
666
851
|
target,
|
|
667
852
|
note: '已收敛 ' + moved.length + ' 份旧副本(回收站保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接' +
|
|
853
|
+
(replaceExactLink ? '(已替换指向 Hub 之外的链接;仅移除链接本身)' : '') +
|
|
854
|
+
(copiedCount ? '(其中 ' + copiedCount + ' 份跨卷复制,校验通过)' : '') +
|
|
668
855
|
(skipped.length ? ';保留:' + skipped.join('、') : ''),
|
|
669
856
|
moved,
|
|
670
857
|
trashDir: trashRoot,
|
|
@@ -677,13 +864,14 @@ function linkSkills(options) {
|
|
|
677
864
|
hubVersion: skill.version,
|
|
678
865
|
moved,
|
|
679
866
|
trashDir: trashRoot,
|
|
867
|
+
...(replaceExactLink ? { replacedExternalLink: true } : {}),
|
|
680
868
|
});
|
|
681
869
|
} catch (error) {
|
|
682
870
|
let restored = true;
|
|
683
871
|
for (const item of moved.slice().reverse()) {
|
|
684
872
|
try {
|
|
685
873
|
fs.mkdirSync(path.dirname(item.from), { recursive: true });
|
|
686
|
-
|
|
874
|
+
moveEntryAcrossDevices(item.to, item.from, { renameEntry });
|
|
687
875
|
} catch (_) { restored = false; }
|
|
688
876
|
}
|
|
689
877
|
results.push({
|
|
@@ -1009,6 +1197,7 @@ module.exports = {
|
|
|
1009
1197
|
writeLinkState,
|
|
1010
1198
|
appendAudit,
|
|
1011
1199
|
hashTree,
|
|
1200
|
+
moveEntryAcrossDevices,
|
|
1012
1201
|
scanHubSkills,
|
|
1013
1202
|
syncHubState,
|
|
1014
1203
|
familySlugSet,
|
|
@@ -23,7 +23,12 @@ symlink),升级只改真源,已链接宿主即时生效。
|
|
|
23
23
|
2. 版本闸门:Hub 版本 ≥ 宿主副本版本才收敛;宿主更高 → 跳过并提示先 `hub update`;
|
|
24
24
|
版本无法解析 → 跳过提示(fail-safe);
|
|
25
25
|
3. 旧副本移入回收站(保留 7 天、输出恢复路径)→ 建立指向 Hub 的链接;
|
|
26
|
+
跨盘(源目录在其它卷,或经 junction / symlink 落在其它卷)时 `rename`
|
|
27
|
+
会报 `EXDEV`,自动回退「复制 → 校验(treeHash + 文件数 + 字节数)→ 删除源」;
|
|
28
|
+
建链失败后的恢复同样支持跨盘回退,校验不通过不会删除源;
|
|
26
29
|
4. 先移后链;建链失败自动尝试恢复已移动副本,恢复失败时报出回收站路径;
|
|
30
|
+
目标条目为指向 Hub 之外的链接时默认跳过;显式 `--force` 可替换为 Hub 链接
|
|
31
|
+
(仅移除链接本身,目标目录不动;dry-run 报 `would-replace`);
|
|
27
32
|
5. 外部技能保持原行为:同名冲突默认跳过,不自动删、不自动更新。
|
|
28
33
|
|
|
29
34
|
落地后跑一次 `hub link --all` 即可完成现存收敛。`hub doctor` 的
|