@yottameta/yotta-skills-plugin 0.28.2 → 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 +64 -4
- package/skills/yotta-skills/assets/view.html +214 -50
- package/skills/yotta-skills/bin/yotta-skills.js +580 -139
- package/skills/yotta-skills/lib/agent-discovery.js +99 -7
- package/skills/yotta-skills/lib/cli-help.js +343 -0
- 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 +175 -1
- package/skills/yotta-skills/lib/hub.js +288 -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 +66 -2
- package/skills/yotta-skills/references/skill-list.md +2 -2
- package/skills/yotta-skills/scan-policy.json +3 -3
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
- package/skills/yotta-skills/skills.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -32,6 +32,9 @@ const routeFeaturesLib = require('./route-features');
|
|
|
32
32
|
const routeDynamicLib = require('./route-dynamic');
|
|
33
33
|
const providerLib = require('./provider');
|
|
34
34
|
const usageLib = require('./usage-journal');
|
|
35
|
+
const cliHelpLib = require('./cli-help');
|
|
36
|
+
const hostsRegistryLib = require('./hosts-registry');
|
|
37
|
+
const hostPurgeLib = require('./host-purge');
|
|
35
38
|
|
|
36
39
|
const DEFAULT_HOST = '127.0.0.1';
|
|
37
40
|
const DEFAULT_PORT = 8789;
|
|
@@ -41,7 +44,7 @@ const DEFAULT_LIMIT = 100;
|
|
|
41
44
|
const MAX_LIMIT = 500;
|
|
42
45
|
const TOKEN_HEADER = 'x-yotta-view-token';
|
|
43
46
|
const TOKEN_PLACEHOLDER = '__YOTTA_VIEW_TOKEN__';
|
|
44
|
-
const CONFIRM = { unlink: 'unlink', rollback: 'rollback' };
|
|
47
|
+
const CONFIRM = { unlink: 'unlink', rollback: 'rollback', remove: 'remove', purge: 'purge' };
|
|
45
48
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
46
49
|
|
|
47
50
|
function nowIso() {
|
|
@@ -218,6 +221,14 @@ function hostsPayload(ctx) {
|
|
|
218
221
|
installedMarks: found.installed.length,
|
|
219
222
|
links: links.length,
|
|
220
223
|
brokenLinks: links.filter((link) => link.status !== 'ok').length,
|
|
224
|
+
// 0.29.0 F3:宿主生命周期状态细分(面板分组 / 筛选)。
|
|
225
|
+
states: {
|
|
226
|
+
available: hosts.filter((host) => host.state === 'available').length,
|
|
227
|
+
orphan: hosts.filter((host) => host.state === 'orphan').length,
|
|
228
|
+
missing: hosts.filter((host) => host.state === 'missing').length,
|
|
229
|
+
markerOnly: hosts.filter((host) => host.state === 'marker-only').length,
|
|
230
|
+
ignored: hosts.filter((host) => host.state === 'ignored').length,
|
|
231
|
+
},
|
|
221
232
|
},
|
|
222
233
|
};
|
|
223
234
|
}
|
|
@@ -372,6 +383,65 @@ function restoreFromSnapshot(ctx, slug, snapshotPath) {
|
|
|
372
383
|
};
|
|
373
384
|
}
|
|
374
385
|
|
|
386
|
+
/**
|
|
387
|
+
* 面板删除技能的目标范围 = 已核实宿主 ∪ 本技能链接台账(与 CLI 默认范围一致)。
|
|
388
|
+
*/
|
|
389
|
+
function removeTargets(ctx, slug) {
|
|
390
|
+
const found = discovery(ctx);
|
|
391
|
+
const targets = [];
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
const add = (dir, label, agentId) => {
|
|
394
|
+
if (!dir) return;
|
|
395
|
+
const resolved = path.resolve(dir);
|
|
396
|
+
const key = normalizeDir(resolved);
|
|
397
|
+
if (seen.has(key)) return;
|
|
398
|
+
seen.add(key);
|
|
399
|
+
targets.push({ dir: resolved, label: label || '指定目录', agentId: agentId || null });
|
|
400
|
+
};
|
|
401
|
+
for (const host of found.hosts || []) {
|
|
402
|
+
if (!host.exists || host.bridgeOnly || !host.verified) continue;
|
|
403
|
+
add(host.dir, host.label, host.agentId);
|
|
404
|
+
}
|
|
405
|
+
for (const item of hubLib.readLinkState(ctx.hubDir).links.filter((link) => link.slug === slug)) {
|
|
406
|
+
add(item.dir, item.label || '台账记录目录', item.agent);
|
|
407
|
+
}
|
|
408
|
+
return targets;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function removePlanPayload(ctx, slug) {
|
|
412
|
+
return hubLib.removeSkills({
|
|
413
|
+
hubDir: ctx.hubDir,
|
|
414
|
+
slug,
|
|
415
|
+
targets: removeTargets(ctx, slug),
|
|
416
|
+
dryRun: true,
|
|
417
|
+
manifest: ctx.manifest,
|
|
418
|
+
homeDir: ctx.homeDir,
|
|
419
|
+
env: ctx.env,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function writeRemove(ctx, body) {
|
|
424
|
+
if (String(body.confirm || '') !== CONFIRM.remove) {
|
|
425
|
+
return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
|
|
426
|
+
}
|
|
427
|
+
const slug = String(body.slug || '').trim().toLowerCase();
|
|
428
|
+
if (!SLUG_RE.test(slug)) return { code: 400, payload: { error: '技能 slug 非法。' } };
|
|
429
|
+
if (String(body.confirmSlug || '') !== slug) {
|
|
430
|
+
return { code: 400, payload: { error: '技能名确认不匹配;请输入完整 slug。' } };
|
|
431
|
+
}
|
|
432
|
+
const payload = hubLib.removeSkills({
|
|
433
|
+
hubDir: ctx.hubDir,
|
|
434
|
+
slug,
|
|
435
|
+
targets: removeTargets(ctx, slug),
|
|
436
|
+
dryRun: false,
|
|
437
|
+
manifest: ctx.manifest,
|
|
438
|
+
homeDir: ctx.homeDir,
|
|
439
|
+
env: ctx.env,
|
|
440
|
+
});
|
|
441
|
+
const code = payload.verdict === 'failed' ? 500 : payload.verdict === 'not-found' ? 404 : 200;
|
|
442
|
+
return { code, payload };
|
|
443
|
+
}
|
|
444
|
+
|
|
375
445
|
function writeAdopt(ctx, body) {
|
|
376
446
|
if (body.skipScan || body.allowUnverified) {
|
|
377
447
|
return {
|
|
@@ -465,6 +535,74 @@ function writeRollback(ctx, body) {
|
|
|
465
535
|
return { code: result.error ? (result.code || 400) : 200, payload: result };
|
|
466
536
|
}
|
|
467
537
|
|
|
538
|
+
// ── 宿主注册与残留清理(0.29.0 F2/F3) ─────────────────────────────────────
|
|
539
|
+
function auditViewHostEvent(ctx, entry) {
|
|
540
|
+
try {
|
|
541
|
+
if (fs.existsSync(ctx.hubDir)) hubLib.appendAudit(ctx.hubDir, entry);
|
|
542
|
+
} catch (_) { /* 审计失败不阻断主流程 */ }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function writeHostAdd(ctx, body) {
|
|
546
|
+
const dir = String(body.dir || '').trim();
|
|
547
|
+
if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
|
|
548
|
+
const label = String(body.label || '').trim();
|
|
549
|
+
const result = hostsRegistryLib.addHost(
|
|
550
|
+
{ homeDir: ctx.homeDir, env: ctx.env },
|
|
551
|
+
{ dir, label: label || undefined },
|
|
552
|
+
);
|
|
553
|
+
if (!result.ok) return { code: 400, payload: { error: result.error } };
|
|
554
|
+
auditViewHostEvent(ctx, {
|
|
555
|
+
event: 'hosts.add',
|
|
556
|
+
dir: result.entry.dir,
|
|
557
|
+
label: result.entry.label,
|
|
558
|
+
agentId: result.entry.agentId,
|
|
559
|
+
via: 'view',
|
|
560
|
+
});
|
|
561
|
+
return { code: 200, payload: { action: 'hosts.add', entry: result.entry } };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function writeHostRemove(ctx, body) {
|
|
565
|
+
const dir = String(body.dir || '').trim();
|
|
566
|
+
if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
|
|
567
|
+
const result = hostsRegistryLib.removeHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir });
|
|
568
|
+
if (!result.ok) return { code: 400, payload: { error: result.error } };
|
|
569
|
+
auditViewHostEvent(ctx, {
|
|
570
|
+
event: 'hosts.remove',
|
|
571
|
+
dir: result.removed.dir,
|
|
572
|
+
label: result.removed.label,
|
|
573
|
+
agentId: result.removed.agentId,
|
|
574
|
+
via: 'view',
|
|
575
|
+
});
|
|
576
|
+
return { code: 200, payload: { action: 'hosts.remove', removed: result.removed } };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function writeHostPurge(ctx, body) {
|
|
580
|
+
if (String(body.confirm || '') !== CONFIRM.purge) {
|
|
581
|
+
return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
|
|
582
|
+
}
|
|
583
|
+
const dir = String(body.dir || '').trim();
|
|
584
|
+
if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
|
|
585
|
+
const result = hostPurgeLib.applyHostPurge({
|
|
586
|
+
hubDir: ctx.hubDir,
|
|
587
|
+
dir,
|
|
588
|
+
homeDir: ctx.homeDir,
|
|
589
|
+
env: ctx.env,
|
|
590
|
+
});
|
|
591
|
+
if (!result.ok) return { code: result.code || 400, payload: { error: result.error, state: result.state || null } };
|
|
592
|
+
return {
|
|
593
|
+
code: 200,
|
|
594
|
+
payload: {
|
|
595
|
+
action: 'hosts.purge',
|
|
596
|
+
dir: result.dir,
|
|
597
|
+
state: result.state,
|
|
598
|
+
unlinkCount: result.unlinkCount,
|
|
599
|
+
keptLinks: result.keptLinks,
|
|
600
|
+
keptEntries: result.keptEntries,
|
|
601
|
+
trashedTo: result.trashedTo,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
468
606
|
function json(res, code, value) {
|
|
469
607
|
if (res.writableEnded) return;
|
|
470
608
|
res.writeHead(code, {
|
|
@@ -558,7 +696,39 @@ function handleGet(ctx, pathname, url, res) {
|
|
|
558
696
|
});
|
|
559
697
|
}
|
|
560
698
|
if (pathname === '/api/overview') return json(res, 200, overviewPayload(ctx));
|
|
699
|
+
if (pathname === '/api/help') {
|
|
700
|
+
return json(res, 200, {
|
|
701
|
+
version: ctx.version || '',
|
|
702
|
+
groups: cliHelpLib.CLI_HELP_MODEL,
|
|
703
|
+
quick: cliHelpLib.CLI_HELP_QUICK,
|
|
704
|
+
options: cliHelpLib.CLI_GLOBAL_OPTIONS,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (pathname === '/api/skills/remove-plan') {
|
|
708
|
+
const slug = String(url.searchParams.get('slug') || '').trim().toLowerCase();
|
|
709
|
+
if (!SLUG_RE.test(slug)) return json(res, 400, { error: '技能 slug 非法。' });
|
|
710
|
+
return json(res, 200, removePlanPayload(ctx, slug));
|
|
711
|
+
}
|
|
561
712
|
if (pathname === '/api/hosts') return json(res, 200, hostsPayload(ctx));
|
|
713
|
+
if (pathname === '/api/hosts/purge-plan') {
|
|
714
|
+
const dir = url.searchParams.get('dir') || '';
|
|
715
|
+
if (!dir) return json(res, 400, { error: '缺少 dir 参数(目标宿主目录)。' });
|
|
716
|
+
const plan = hostPurgeLib.planHostPurge({
|
|
717
|
+
hubDir: ctx.hubDir,
|
|
718
|
+
dir,
|
|
719
|
+
homeDir: ctx.homeDir,
|
|
720
|
+
env: ctx.env,
|
|
721
|
+
});
|
|
722
|
+
if (!plan.ok) return json(res, plan.code || 400, { error: plan.error, state: plan.state || null });
|
|
723
|
+
return json(res, 200, {
|
|
724
|
+
dir: plan.target,
|
|
725
|
+
state: plan.state,
|
|
726
|
+
unlinkCount: plan.linkSlugs.length,
|
|
727
|
+
keptLinks: plan.keptLinks,
|
|
728
|
+
keptEntries: plan.keptEntries,
|
|
729
|
+
trashedTo: plan.trashPath,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
562
732
|
if (pathname === '/api/adopt/scan') return json(res, 200, adoptScanPayload(ctx));
|
|
563
733
|
if (pathname === '/api/links') return json(res, 200, linksPayload(ctx));
|
|
564
734
|
if (pathname === '/api/links/plan') {
|
|
@@ -590,6 +760,10 @@ function handlePost(ctx, pathname, req, res) {
|
|
|
590
760
|
if (pathname === '/api/links/apply') return writeLink(ctx, body);
|
|
591
761
|
if (pathname === '/api/links/remove') return writeUnlink(ctx, body);
|
|
592
762
|
if (pathname === '/api/rollback/apply') return writeRollback(ctx, body);
|
|
763
|
+
if (pathname === '/api/skills/remove') return writeRemove(ctx, body);
|
|
764
|
+
if (pathname === '/api/hosts/add') return writeHostAdd(ctx, body);
|
|
765
|
+
if (pathname === '/api/hosts/remove') return writeHostRemove(ctx, body);
|
|
766
|
+
if (pathname === '/api/hosts/purge') return writeHostPurge(ctx, body);
|
|
593
767
|
return { code: 404, payload: { error: 'not found' } };
|
|
594
768
|
};
|
|
595
769
|
ctx.queue(run)
|