@yottameta/yotta-skills-plugin 0.28.3 → 0.29.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.
- 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 +48 -3
- package/skills/yotta-skills/assets/view.html +99 -11
- package/skills/yotta-skills/bin/yotta-skills.js +459 -62
- package/skills/yotta-skills/lib/agent-discovery.js +99 -7
- package/skills/yotta-skills/lib/cli-help.js +31 -1
- package/skills/yotta-skills/lib/host-purge.js +167 -0
- package/skills/yotta-skills/lib/hosts-registry.js +197 -0
- package/skills/yotta-skills/lib/hub-view-server.js +101 -1
- package/skills/yotta-skills/lib/hub.js +16 -0
- package/skills/yotta-skills/lib/install-pipeline.js +19 -0
- package/skills/yotta-skills/lib/self-install.js +224 -0
- package/skills/yotta-skills/references/hub.md +36 -0
- package/skills/yotta-skills/scan-policy.json +2 -2
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const os = require('os');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const agentDirs = require('./agent-dirs');
|
|
18
|
+
const hostsRegistry = require('./hosts-registry');
|
|
18
19
|
|
|
19
20
|
const SKILL_FILE = 'SKILL.md';
|
|
20
21
|
const SKIP_DIRS = new Set([
|
|
@@ -103,6 +104,15 @@ function addRoot(list, seen, dir, meta) {
|
|
|
103
104
|
if (meta && meta.label && existing.label === '自动发现目录') existing.label = meta.label;
|
|
104
105
|
if (meta && meta.verified) existing.verified = true;
|
|
105
106
|
if (meta && meta.bridgeOnly) existing.bridgeOnly = true;
|
|
107
|
+
// 用户注册优先:覆盖分类与手动标记(0.29.0 F2/F3)。
|
|
108
|
+
if (meta && meta.registry) {
|
|
109
|
+
existing.registry = true;
|
|
110
|
+
existing.verified = true;
|
|
111
|
+
existing.detection = 'user-registry';
|
|
112
|
+
if (meta.label) existing.label = meta.label;
|
|
113
|
+
if (meta.agentId) existing.agentId = meta.agentId;
|
|
114
|
+
if (meta.manualState) existing.manualState = meta.manualState;
|
|
115
|
+
}
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
118
|
const record = {
|
|
@@ -115,6 +125,8 @@ function addRoot(list, seen, dir, meta) {
|
|
|
115
125
|
bridgeOnly: Boolean(meta && meta.bridgeOnly),
|
|
116
126
|
detection: (meta && meta.detection) || 'mapping',
|
|
117
127
|
exists: isDirectory(resolved),
|
|
128
|
+
registry: Boolean(meta && meta.registry),
|
|
129
|
+
manualState: (meta && meta.manualState) || null,
|
|
118
130
|
};
|
|
119
131
|
record.skillCount = record.exists ? countSkills(resolved) : 0;
|
|
120
132
|
record.status = record.exists ? 'skills-dir' : 'missing';
|
|
@@ -223,23 +235,65 @@ function installedMarkers(options) {
|
|
|
223
235
|
env['ProgramFiles(x86)'],
|
|
224
236
|
];
|
|
225
237
|
for (const root of roots) collectMarkerNames(root, 2, names, 800);
|
|
226
|
-
|
|
238
|
+
// 标记过滤:已收录宿主的 label 双向匹配 + 启发式关键词;排除卸载器 / 运行时噪音。
|
|
239
|
+
return [...names].filter((name) =>
|
|
240
|
+
(knownLabelMatches(name) || AGENT_HINT.test(name)) && !INSTALLED_NOISE.test(name));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeLabel(value) {
|
|
244
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
227
245
|
}
|
|
228
246
|
|
|
247
|
+
/** 标记名与已收录宿主 label 的双向词边界包含("Trae" ↔ "Trae Code CLI")。 */
|
|
229
248
|
function knownLabelMatches(name) {
|
|
230
|
-
const normalized =
|
|
249
|
+
const normalized = normalizeLabel(name);
|
|
250
|
+
if (!normalized) return false;
|
|
231
251
|
for (const info of Object.values(agentDirs.AGENT_DIRS)) {
|
|
232
|
-
|
|
252
|
+
const label = normalizeLabel(info.label);
|
|
253
|
+
if (!label) continue;
|
|
254
|
+
if (normalized === label) return true;
|
|
255
|
+
if (normalized.startsWith(label + ' ') || normalized.endsWith(' ' + label) || normalized.includes(' ' + label + ' ')) return true;
|
|
256
|
+
if (label.startsWith(normalized + ' ') || label.endsWith(' ' + normalized) || label.includes(' ' + normalized + ' ')) return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 应用标记与宿主匹配:label / agentId 与标记名按词边界双向包含。
|
|
263
|
+
* 例:"Claude" 匹配 "Claude Code";"Trae" 匹配 "Trae Code CLI";
|
|
264
|
+
* 避免 "Pi" 误配 "Pilot" 这类裸子串。
|
|
265
|
+
*/
|
|
266
|
+
function markerMatchesHost(markerName, host) {
|
|
267
|
+
const marker = normalizeLabel(markerName);
|
|
268
|
+
if (!marker) return false;
|
|
269
|
+
const candidates = [host && host.label, host && host.agentId].filter(Boolean).map(normalizeLabel);
|
|
270
|
+
for (const candidate of candidates) {
|
|
271
|
+
if (!candidate) continue;
|
|
272
|
+
if (marker === candidate) return true;
|
|
273
|
+
if (marker.startsWith(candidate + ' ') || marker.endsWith(' ' + candidate) || marker.includes(' ' + candidate + ' ')) return true;
|
|
274
|
+
if (candidate.startsWith(marker + ' ') || candidate.endsWith(' ' + marker) || candidate.includes(' ' + marker + ' ')) return true;
|
|
233
275
|
}
|
|
234
276
|
return false;
|
|
235
277
|
}
|
|
236
278
|
|
|
279
|
+
/** F3 状态判定:目录 × 实体证据(应用标记 / 用户注册 / 手动标记)。 */
|
|
280
|
+
function judgeHostState(host) {
|
|
281
|
+
if (host.manualState) return host.manualState; // 手动覆盖(available / orphan / ignored)
|
|
282
|
+
const entity = Boolean(host.markerEvidence || (host.evidence && host.evidence.registry));
|
|
283
|
+
if (host.exists && entity) return 'available';
|
|
284
|
+
if (host.exists) return 'orphan';
|
|
285
|
+
if (entity) return 'marker-only';
|
|
286
|
+
return 'missing';
|
|
287
|
+
}
|
|
288
|
+
|
|
237
289
|
/**
|
|
238
290
|
* Discover hosts and their skill directories.
|
|
239
291
|
*
|
|
240
292
|
* Returns:
|
|
241
293
|
* hosts - all candidate skill roots (existing and missing known mappings)
|
|
242
|
-
* installed -
|
|
294
|
+
* installed - entity evidence (app markers + user registry), including hosts
|
|
295
|
+
* without a skills dir; plain directory existence is NOT an
|
|
296
|
+
* installed marker (0.29.0 F3)
|
|
243
297
|
* errors - non-fatal discovery errors
|
|
244
298
|
*/
|
|
245
299
|
function discoverHosts(options) {
|
|
@@ -253,6 +307,23 @@ function discoverHosts(options) {
|
|
|
253
307
|
for (const root of agentDirs.envRoots(options)) {
|
|
254
308
|
addRoot(roots, seen, root.dir, { ...root, detection: root.env ? 'env' : 'workspace' });
|
|
255
309
|
}
|
|
310
|
+
// 合并顺序:内置映射 → 环境变量 → 用户注册表(F2)→ 自动发现;
|
|
311
|
+
// 同 realpath 用户注册优先(分类 = 已核实,detection = user-registry)。
|
|
312
|
+
let registry = { hosts: [] };
|
|
313
|
+
try {
|
|
314
|
+
registry = hostsRegistry.readHostsRegistry(options);
|
|
315
|
+
} catch (_) { /* registry 读取失败不影响发现 */ }
|
|
316
|
+
for (const item of registry.hosts || []) {
|
|
317
|
+
addRoot(roots, seen, item.dir, {
|
|
318
|
+
detection: 'user-registry',
|
|
319
|
+
label: item.label || '自定义宿主',
|
|
320
|
+
agentId: item.agentId || null,
|
|
321
|
+
verified: true,
|
|
322
|
+
known: false,
|
|
323
|
+
registry: true,
|
|
324
|
+
manualState: item.manualState || null,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
256
327
|
for (const root of commonRoots(options)) {
|
|
257
328
|
for (const found of scanForSkillDirs(root.dir, {
|
|
258
329
|
maxDepth: root.maxDepth,
|
|
@@ -265,6 +336,22 @@ function discoverHosts(options) {
|
|
|
265
336
|
}
|
|
266
337
|
}
|
|
267
338
|
|
|
339
|
+
// F3:目录证据与实体证据分开记录,判定生命周期状态。
|
|
340
|
+
const markers = installedMarkers(options);
|
|
341
|
+
for (const host of roots) {
|
|
342
|
+
const markerEvidence = markers.some((name) => markerMatchesHost(name, host));
|
|
343
|
+
host.markerEvidence = markerEvidence;
|
|
344
|
+
host.dirEvidence = host.exists;
|
|
345
|
+
host.evidence = {
|
|
346
|
+
dir: host.exists,
|
|
347
|
+
marker: markerEvidence,
|
|
348
|
+
registry: Boolean(host.registry),
|
|
349
|
+
manual: Boolean(host.manualState),
|
|
350
|
+
};
|
|
351
|
+
host.stateSource = host.manualState ? 'manual' : 'auto';
|
|
352
|
+
host.state = judgeHostState(host);
|
|
353
|
+
}
|
|
354
|
+
|
|
268
355
|
const installed = [];
|
|
269
356
|
const installedSeen = new Set();
|
|
270
357
|
const addInstalled = (name, source) => {
|
|
@@ -275,13 +362,16 @@ function discoverHosts(options) {
|
|
|
275
362
|
installedSeen.add(key);
|
|
276
363
|
installed.push({ label, source, hasSkillsDir: false });
|
|
277
364
|
};
|
|
278
|
-
for (const name of
|
|
365
|
+
for (const name of markers) {
|
|
279
366
|
if (knownLabelMatches(name) || AGENT_HINT.test(name)) addInstalled(name, '应用标记');
|
|
280
367
|
}
|
|
368
|
+
for (const host of roots) {
|
|
369
|
+
if (host.detection === 'user-registry') addInstalled(host.label, '用户注册');
|
|
370
|
+
}
|
|
281
371
|
for (const host of roots) {
|
|
282
372
|
if (!host.exists) continue;
|
|
283
|
-
|
|
284
|
-
const item = installed.find((entry) => entry.label
|
|
373
|
+
if (!host.markerEvidence && host.detection !== 'user-registry') continue;
|
|
374
|
+
const item = installed.find((entry) => markerMatchesHost(entry.label, host));
|
|
285
375
|
if (item) {
|
|
286
376
|
item.hasSkillsDir = true;
|
|
287
377
|
item.skillDir = host.dir;
|
|
@@ -303,4 +393,6 @@ module.exports = {
|
|
|
303
393
|
discoverHosts,
|
|
304
394
|
scanForSkillDirs,
|
|
305
395
|
countSkills,
|
|
396
|
+
markerMatchesHost,
|
|
397
|
+
judgeHostState,
|
|
306
398
|
};
|
|
@@ -44,6 +44,10 @@ const CLI_GLOBAL_OPTIONS = [
|
|
|
44
44
|
{ flag: '--port', arg: '<n>', what: 'view 面板端口(默认 8789)', when: '端口冲突或想固定端口时', caution: '仅 127.0.0.1 监听' },
|
|
45
45
|
{ flag: '--all', arg: '', what: 'hub link / unlink 作用于全部已核实宿主', when: '全量分发或解除时', caution: '' },
|
|
46
46
|
{ flag: '--include-discovered', arg: '', what: 'hub link / unlink / remove 时纳入自动发现目录', when: '确认要扩围到未核实目录时', caution: '默认只链已核实宿主;桥接目录永不链接' },
|
|
47
|
+
{ flag: '--state', arg: '<state>', what: 'hub hosts 状态过滤(available / orphan / missing / marker-only / ignored);hub hosts mark 时为手动状态', when: '只看某类宿主、或手动标记宿主状态时', caution: '' },
|
|
48
|
+
{ flag: '--label', arg: '<名称>', what: 'hub hosts add 的显示名称(默认取目录名)', when: '注册自定义宿主时', caution: '最长 48 字符,禁控制字符' },
|
|
49
|
+
{ flag: '--id', arg: '<agentId>', what: 'hub hosts add / remove 的智能体标识', when: '按智能体注册或移除自定义宿主时', caution: '' },
|
|
50
|
+
{ flag: '--purge', arg: '', what: 'hub hosts remove 清理残留目录(默认预览;--yes 执行)', when: '清理实体未确认的残留技能目录时', caution: '只删指向 Hub 的链接;目录移入回收站保留 7 天' },
|
|
47
51
|
{ flag: '--scan', arg: '', what: 'hub adopt 只读预演(默认行为)', when: '先看收编候选与冲突时', caution: '' },
|
|
48
52
|
{ flag: '--apply', arg: '', what: 'hub adopt 执行收编', when: '确认候选后执行时', caution: '执行时逐项运行元信扫描;high / critical 默认阻断' },
|
|
49
53
|
{ flag: '--in-place', arg: '', what: 'hub adopt 原地登记,不复制', when: '想让 Hub 直接指向原目录时', caution: '原目录删除即断链;默认复制保真更安全' },
|
|
@@ -85,6 +89,20 @@ const CLI_HELP_MODEL = [
|
|
|
85
89
|
when: '执行前先确认影响面时',
|
|
86
90
|
options: ['--dry-run'],
|
|
87
91
|
},
|
|
92
|
+
{
|
|
93
|
+
name: 'install-self',
|
|
94
|
+
usage: 'install-self [--dir <path>] [--dry-run] [--force] [--json]',
|
|
95
|
+
what: '元阁独立安装:把管理引擎装到独立位置(默认 ~/.yottaskills/yotta-skills),与 Hub / 宿主分离',
|
|
96
|
+
when: '想把元阁装成独立 CLI(不依赖任何智能体)时',
|
|
97
|
+
options: ['--dir', '--dry-run', '--force', '--json'],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'where',
|
|
101
|
+
usage: 'where [--json]',
|
|
102
|
+
what: '查看元阁位置:运行中 CLI / 独立安装目录(标记当前运行项)/ Hub 真源',
|
|
103
|
+
when: '确认元阁装在哪、是否独立安装时',
|
|
104
|
+
options: ['--json', '--hub'],
|
|
105
|
+
},
|
|
88
106
|
],
|
|
89
107
|
},
|
|
90
108
|
{
|
|
@@ -156,7 +174,11 @@ const CLI_HELP_MODEL = [
|
|
|
156
174
|
when: '多智能体共享技能、统一管理技能目录时',
|
|
157
175
|
options: ['--hub', '--all', '--include-discovered', '--dry-run', '--json'],
|
|
158
176
|
subcommands: [
|
|
159
|
-
{ name: 'hosts', usage: 'hub hosts [--json]', what: '
|
|
177
|
+
{ name: 'hosts', usage: 'hub hosts [--json] [--state <state>]', what: '发现宿主与技能目录(可用 / 残留 / 未创建 / 仅标记 / 桥接)', when: '先看这台机器有哪些宿主目录时', options: ['--json', '--state'] },
|
|
178
|
+
{ name: 'hosts add', usage: 'hub hosts add <dir> [--label <名称>] [--id <agentId>] [--json]', what: '注册自定义宿主目录(纳入发现 / 分发 / 收编;不创建目录)', when: '宿主未被自动识别或识别错误时', options: ['--label', '--id', '--json'] },
|
|
179
|
+
{ name: 'hosts remove', usage: 'hub hosts remove <dir | --id <agentId>> [--purge [--yes]] [--json]', what: '移除注册(绝不删目录);--purge = 清理残留目录(默认预览,--yes 执行)', when: '注销自定义宿主、清理残留目录时', options: ['--id', '--purge', '--yes', '--json'] },
|
|
180
|
+
{ name: 'hosts list', usage: 'hub hosts list [--json]', what: '查看自定义宿主注册表(hosts.json)', when: '核对已注册的自定义目录时', options: ['--json'] },
|
|
181
|
+
{ name: 'hosts mark', usage: 'hub hosts mark <dir> --state <available|orphan|ignored> [--json]', what: '手动标记宿主状态(可用 / 残留 / 忽略)', when: '实体证据不足需要人工确认时', options: ['--state', '--json'] },
|
|
160
182
|
{ name: 'install', usage: 'hub install [skill...]', what: '把元技能装进 Hub 真源(清单 27 + 特殊家族 5)', when: '初始化 Hub 或补装技能时', options: ['--hub'] },
|
|
161
183
|
{ name: 'update', usage: 'hub update', what: '更新 Hub 内的元技能(非元技能不参与)', when: '日常维护 Hub 真源时', options: ['--hub'] },
|
|
162
184
|
{ name: 'adopt', usage: 'hub adopt [--scan | --apply] [--include <a,b>] [--in-place]', what: '把各宿主已有技能收编进 Hub(默认复制保真)', when: '接管机器上已装的技能时', options: ['--scan', '--apply', '--include', '--in-place', '--force', '--skip-scan', '--allow-unverified'] },
|
|
@@ -240,6 +262,8 @@ const CLI_HELP_QUICK = [
|
|
|
240
262
|
{ cmd: 'update', example: 'yotta-skills update --check' },
|
|
241
263
|
{ cmd: 'update', example: 'yotta-skills update --auto' },
|
|
242
264
|
{ cmd: '--dry-run', example: 'yotta-skills install --dry-run --agent codex' },
|
|
265
|
+
{ cmd: 'install-self', example: 'yotta-skills install-self' },
|
|
266
|
+
{ cmd: 'where', example: 'yotta-skills where' },
|
|
243
267
|
],
|
|
244
268
|
},
|
|
245
269
|
{
|
|
@@ -265,6 +289,12 @@ const CLI_HELP_QUICK = [
|
|
|
265
289
|
items: [
|
|
266
290
|
{ cmd: 'hub', example: 'yotta-skills hub' },
|
|
267
291
|
{ cmd: 'hub hosts', example: 'yotta-skills hub hosts' },
|
|
292
|
+
{ cmd: 'hub hosts', example: 'yotta-skills hub hosts --state orphan' },
|
|
293
|
+
{ cmd: 'hub hosts add', example: 'yotta-skills hub hosts add <技能目录>' },
|
|
294
|
+
{ cmd: 'hub hosts list', example: 'yotta-skills hub hosts list' },
|
|
295
|
+
{ cmd: 'hub hosts mark', example: 'yotta-skills hub hosts mark <技能目录> --state orphan' },
|
|
296
|
+
{ cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录>', note: '只移除注册,不删目录' },
|
|
297
|
+
{ cmd: 'hub hosts remove', example: 'yotta-skills hub hosts remove <技能目录> --purge', danger: true, note: '清理残留目录:先预览,--yes 才执行;目录入回收站保留 7 天' },
|
|
268
298
|
{ cmd: 'hub install', example: 'yotta-skills hub install' },
|
|
269
299
|
{ cmd: 'hub update', example: 'yotta-skills hub update' },
|
|
270
300
|
{ cmd: 'hub adopt', example: 'yotta-skills hub adopt --scan' },
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* 残留目录清理核心(0.29.0 F3):CLI 与面板共用。
|
|
4
|
+
*
|
|
5
|
+
* 只处理 state=orphan 的宿主目录 —— 全量 unlink(只删指向 Hub 的链接,含死链)
|
|
6
|
+
* → 目录移入回收站(保留 7 天,跨卷走复制 + 校验 + 删源)→ 清注册 / 链接台账
|
|
7
|
+
* → 写审计。非 Hub 链接 / 非链接内容保留并报告。
|
|
8
|
+
*
|
|
9
|
+
* fail-closed:Hub 真源 / 子目录、锁与数据桥接目录、非残留状态(可用 / 仅标记 /
|
|
10
|
+
* 未创建 / 已忽略)一律拒绝;需要人工确认时先 hub hosts mark --state orphan。
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const hubLib = require('./hub');
|
|
16
|
+
const agentDirsLib = require('./agent-dirs');
|
|
17
|
+
const agentDiscoveryLib = require('./agent-discovery');
|
|
18
|
+
const hostsRegistryLib = require('./hosts-registry');
|
|
19
|
+
|
|
20
|
+
function purgeStamp(now) {
|
|
21
|
+
const d = now instanceof Date ? now : new Date();
|
|
22
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
23
|
+
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' +
|
|
24
|
+
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '-' + process.pid;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeOrphanName(value) {
|
|
28
|
+
const cleaned = String(value || '')
|
|
29
|
+
.replace(/[\\/:*?"<>|\s]+/g, '-')
|
|
30
|
+
.replace(/^-+|-+$/g, '');
|
|
31
|
+
return cleaned || 'host';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pathKey(dir) {
|
|
35
|
+
let real = path.resolve(dir);
|
|
36
|
+
try {
|
|
37
|
+
real = fs.realpathSync.native(real);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
try { real = fs.realpathSync(real); } catch (_) { /* keep resolved */ }
|
|
40
|
+
}
|
|
41
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isInside(root, target) {
|
|
45
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
46
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 只读预检 + 清理计划(不写任何文件)。 */
|
|
50
|
+
function planHostPurge(options) {
|
|
51
|
+
const opts = options || {};
|
|
52
|
+
const hubDir = path.resolve(opts.hubDir);
|
|
53
|
+
const target = path.resolve(opts.dir);
|
|
54
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
55
|
+
const env = opts.env || process.env;
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(target)) {
|
|
58
|
+
return { ok: false, code: 400, error: '目录不存在: ' + target };
|
|
59
|
+
}
|
|
60
|
+
if (isInside(hubDir, target)) {
|
|
61
|
+
return { ok: false, code: 400, error: '该目录不允许残留清理(Hub 真源 / 子目录): ' + target };
|
|
62
|
+
}
|
|
63
|
+
if (agentDirsLib.isBridgeOnlyDir(target, { homeDir, env })) {
|
|
64
|
+
return { ok: false, code: 400, error: '锁 / 数据桥接目录不参与残留清理: ' + target };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const discovery = opts.discovery || agentDiscoveryLib.discoverHosts({ homeDir, env });
|
|
68
|
+
const host = discovery.hosts.find((item) => pathKey(item.dir) === pathKey(target));
|
|
69
|
+
const state = host ? host.state : null;
|
|
70
|
+
if (state !== 'orphan') {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: 400,
|
|
74
|
+
state,
|
|
75
|
+
error: '目标不是「残留(实体未确认)」状态' + (state ? '(当前: ' + state + ')' : '(不在发现列表)'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const linkSlugs = [];
|
|
80
|
+
let keptLinks = 0;
|
|
81
|
+
let keptEntries = 0;
|
|
82
|
+
let entries = [];
|
|
83
|
+
try { entries = fs.readdirSync(target, { withFileTypes: true }); } catch (_) { entries = []; }
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const full = path.join(target, entry.name);
|
|
86
|
+
const cls = hubLib.classifyTarget(full, hubDir);
|
|
87
|
+
if (cls.kind === 'link') {
|
|
88
|
+
if (cls.inHub) linkSlugs.push(entry.name);
|
|
89
|
+
else keptLinks++;
|
|
90
|
+
} else if (cls.kind !== 'missing') {
|
|
91
|
+
keptEntries++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const trashRoot = hubLib.resolveTrashRoot(hubDir, opts);
|
|
95
|
+
const label = host && host.label ? host.label : path.basename(target);
|
|
96
|
+
const trashPath = path.join(trashRoot, purgeStamp(opts.now), 'orphan-' + safeOrphanName(label));
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
hubDir,
|
|
100
|
+
target,
|
|
101
|
+
state,
|
|
102
|
+
label,
|
|
103
|
+
linkSlugs,
|
|
104
|
+
keptLinks,
|
|
105
|
+
keptEntries,
|
|
106
|
+
trashRoot,
|
|
107
|
+
trashPath,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 执行清理:unlink → trash → 清注册 / 台账 → 审计。 */
|
|
112
|
+
function applyHostPurge(options) {
|
|
113
|
+
const opts = options || {};
|
|
114
|
+
const plan = planHostPurge(opts);
|
|
115
|
+
if (!plan.ok) return plan;
|
|
116
|
+
|
|
117
|
+
const unlinkResult = hubLib.unlinkSkills({
|
|
118
|
+
hubDir: plan.hubDir,
|
|
119
|
+
targetDir: plan.target,
|
|
120
|
+
slugs: plan.linkSlugs,
|
|
121
|
+
dryRun: false,
|
|
122
|
+
});
|
|
123
|
+
const unlinkedCount = (unlinkResult.results || [])
|
|
124
|
+
.filter((item) => item.status === 'unlinked').length;
|
|
125
|
+
|
|
126
|
+
fs.mkdirSync(path.dirname(plan.trashPath), { recursive: true });
|
|
127
|
+
hubLib.moveEntryAcrossDevices(plan.target, plan.trashPath);
|
|
128
|
+
try {
|
|
129
|
+
hubLib.pruneTrash(plan.trashRoot, { days: hubLib.TRASH_RETENTION_DAYS });
|
|
130
|
+
} catch (_) { /* best-effort */ }
|
|
131
|
+
|
|
132
|
+
const registryOpts = { homeDir: opts.homeDir, env: opts.env, hub: opts.hub };
|
|
133
|
+
const registry = hostsRegistryLib.readHostsRegistry(registryOpts);
|
|
134
|
+
if (registry.hosts.some((item) => pathKey(item.dir) === pathKey(plan.target))) {
|
|
135
|
+
hostsRegistryLib.removeHost(registryOpts, { dir: plan.target });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
hubLib.appendAudit(plan.hubDir, {
|
|
140
|
+
event: 'hosts.purge',
|
|
141
|
+
dir: plan.target,
|
|
142
|
+
label: plan.label,
|
|
143
|
+
state: plan.state,
|
|
144
|
+
trashedTo: plan.trashPath,
|
|
145
|
+
unlinked: unlinkedCount,
|
|
146
|
+
keptLinks: plan.keptLinks,
|
|
147
|
+
keptEntries: plan.keptEntries,
|
|
148
|
+
});
|
|
149
|
+
} catch (_) { /* 审计失败不阻断 */ }
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
dir: plan.target,
|
|
154
|
+
state: plan.state,
|
|
155
|
+
unlinkCount: unlinkedCount,
|
|
156
|
+
keptLinks: plan.keptLinks,
|
|
157
|
+
keptEntries: plan.keptEntries,
|
|
158
|
+
trashedTo: plan.trashPath,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
planHostPurge,
|
|
164
|
+
applyHostPurge,
|
|
165
|
+
purgeStamp,
|
|
166
|
+
safeOrphanName,
|
|
167
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* 用户级自定义宿主注册表(0.29.0 F2)—— <root>/hosts.json。
|
|
4
|
+
*
|
|
5
|
+
* root = YOTTA_SKILLS_HOME 或 ~/.yottaskills(与 self.json 同根,独立于 Hub 目录)。
|
|
6
|
+
* schema v1:{ version, hosts:[{ dir, label, agentId, scope:"verified", note,
|
|
7
|
+
* addedAt, source:"manual", manualState? }] }
|
|
8
|
+
*
|
|
9
|
+
* 注册只影响发现 / 分发 / 收编范围:不创建目录、不改宿主配置;
|
|
10
|
+
* 移除注册绝不删除目录。
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const hubLib = require('./hub');
|
|
16
|
+
const agentDirsLib = require('./agent-dirs');
|
|
17
|
+
|
|
18
|
+
const HOSTS_REGISTRY_VERSION = 1;
|
|
19
|
+
const LABEL_MAX_LENGTH = 48;
|
|
20
|
+
const MANUAL_STATES = ['available', 'orphan', 'ignored'];
|
|
21
|
+
|
|
22
|
+
function nowIso() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveSkillsRoot(opts) {
|
|
27
|
+
const options = opts || {};
|
|
28
|
+
const env = options.env || process.env;
|
|
29
|
+
const explicit = options.skillsRoot || env.YOTTA_SKILLS_HOME;
|
|
30
|
+
if (explicit) return path.resolve(explicit);
|
|
31
|
+
return path.join(options.homeDir || os.homedir(), '.yottaskills');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hostsRegistryPath(opts) {
|
|
35
|
+
return path.join(resolveSkillsRoot(opts), 'hosts.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readHostsRegistry(opts) {
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(fs.readFileSync(hostsRegistryPath(opts), 'utf8'));
|
|
41
|
+
if (value && typeof value === 'object' && Array.isArray(value.hosts)) {
|
|
42
|
+
return {
|
|
43
|
+
version: HOSTS_REGISTRY_VERSION,
|
|
44
|
+
hosts: value.hosts.filter((item) => item && typeof item.dir === 'string' && item.dir),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
} catch (_) { /* missing or corrupt registry falls back to empty */ }
|
|
48
|
+
return { version: HOSTS_REGISTRY_VERSION, hosts: [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeHostsRegistry(opts, registry) {
|
|
52
|
+
const file = hostsRegistryPath(opts);
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
55
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
56
|
+
version: HOSTS_REGISTRY_VERSION,
|
|
57
|
+
hosts: registry.hosts,
|
|
58
|
+
}, null, 2) + '\n', 'utf8');
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeRealpath(target) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.realpathSync.native(target);
|
|
66
|
+
} catch (_) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.realpathSync(target);
|
|
69
|
+
} catch (_) {
|
|
70
|
+
return path.resolve(target);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function samePath(left, right) {
|
|
76
|
+
const a = safeRealpath(left);
|
|
77
|
+
const b = safeRealpath(right);
|
|
78
|
+
if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
|
|
79
|
+
return a === b;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isInside(root, target) {
|
|
83
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
84
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** fail-closed 校验:返回中文错误或 null。 */
|
|
88
|
+
function validateHostDir(dir, opts) {
|
|
89
|
+
const target = path.resolve(dir);
|
|
90
|
+
let stat;
|
|
91
|
+
try {
|
|
92
|
+
stat = fs.statSync(target);
|
|
93
|
+
} catch (_) {
|
|
94
|
+
return '目录不存在:' + target;
|
|
95
|
+
}
|
|
96
|
+
if (!stat.isDirectory()) return '不是目录:' + target;
|
|
97
|
+
const hubDir = hubLib.resolveHubDir(opts);
|
|
98
|
+
if (samePath(target, hubDir) || isInside(hubDir, target)) {
|
|
99
|
+
return '指向 Hub 自身或子目录,不允许注册:' + target;
|
|
100
|
+
}
|
|
101
|
+
if (agentDirsLib.isBridgeOnlyDir(target, opts)) {
|
|
102
|
+
return '锁 / 数据桥接目录不允许注册:' + target;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateLabel(label) {
|
|
108
|
+
const value = String(label || '').trim();
|
|
109
|
+
if (!value) return 'label 不能为空';
|
|
110
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) return 'label 含控制字符';
|
|
111
|
+
if (value.length > LABEL_MAX_LENGTH) return 'label 过长(最多 ' + LABEL_MAX_LENGTH + ' 字符)';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 注册自定义宿主目录(fail-closed;不创建目录、不改宿主配置)。 */
|
|
116
|
+
function addHost(opts, input) {
|
|
117
|
+
const options = opts || {};
|
|
118
|
+
const dir = input && input.dir ? path.resolve(input.dir) : null;
|
|
119
|
+
if (!dir) return { ok: false, error: '缺少目录:hub hosts add <dir>' };
|
|
120
|
+
const guard = validateHostDir(dir, options);
|
|
121
|
+
if (guard) return { ok: false, error: guard };
|
|
122
|
+
const label = String((input && input.label) || path.basename(dir)).trim();
|
|
123
|
+
const labelError = validateLabel(label);
|
|
124
|
+
if (labelError) return { ok: false, error: labelError };
|
|
125
|
+
const agentId = input && input.agentId ? String(input.agentId).trim().toLowerCase() : null;
|
|
126
|
+
|
|
127
|
+
const registry = readHostsRegistry(options);
|
|
128
|
+
const existing = registry.hosts.find((item) => samePath(item.dir, dir));
|
|
129
|
+
if (existing) {
|
|
130
|
+
return { ok: false, error: '该目录已注册:' + dir, existing };
|
|
131
|
+
}
|
|
132
|
+
const entry = {
|
|
133
|
+
dir,
|
|
134
|
+
label,
|
|
135
|
+
agentId,
|
|
136
|
+
scope: 'verified',
|
|
137
|
+
note: (input && input.note) || null,
|
|
138
|
+
addedAt: nowIso(),
|
|
139
|
+
source: 'manual',
|
|
140
|
+
};
|
|
141
|
+
registry.hosts.push(entry);
|
|
142
|
+
writeHostsRegistry(options, registry);
|
|
143
|
+
return { ok: true, entry, registry };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 移除注册(只移除登记,绝不删除目录)。 */
|
|
147
|
+
function removeHost(opts, target) {
|
|
148
|
+
const options = opts || {};
|
|
149
|
+
const registry = readHostsRegistry(options);
|
|
150
|
+
const index = registry.hosts.findIndex((item) =>
|
|
151
|
+
(target && target.dir && samePath(item.dir, target.dir)) ||
|
|
152
|
+
(target && target.id && String(item.agentId || '').toLowerCase() === String(target.id).toLowerCase()));
|
|
153
|
+
if (index < 0) return { ok: false, error: '未找到注册项' };
|
|
154
|
+
const removed = registry.hosts.splice(index, 1)[0];
|
|
155
|
+
writeHostsRegistry(options, registry);
|
|
156
|
+
return { ok: true, removed, registry };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 手动标记宿主状态(可用 / 残留 / 忽略);条目不存在时先注册。 */
|
|
160
|
+
function markHost(opts, input) {
|
|
161
|
+
const options = opts || {};
|
|
162
|
+
const state = input && input.state ? String(input.state).toLowerCase() : null;
|
|
163
|
+
if (!MANUAL_STATES.includes(state)) {
|
|
164
|
+
return { ok: false, error: '未知手动状态:' + state + '(可用 available / 残留 orphan / 忽略 ignored)' };
|
|
165
|
+
}
|
|
166
|
+
const dir = input && input.dir ? path.resolve(input.dir) : null;
|
|
167
|
+
if (!dir) return { ok: false, error: '缺少目录' };
|
|
168
|
+
const registry = readHostsRegistry(options);
|
|
169
|
+
let entry = registry.hosts.find((item) => samePath(item.dir, dir));
|
|
170
|
+
if (!entry) {
|
|
171
|
+
const added = addHost(options, { dir, label: input.label });
|
|
172
|
+
if (!added.ok) return added;
|
|
173
|
+
const refreshed = readHostsRegistry(options);
|
|
174
|
+
entry = refreshed.hosts.find((item) => samePath(item.dir, dir));
|
|
175
|
+
entry.manualState = state;
|
|
176
|
+
writeHostsRegistry(options, refreshed);
|
|
177
|
+
return { ok: true, entry, registry: refreshed };
|
|
178
|
+
}
|
|
179
|
+
entry.manualState = state;
|
|
180
|
+
writeHostsRegistry(options, registry);
|
|
181
|
+
return { ok: true, entry, registry };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
HOSTS_REGISTRY_VERSION,
|
|
186
|
+
MANUAL_STATES,
|
|
187
|
+
LABEL_MAX_LENGTH,
|
|
188
|
+
resolveSkillsRoot,
|
|
189
|
+
hostsRegistryPath,
|
|
190
|
+
readHostsRegistry,
|
|
191
|
+
writeHostsRegistry,
|
|
192
|
+
validateHostDir,
|
|
193
|
+
validateLabel,
|
|
194
|
+
addHost,
|
|
195
|
+
removeHost,
|
|
196
|
+
markHost,
|
|
197
|
+
};
|