@yottameta/yotta-skills 0.24.2 → 0.25.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.
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hub adoption (S2): scan host directories, import external skills into the
5
+ * local Hub, and refresh a Hub entry from an explicit source path.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const scanLib = require('./skills-scan');
11
+ const hubLib = require('./hub');
12
+ const { copyDir } = require('./copy-tree');
13
+
14
+ function compareVersions(left, right) {
15
+ const a = String(left || '').split('.').map((part) => parseInt(part, 10) || 0);
16
+ const b = String(right || '').split('.').map((part) => parseInt(part, 10) || 0);
17
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
18
+ const diff = (a[i] || 0) - (b[i] || 0);
19
+ if (diff) return diff;
20
+ }
21
+ return 0;
22
+ }
23
+
24
+ function selectedVariant(variants) {
25
+ return variants.slice().sort((a, b) => {
26
+ const byVersion = compareVersions(b.version, a.version);
27
+ if (byVersion) return byVersion;
28
+ return String(a.host || '').localeCompare(String(b.host || ''));
29
+ })[0] || null;
30
+ }
31
+
32
+ function scanCandidates(options) {
33
+ const opts = options || {};
34
+ const hubDir = path.resolve(opts.hubDir);
35
+ const discovery = opts.discovery || { hosts: [] };
36
+ const existing = new Map(hubLib.scanHubSkills(hubDir).map((item) => [item.slug, item]));
37
+ const groups = new Map();
38
+
39
+ for (const host of discovery.hosts) {
40
+ if (!host.exists) continue;
41
+ for (const skill of scanLib.scanSkillDir(host.dir)) {
42
+ const variant = {
43
+ slug: skill.slug,
44
+ version: skill.version || '',
45
+ description: skill.description || '',
46
+ dir: skill.source_dir,
47
+ host: host.label,
48
+ hostDir: host.dir,
49
+ treeHash: hubLib.hashTree(skill.source_dir),
50
+ };
51
+ const group = groups.get(skill.slug);
52
+ if (group) {
53
+ group.variants.push(variant);
54
+ } else {
55
+ groups.set(skill.slug, {
56
+ slug: skill.slug,
57
+ variants: [variant],
58
+ inHub: existing.has(skill.slug),
59
+ });
60
+ }
61
+ }
62
+ }
63
+
64
+ const candidates = [];
65
+ for (const group of groups.values()) {
66
+ const selected = selectedVariant(group.variants);
67
+ const hashes = new Set(group.variants.map((item) => item.treeHash));
68
+ const versions = new Set(group.variants.map((item) => item.version).filter(Boolean));
69
+ candidates.push({
70
+ slug: group.slug,
71
+ version: selected ? selected.version : '',
72
+ description: selected ? selected.description : '',
73
+ source: selected ? selected.dir : null,
74
+ sourceHost: selected ? selected.host : null,
75
+ sourceHostDir: selected ? selected.hostDir : null,
76
+ treeHash: selected ? selected.treeHash : null,
77
+ variants: group.variants,
78
+ inHub: group.inHub,
79
+ conflict: hashes.size > 1 || versions.size > 1,
80
+ });
81
+ }
82
+ candidates.sort((a, b) => a.slug.localeCompare(b.slug));
83
+ return {
84
+ generatedAt: new Date().toISOString(),
85
+ hubDir,
86
+ candidates,
87
+ summary: {
88
+ candidates: candidates.length,
89
+ conflicts: candidates.filter((item) => item.conflict).length,
90
+ alreadyInHub: candidates.filter((item) => item.inHub).length,
91
+ },
92
+ };
93
+ }
94
+
95
+ function includeMatch(slug, include) {
96
+ if (!Array.isArray(include) || include.length === 0) return true;
97
+ return include.includes(slug);
98
+ }
99
+
100
+ function replaceWithBackup(target, options) {
101
+ const opts = options || {};
102
+ if (!fs.existsSync(target)) return null;
103
+ const stat = fs.lstatSync(target);
104
+ if (stat.isSymbolicLink()) {
105
+ hubLib.removeDirLink(target);
106
+ return null;
107
+ }
108
+ if (!opts.force) throw new Error('目标已存在真目录;如需替换请显式加 --force');
109
+ const backup = target + '.yottaskills-backup-' + Date.now();
110
+ fs.renameSync(target, backup);
111
+ return backup;
112
+ }
113
+
114
+ function copyFidelity(sourceDir, targetDir) {
115
+ const tmp = targetDir + '.yottaskills-import-' + process.pid + '-' + Date.now();
116
+ fs.mkdirSync(tmp, { recursive: true });
117
+ copyDir(sourceDir, tmp, new Set(), true);
118
+ if (!fs.existsSync(path.join(tmp, 'SKILL.md'))) {
119
+ fs.rmSync(tmp, { recursive: true, force: true });
120
+ throw new Error('来源目录缺少 SKILL.md');
121
+ }
122
+ return tmp;
123
+ }
124
+
125
+ function persistImportMeta(hubDir, slug, meta) {
126
+ const state = hubLib.readHubState(hubDir);
127
+ const record = state.skills[slug];
128
+ if (!record) return;
129
+ Object.assign(record, meta, { updatedAt: new Date().toISOString() });
130
+ hubLib.writeHubState(hubDir, state);
131
+ }
132
+
133
+ function applyCandidates(options) {
134
+ const opts = options || {};
135
+ const hubDir = path.resolve(opts.hubDir);
136
+ const candidates = opts.candidates || [];
137
+ const include = opts.include || [];
138
+ const results = [];
139
+ fs.mkdirSync(hubDir, { recursive: true });
140
+
141
+ for (const candidate of candidates) {
142
+ if (!includeMatch(candidate.slug, include)) continue;
143
+ if (candidate.inHub && !opts.force) {
144
+ results.push({ slug: candidate.slug, status: 'skip', note: 'Hub 已存在;如需覆盖请显式加 --force' });
145
+ continue;
146
+ }
147
+ if (candidate.conflict && !opts.force) {
148
+ results.push({
149
+ slug: candidate.slug,
150
+ status: 'conflict',
151
+ note: '多宿主副本版本 / 哈希不一致;请先人工确认或用 --force 选定代表副本',
152
+ });
153
+ continue;
154
+ }
155
+ if (!candidate.source) {
156
+ results.push({ slug: candidate.slug, status: 'error', note: '没有可收编的来源目录' });
157
+ continue;
158
+ }
159
+ const target = path.join(hubDir, candidate.slug);
160
+ let scan = { ok: true, verdict: 'explicit-unverified', counts: null };
161
+ if (!opts.skipScan) {
162
+ if (typeof opts.scan !== 'function') {
163
+ results.push({ slug: candidate.slug, status: 'blocked', note: '未提供元信扫描通道;如确认可加 --allow-unverified' });
164
+ continue;
165
+ }
166
+ scan = opts.scan(candidate.source, candidate);
167
+ if (!scan.ok) {
168
+ results.push({ slug: candidate.slug, status: 'blocked', note: scan.error || '扫描失败' });
169
+ continue;
170
+ }
171
+ if (scan.block && !opts.allowUnverified) {
172
+ results.push({ slug: candidate.slug, status: 'blocked', note: '元信 verdict: ' + scan.verdict });
173
+ continue;
174
+ }
175
+ }
176
+ let backup = null;
177
+ let tmp = null;
178
+ try {
179
+ backup = replaceWithBackup(target, { force: opts.force });
180
+ if (opts.inPlace) {
181
+ hubLib.createDirLink(candidate.source, target);
182
+ } else {
183
+ tmp = copyFidelity(candidate.source, target);
184
+ fs.renameSync(tmp, target);
185
+ tmp = null;
186
+ }
187
+ results.push({
188
+ slug: candidate.slug,
189
+ status: 'imported',
190
+ note: opts.inPlace ? '已原地登记并链接到 Hub' : '已复制到 Hub',
191
+ backup,
192
+ scanVerdict: scan.verdict,
193
+ sourcePath: candidate.source,
194
+ sourceAgent: candidate.sourceHost,
195
+ });
196
+ hubLib.appendAudit(hubDir, {
197
+ event: 'adopt',
198
+ slug: candidate.slug,
199
+ source: candidate.source,
200
+ sourceHost: candidate.sourceHost,
201
+ inPlace: Boolean(opts.inPlace),
202
+ verdict: scan.verdict,
203
+ backup,
204
+ });
205
+ } catch (error) {
206
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
207
+ results.push({ slug: candidate.slug, status: 'error', note: error.message });
208
+ }
209
+ }
210
+ const synced = hubLib.syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
211
+ for (const item of results) {
212
+ if (item.status !== 'imported') continue;
213
+ persistImportMeta(hubDir, item.slug, {
214
+ origin: 'external',
215
+ sourcePath: item.sourcePath,
216
+ sourceAgent: item.sourceAgent,
217
+ scanVerdict: item.scanVerdict,
218
+ inPlace: Boolean(opts.inPlace),
219
+ });
220
+ }
221
+ return { hubDir, results, state: synced.state };
222
+ }
223
+
224
+ function refreshFrom(options) {
225
+ const opts = options || {};
226
+ const hubDir = path.resolve(opts.hubDir);
227
+ const slug = opts.slug;
228
+ const source = path.resolve(opts.from);
229
+ const target = path.join(hubDir, slug);
230
+ if (!fs.existsSync(path.join(source, 'SKILL.md'))) {
231
+ return { ok: false, slug, error: '来源目录缺少 SKILL.md: ' + source };
232
+ }
233
+ let scan = { ok: true, verdict: 'explicit-unverified', counts: null };
234
+ if (!opts.skipScan) {
235
+ if (typeof opts.scan !== 'function') {
236
+ return { ok: false, slug, error: '未提供元信扫描通道;如确认可加 --allow-unverified' };
237
+ }
238
+ scan = opts.scan(source, { slug });
239
+ if (!scan.ok) return { ok: false, slug, error: scan.error || '扫描失败' };
240
+ if (scan.block && !opts.allowUnverified) return { ok: false, slug, error: '元信 verdict: ' + scan.verdict };
241
+ }
242
+ let backup = null;
243
+ let tmp = null;
244
+ try {
245
+ backup = replaceWithBackup(target, { force: true });
246
+ tmp = copyFidelity(source, target);
247
+ fs.renameSync(tmp, target);
248
+ tmp = null;
249
+ hubLib.syncHubState(hubDir, { manifest: opts.manifest, homeDir: opts.homeDir, env: opts.env });
250
+ persistImportMeta(hubDir, slug, {
251
+ sourcePath: source,
252
+ scanVerdict: scan.verdict,
253
+ updatedAt: new Date().toISOString(),
254
+ });
255
+ hubLib.appendAudit(hubDir, { event: 'refresh', slug, source, verdict: scan.verdict, backup });
256
+ return { ok: true, slug, source, backup, verdict: scan.verdict };
257
+ } catch (error) {
258
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
259
+ return { ok: false, slug, error: error.message };
260
+ }
261
+ }
262
+
263
+ module.exports = {
264
+ compareVersions,
265
+ scanCandidates,
266
+ applyCandidates,
267
+ refreshFrom,
268
+ };