@yottameta/yotta-skills-plugin 0.29.3 → 0.29.5
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 +8 -4
- package/skills/yotta-skills/assets/view.html +191 -11
- package/skills/yotta-skills/bin/yotta-skills.js +286 -87
- package/skills/yotta-skills/lib/cli-help.js +7 -2
- package/skills/yotta-skills/lib/hub-adopt.js +123 -24
- package/skills/yotta-skills/lib/hub-migrate.js +212 -1
- package/skills/yotta-skills/lib/hub-targets.js +85 -0
- package/skills/yotta-skills/lib/hub-view-server.js +238 -7
- package/skills/yotta-skills/lib/hub.js +198 -9
- package/skills/yotta-skills/lib/install-pipeline.js +53 -5
- package/skills/yotta-skills/lib/npm-pack.js +3 -0
- package/skills/yotta-skills/lib/skills-config.js +99 -5
- package/skills/yotta-skills/lib/skills-scan.js +3 -0
- package/skills/yotta-skills/references/hub.md +23 -4
- package/skills/yotta-skills/references/skill-list.md +1 -1
- package/skills/yotta-skills/scripts/yotta-skills-mcp.py +1 -1
- package/skills/yotta-skills/skills.json +2 -2
|
@@ -36,6 +36,8 @@ const cliHelpLib = require('./cli-help');
|
|
|
36
36
|
const hostsRegistryLib = require('./hosts-registry');
|
|
37
37
|
const hostPurgeLib = require('./host-purge');
|
|
38
38
|
const skillsConfigLib = require('./skills-config');
|
|
39
|
+
const hubMigrateLib = require('./hub-migrate');
|
|
40
|
+
const hubTargetsLib = require('./hub-targets');
|
|
39
41
|
|
|
40
42
|
const DEFAULT_HOST = '127.0.0.1';
|
|
41
43
|
const DEFAULT_PORT = 8789;
|
|
@@ -149,6 +151,16 @@ function discovery(ctx) {
|
|
|
149
151
|
return discoveryLib.discoverHosts({ homeDir: ctx.homeDir, env: ctx.env });
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/** 迁移 / 回滚的重链范围(与 CLI hub link --all 同源,只取存在的目录)。 */
|
|
155
|
+
function migrationTargets(ctx, excludeDirs) {
|
|
156
|
+
const skip = (excludeDirs || []).filter(Boolean);
|
|
157
|
+
return hubTargetsLib.computeHubTargets({
|
|
158
|
+
discovery: discovery(ctx),
|
|
159
|
+
homeDir: ctx.homeDir,
|
|
160
|
+
env: ctx.env,
|
|
161
|
+
}).filter((item) => fs.existsSync(item.dir) && !skip.some((dir) => sameDir(item.dir, dir)));
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
function scanEngineInfo(ctx, found) {
|
|
153
165
|
const engine = hubScanLib.scanEngineForHub(ctx.hubDir, found, { python: ctx.python, verify: ctx.verify });
|
|
154
166
|
if (!engine) return { available: false, path: null };
|
|
@@ -166,6 +178,22 @@ function overviewPayload(ctx) {
|
|
|
166
178
|
});
|
|
167
179
|
const audit = readJsonlTail(hubAuditPath(ctx.hubDir), 8);
|
|
168
180
|
const hubNow = skillsConfigLib.resolveHub({ homeDir: ctx.homeDir, env: ctx.env });
|
|
181
|
+
const config = skillsConfigLib.readConfig({ homeDir: ctx.homeDir, env: ctx.env });
|
|
182
|
+
const lastMigration = config.lastMigration
|
|
183
|
+
? { ...config.lastMigration, daysLeft: hubMigrateLib.trashDaysLeft(config.lastMigration.at) }
|
|
184
|
+
: null;
|
|
185
|
+
let switchPending = null;
|
|
186
|
+
if (config.lastSwitch && !sameDir(config.lastSwitch.from, ctx.hubDir)) {
|
|
187
|
+
const oldSkills = hubLib.countHubSkills(config.lastSwitch.from);
|
|
188
|
+
if (oldSkills > 0) {
|
|
189
|
+
switchPending = {
|
|
190
|
+
from: config.lastSwitch.from,
|
|
191
|
+
to: config.lastSwitch.to,
|
|
192
|
+
at: config.lastSwitch.at,
|
|
193
|
+
skills: oldSkills,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
169
197
|
return {
|
|
170
198
|
version: ctx.version,
|
|
171
199
|
hubDir: ctx.hubDir,
|
|
@@ -195,6 +223,47 @@ function overviewPayload(ctx) {
|
|
|
195
223
|
},
|
|
196
224
|
doctor: { ok: doctor.ok, summary: doctor.summary, checks: doctor.checks },
|
|
197
225
|
audit: { count: audit.entries.length, recent: audit.entries, invalid: audit.invalid },
|
|
226
|
+
// 0.29.5 S1/S2:最近一次迁移 + 未迁移提示(面板迁移向导 / 一键回滚数据源)。
|
|
227
|
+
migration: {
|
|
228
|
+
last: lastMigration,
|
|
229
|
+
lastSwitch: config.lastSwitch,
|
|
230
|
+
switchPending,
|
|
231
|
+
trashRetentionDays: hubLib.TRASH_RETENTION_DAYS,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** GET /api/hub/config:Hub 位置 + 最近迁移 + 回滚预览(只读)。 */
|
|
237
|
+
function hubConfigPayload(ctx) {
|
|
238
|
+
const configOpts = { homeDir: ctx.homeDir, env: ctx.env };
|
|
239
|
+
const resolvedNow = skillsConfigLib.resolveHub(configOpts);
|
|
240
|
+
const resolved = ctx.hubFlag
|
|
241
|
+
? {
|
|
242
|
+
dir: ctx.hubDir,
|
|
243
|
+
source: 'flag',
|
|
244
|
+
configured: resolvedNow.configured,
|
|
245
|
+
configFile: resolvedNow.configFile,
|
|
246
|
+
defaultDir: resolvedNow.defaultDir,
|
|
247
|
+
}
|
|
248
|
+
: resolvedNow;
|
|
249
|
+
const config = skillsConfigLib.readConfig(configOpts);
|
|
250
|
+
const rollback = hubMigrateLib.previewRollback({
|
|
251
|
+
configOpts,
|
|
252
|
+
env: ctx.env,
|
|
253
|
+
currentDir: ctx.hubFlag ? ctx.hubDir : null,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
hub: resolved.dir,
|
|
257
|
+
source: resolved.source,
|
|
258
|
+
configured: resolved.configured,
|
|
259
|
+
configFile: resolved.configFile,
|
|
260
|
+
defaultDir: resolved.defaultDir,
|
|
261
|
+
lastMigration: config.lastMigration
|
|
262
|
+
? { ...config.lastMigration, daysLeft: hubMigrateLib.trashDaysLeft(config.lastMigration.at) }
|
|
263
|
+
: null,
|
|
264
|
+
lastSwitch: config.lastSwitch,
|
|
265
|
+
rollback,
|
|
266
|
+
trashRetentionDays: hubLib.TRASH_RETENTION_DAYS,
|
|
198
267
|
};
|
|
199
268
|
}
|
|
200
269
|
|
|
@@ -686,7 +755,12 @@ function writeHostInclude(ctx, body) {
|
|
|
686
755
|
return { code: 200, payload: { action: 'hosts.include', removed: result.removed } };
|
|
687
756
|
}
|
|
688
757
|
|
|
689
|
-
/**
|
|
758
|
+
/**
|
|
759
|
+
* 0.29.2 U4:面板「Hub 位置」设置 / 清除;0.29.5 S1/S2 升级为迁移向导 + 一键回滚:
|
|
760
|
+
* - mode=move(默认):复用 CLI `--move` 同一内核(复制校验 → 切配置 → 重链 → 回收站)。
|
|
761
|
+
* - mode=switch:仅切换指针;旧 Hub 有内容时记录 lastSwitch(面板横幅提示未迁移)。
|
|
762
|
+
* - body.rollback=true:一键回滚(位置回退,用当前内容;反向迁移同内核)。
|
|
763
|
+
*/
|
|
690
764
|
function writeHubConfig(ctx, body) {
|
|
691
765
|
if (String(body.confirm || '') !== CONFIRM.hubConfig) {
|
|
692
766
|
return { code: 400, payload: { error: '确认串不匹配(需要 "' + CONFIRM.hubConfig + '")。' } };
|
|
@@ -703,24 +777,180 @@ function writeHubConfig(ctx, body) {
|
|
|
703
777
|
}
|
|
704
778
|
return { code: 200, payload: { action: 'config.clear', removed: result.removed, restartRequired: true } };
|
|
705
779
|
}
|
|
780
|
+
|
|
781
|
+
if (body.rollback) return writeHubRollback(ctx, configOpts, body);
|
|
782
|
+
|
|
706
783
|
const hub = String(body.hub || '').trim();
|
|
707
784
|
const guard = skillsConfigLib.validateHubPath(hub, configOpts);
|
|
708
785
|
if (!guard.ok) return { code: 400, payload: { error: guard.error } };
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
786
|
+
const target = guard.dir;
|
|
787
|
+
const mode = body.mode === 'switch' ? 'switch' : 'move';
|
|
788
|
+
// 仅允许「迁移未迁移的旧 Hub → 当前位置」使用显式 from(必须命中 lastSwitch 记录),
|
|
789
|
+
// 不接受任意目录作为迁移源。
|
|
790
|
+
let migrationFrom = current.dir;
|
|
791
|
+
const fromOverride = String(body.from || '').trim();
|
|
792
|
+
if (mode === 'move' && fromOverride) {
|
|
793
|
+
const config = skillsConfigLib.readConfig(configOpts);
|
|
794
|
+
const pending = config.lastSwitch;
|
|
795
|
+
if (!pending || !sameDir(pending.from, fromOverride)) {
|
|
796
|
+
return {
|
|
797
|
+
code: 400,
|
|
798
|
+
payload: { error: 'from 只能指向面板记录的「未迁移旧 Hub」(' + (pending ? pending.from : '当前无记录') + ')。' },
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
migrationFrom = path.resolve(fromOverride);
|
|
802
|
+
}
|
|
803
|
+
if (mode === 'move' && sameDir(target, migrationFrom)) {
|
|
804
|
+
return { code: 400, payload: { error: '新旧 Hub 路径相同:' + target } };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (mode === 'switch') {
|
|
808
|
+
if (sameDir(target, current.dir)) {
|
|
809
|
+
return { code: 400, payload: { error: '新旧 Hub 路径相同:' + target } };
|
|
810
|
+
}
|
|
811
|
+
const result = skillsConfigLib.setHub(configOpts, { hub: target });
|
|
812
|
+
if (!result.ok) return { code: 400, payload: { error: result.error } };
|
|
813
|
+
const oldSkills = hubLib.countHubSkills(current.dir);
|
|
814
|
+
if (oldSkills > 0) {
|
|
815
|
+
skillsConfigLib.recordLastSwitch(configOpts, {
|
|
816
|
+
from: current.dir,
|
|
817
|
+
to: target,
|
|
818
|
+
at: nowIso(),
|
|
819
|
+
skills: oldSkills,
|
|
820
|
+
via: 'view',
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const auditDir = fs.existsSync(target) ? target : current.dir;
|
|
824
|
+
if (fs.existsSync(auditDir)) {
|
|
825
|
+
try {
|
|
826
|
+
hubLib.appendAudit(auditDir, {
|
|
827
|
+
event: 'config.set',
|
|
828
|
+
hub: target,
|
|
829
|
+
previousHub: current.dir,
|
|
830
|
+
moved: false,
|
|
831
|
+
via: 'view',
|
|
832
|
+
});
|
|
833
|
+
} catch (_) { /* best-effort */ }
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
code: 200,
|
|
837
|
+
payload: {
|
|
838
|
+
action: 'config.set',
|
|
839
|
+
mode: 'switch',
|
|
840
|
+
hub: target,
|
|
841
|
+
previous: current.dir,
|
|
842
|
+
configFile: result.configFile,
|
|
843
|
+
oldSkills,
|
|
844
|
+
restartRequired: true,
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const migration = hubMigrateLib.runHubMigration({
|
|
850
|
+
from: migrationFrom,
|
|
851
|
+
to: target,
|
|
852
|
+
targets: migrationTargets(ctx, [target, migrationFrom]),
|
|
853
|
+
manifest: ctx.manifest,
|
|
854
|
+
homeDir: ctx.homeDir,
|
|
855
|
+
env: ctx.env,
|
|
856
|
+
configOpts,
|
|
857
|
+
via: 'view',
|
|
858
|
+
cleanResidue: Boolean(body.cleanResidue),
|
|
859
|
+
});
|
|
860
|
+
if (!migration.ok && migration.phase !== 'relink') {
|
|
861
|
+
return {
|
|
862
|
+
code: migration.code === 'target-residue' ? 409 : 400,
|
|
863
|
+
payload: {
|
|
864
|
+
error: migration.error,
|
|
865
|
+
code: migration.code,
|
|
866
|
+
target: migration.target || null,
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const auditDir = fs.existsSync(target) ? target : current.dir;
|
|
712
871
|
if (fs.existsSync(auditDir)) {
|
|
713
872
|
try {
|
|
714
|
-
hubLib.appendAudit(auditDir, {
|
|
873
|
+
hubLib.appendAudit(auditDir, {
|
|
874
|
+
event: 'config.set',
|
|
875
|
+
hub: target,
|
|
876
|
+
previousHub: current.dir,
|
|
877
|
+
moved: true,
|
|
878
|
+
incomplete: migration.report.incomplete,
|
|
879
|
+
via: 'view',
|
|
880
|
+
});
|
|
715
881
|
} catch (_) { /* best-effort */ }
|
|
716
882
|
}
|
|
717
883
|
return {
|
|
718
884
|
code: 200,
|
|
719
885
|
payload: {
|
|
720
886
|
action: 'config.set',
|
|
721
|
-
|
|
887
|
+
mode: 'move',
|
|
888
|
+
hub: target,
|
|
722
889
|
previous: current.dir,
|
|
723
|
-
configFile:
|
|
890
|
+
configFile: skillsConfigLib.resolveHub(configOpts).configFile,
|
|
891
|
+
move: migration.report,
|
|
892
|
+
restartRequired: true,
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** 面板一键回滚:预览 → 反向迁移(位置回退,用当前内容;同内核)。 */
|
|
898
|
+
function writeHubRollback(ctx, configOpts, body) {
|
|
899
|
+
const preview = hubMigrateLib.previewRollback({
|
|
900
|
+
configOpts,
|
|
901
|
+
env: ctx.env,
|
|
902
|
+
currentDir: ctx.hubFlag ? ctx.hubDir : null,
|
|
903
|
+
});
|
|
904
|
+
if (!preview.ok) {
|
|
905
|
+
return {
|
|
906
|
+
code: 400,
|
|
907
|
+
payload: {
|
|
908
|
+
error: preview.error || ('回滚被阻断:' + (preview.blocked || []).join(';')),
|
|
909
|
+
preview,
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
const migration = hubMigrateLib.runHubMigration({
|
|
914
|
+
from: preview.current,
|
|
915
|
+
to: preview.target,
|
|
916
|
+
kind: 'rollback',
|
|
917
|
+
targets: migrationTargets(ctx, [preview.current, preview.target]),
|
|
918
|
+
manifest: ctx.manifest,
|
|
919
|
+
homeDir: ctx.homeDir,
|
|
920
|
+
env: ctx.env,
|
|
921
|
+
configOpts,
|
|
922
|
+
via: 'view',
|
|
923
|
+
cleanResidue: Boolean(body.cleanResidue),
|
|
924
|
+
});
|
|
925
|
+
if (!migration.ok && migration.phase !== 'relink') {
|
|
926
|
+
return {
|
|
927
|
+
code: migration.code === 'target-residue' ? 409 : 400,
|
|
928
|
+
payload: {
|
|
929
|
+
error: migration.error,
|
|
930
|
+
code: migration.code,
|
|
931
|
+
target: migration.target || null,
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
const auditDir = fs.existsSync(preview.target) ? preview.target : preview.current;
|
|
936
|
+
if (fs.existsSync(auditDir)) {
|
|
937
|
+
try {
|
|
938
|
+
hubLib.appendAudit(auditDir, {
|
|
939
|
+
event: 'config.rollback',
|
|
940
|
+
hub: preview.target,
|
|
941
|
+
previousHub: preview.current,
|
|
942
|
+
incomplete: migration.report.incomplete,
|
|
943
|
+
via: 'view',
|
|
944
|
+
});
|
|
945
|
+
} catch (_) { /* best-effort */ }
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
code: 200,
|
|
949
|
+
payload: {
|
|
950
|
+
action: 'config.rollback',
|
|
951
|
+
hub: preview.target,
|
|
952
|
+
previous: preview.current,
|
|
953
|
+
move: migration.report,
|
|
724
954
|
restartRequired: true,
|
|
725
955
|
},
|
|
726
956
|
};
|
|
@@ -865,6 +1095,7 @@ function handleGet(ctx, pathname, url, res) {
|
|
|
865
1095
|
}
|
|
866
1096
|
if (pathname === '/api/records') return json(res, 200, recordsPayload(ctx, queryLimit(url)));
|
|
867
1097
|
if (pathname === '/api/rollback/list') return json(res, 200, rollbackListPayload(ctx, queryLimit(url)));
|
|
1098
|
+
if (pathname === '/api/hub/config') return json(res, 200, hubConfigPayload(ctx));
|
|
868
1099
|
if (pathname === '/api/route') {
|
|
869
1100
|
const request = String(url.searchParams.get('request') || '').trim();
|
|
870
1101
|
if (!request) return json(res, 400, { error: '缺少 request 参数(需求摘要)。' });
|
|
@@ -394,6 +394,41 @@ function scanHubSkills(hubDir) {
|
|
|
394
394
|
return result.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/** 轻量技能计数(顶层目录 / 链接含 SKILL.md;不做哈希,用于提示与 doctor)。 */
|
|
398
|
+
function countHubSkills(dir) {
|
|
399
|
+
let entries;
|
|
400
|
+
try {
|
|
401
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
402
|
+
} catch (_) {
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
let count = 0;
|
|
406
|
+
for (const entry of entries) {
|
|
407
|
+
if (entry.name.startsWith('.')) continue;
|
|
408
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
409
|
+
try {
|
|
410
|
+
if (fs.existsSync(path.join(dir, entry.name, SKILL_FILE))) count += 1;
|
|
411
|
+
} catch (_) { /* skip unreadable entry */ }
|
|
412
|
+
}
|
|
413
|
+
return count;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 空 Hub 提示:优先指向「未迁移旧 Hub」(0.29.5 S1),否则回退到 install / adopt。 */
|
|
417
|
+
function emptyHubHint(opts, hubDir) {
|
|
418
|
+
const fallback = '先运行 yotta-skills hub install 或 hub adopt --apply';
|
|
419
|
+
try {
|
|
420
|
+
const config = skillsConfigLib.readConfig({ homeDir: opts.homeDir, env: opts.env });
|
|
421
|
+
if (config.lastSwitch && config.lastSwitch.from && !samePath(config.lastSwitch.from, hubDir)) {
|
|
422
|
+
const count = countHubSkills(config.lastSwitch.from);
|
|
423
|
+
if (count > 0) {
|
|
424
|
+
return '旧 Hub ' + config.lastSwitch.from + ' 还有 ' + count + ' 个技能未迁移;迁移:yotta-skills hub config set --hub "' +
|
|
425
|
+
path.resolve(hubDir) + '" --move(面板「一键迁移」同源)';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (_) { /* fall back */ }
|
|
429
|
+
return fallback;
|
|
430
|
+
}
|
|
431
|
+
|
|
397
432
|
function manifestSlugs(manifest) {
|
|
398
433
|
const set = new Set();
|
|
399
434
|
const list = Array.isArray(manifest) ? manifest : (manifest && manifest.skills) || [];
|
|
@@ -457,6 +492,11 @@ function safeHostName(value) {
|
|
|
457
492
|
return cleaned || 'host';
|
|
458
493
|
}
|
|
459
494
|
|
|
495
|
+
/** 目录短哈希(回收站命名去重:不同目录同 basename 时不互相撞车)。 */
|
|
496
|
+
function shortPathHash(dir) {
|
|
497
|
+
return crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 8);
|
|
498
|
+
}
|
|
499
|
+
|
|
460
500
|
/** 清理回收站中超过保留期的条目(best-effort;返回清理数量)。 */
|
|
461
501
|
function pruneTrash(trashRoot, options) {
|
|
462
502
|
const opts = options || {};
|
|
@@ -488,6 +528,77 @@ function pruneTrash(trashRoot, options) {
|
|
|
488
528
|
return pruned;
|
|
489
529
|
}
|
|
490
530
|
|
|
531
|
+
/** 历史备份 / 暂存残留的目录名标记(0.29.5 S3)。 */
|
|
532
|
+
const BACKUP_RESIDUE_MARKERS = [
|
|
533
|
+
'.yottaskills-backup-',
|
|
534
|
+
'.yottaskills-import-',
|
|
535
|
+
'.yottaskills-rollback-',
|
|
536
|
+
'.yottaskills-staging',
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 扫描宿主 / Hub 目录里的 `.yottaskills-backup-*` / `.yottaskills-import-*`
|
|
541
|
+
* 历史残留(只读)。dirs = [{ dir, label }] 或字符串数组。
|
|
542
|
+
*/
|
|
543
|
+
function findBackupResidues(options) {
|
|
544
|
+
const opts = options || {};
|
|
545
|
+
const dirs = Array.isArray(opts.dirs) ? opts.dirs : [];
|
|
546
|
+
const found = [];
|
|
547
|
+
for (const item of dirs) {
|
|
548
|
+
const dir = path.resolve(typeof item === 'string' ? item : item.dir);
|
|
549
|
+
const label = typeof item === 'string' ? null : (item.label || null);
|
|
550
|
+
let entries;
|
|
551
|
+
try {
|
|
552
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
553
|
+
} catch (_) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
if (!BACKUP_RESIDUE_MARKERS.some((marker) => entry.name.includes(marker))) continue;
|
|
558
|
+
found.push({
|
|
559
|
+
dir,
|
|
560
|
+
label,
|
|
561
|
+
name: entry.name,
|
|
562
|
+
path: path.join(dir, entry.name),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return found;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 清理历史备份残留:移入回收站 `backup-cleanup-<stamp>/`(保留 7 天)。
|
|
571
|
+
* options: { dirs, hubDir, trashRoot, residues, now, renameEntry }
|
|
572
|
+
*/
|
|
573
|
+
function cleanupBackupResidues(options) {
|
|
574
|
+
const opts = options || {};
|
|
575
|
+
const residues = Array.isArray(opts.residues) ? opts.residues : findBackupResidues(opts);
|
|
576
|
+
const baseHub = opts.hubDir || (opts.dirs && opts.dirs.length
|
|
577
|
+
? (typeof opts.dirs[0] === 'string' ? opts.dirs[0] : opts.dirs[0].dir)
|
|
578
|
+
: null);
|
|
579
|
+
const trashRoot = opts.trashRoot || resolveTrashRoot(baseHub || process.cwd(), opts);
|
|
580
|
+
const stamp = trashRunStamp(opts.now);
|
|
581
|
+
const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
|
|
582
|
+
const moved = [];
|
|
583
|
+
const failed = [];
|
|
584
|
+
for (const item of residues) {
|
|
585
|
+
const dest = path.join(
|
|
586
|
+
trashRoot,
|
|
587
|
+
'backup-cleanup-' + stamp,
|
|
588
|
+
safeHostName(path.basename(item.dir)) + '-' + shortPathHash(item.dir),
|
|
589
|
+
item.name,
|
|
590
|
+
);
|
|
591
|
+
try {
|
|
592
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
593
|
+
const transfer = moveEntryAcrossDevices(item.path, dest, { renameEntry });
|
|
594
|
+
moved.push({ from: item.path, to: dest, dir: item.dir, name: item.name, method: transfer.method });
|
|
595
|
+
} catch (error) {
|
|
596
|
+
failed.push({ path: item.path, error: error.message });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return { ok: failed.length === 0, trashRoot, found: residues.length, moved, failed };
|
|
600
|
+
}
|
|
601
|
+
|
|
491
602
|
/**
|
|
492
603
|
* 列出宿主目录中某个家族技能的副本:
|
|
493
604
|
* - 精确同名(slug)的真目录 / 链接;
|
|
@@ -656,7 +767,7 @@ function linkSkills(options) {
|
|
|
656
767
|
const makeLink = typeof opts.createLink === 'function' ? opts.createLink : createDirLink;
|
|
657
768
|
const renameEntry = typeof opts.renameEntry === 'function' ? opts.renameEntry : fs.renameSync;
|
|
658
769
|
const stamp = trashRunStamp(opts.now);
|
|
659
|
-
const hostName = safeHostName(opts.label || path.basename(targetDir));
|
|
770
|
+
const hostName = safeHostName(opts.label || path.basename(targetDir)) + '-' + shortPathHash(targetDir);
|
|
660
771
|
let prunedTrash = 0;
|
|
661
772
|
if (!dryRun) {
|
|
662
773
|
try {
|
|
@@ -897,6 +1008,7 @@ function linkSkills(options) {
|
|
|
897
1008
|
|
|
898
1009
|
// ── 非家族技能:保持原行为(同名冲突默认跳过,不自动删) ────────────
|
|
899
1010
|
let backup = null;
|
|
1011
|
+
let backupMethod = null;
|
|
900
1012
|
if (current.kind === 'link' && current.inHub && current.targetExists !== false && samePath(current.target, skill.dir)) {
|
|
901
1013
|
results.push({ slug, status: 'linked', target, note: '已链接到 hub' });
|
|
902
1014
|
continue;
|
|
@@ -917,11 +1029,21 @@ function linkSkills(options) {
|
|
|
917
1029
|
}
|
|
918
1030
|
if (current.kind === 'directory') {
|
|
919
1031
|
if (dryRun) {
|
|
920
|
-
results.push({
|
|
1032
|
+
results.push({
|
|
1033
|
+
slug,
|
|
1034
|
+
status: 'would-backup',
|
|
1035
|
+
target,
|
|
1036
|
+
note: '将把真目录旧副本移入回收站(保留 ' + TRASH_RETENTION_DAYS + ' 天)后建立链接',
|
|
1037
|
+
});
|
|
921
1038
|
continue;
|
|
922
1039
|
}
|
|
923
|
-
|
|
924
|
-
|
|
1040
|
+
// 0.29.5 S3:旧副本不再原地留 <name>.yottaskills-backup-*(宿主会当技能扫),
|
|
1041
|
+
// 统一移入回收站(7 天可恢复);跨卷走复制 + 校验 + 删源。
|
|
1042
|
+
const dest = path.join(trashRoot, stamp, hostName, slug);
|
|
1043
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1044
|
+
const transfer = moveEntryAcrossDevices(target, dest, { renameEntry });
|
|
1045
|
+
backup = dest;
|
|
1046
|
+
backupMethod = transfer.method;
|
|
925
1047
|
} else if (current.kind === 'link') {
|
|
926
1048
|
if (dryRun) {
|
|
927
1049
|
results.push({ slug, status: 'would-replace', target, note: '将替换已有链接' });
|
|
@@ -937,8 +1059,10 @@ function linkSkills(options) {
|
|
|
937
1059
|
results.push({ slug, status: 'would-link', target, note: '将建立链接' });
|
|
938
1060
|
continue;
|
|
939
1061
|
}
|
|
1062
|
+
let linked = false;
|
|
940
1063
|
try {
|
|
941
1064
|
makeLink(skill.dir, target);
|
|
1065
|
+
linked = true;
|
|
942
1066
|
const index = links.findIndex((item) => item.slug === slug && samePath(item.dir || '', targetDir));
|
|
943
1067
|
const record = {
|
|
944
1068
|
slug,
|
|
@@ -949,17 +1073,41 @@ function linkSkills(options) {
|
|
|
949
1073
|
hubDir: skill.dir,
|
|
950
1074
|
linkedAt: nowIso(),
|
|
951
1075
|
backup,
|
|
1076
|
+
backupMethod,
|
|
952
1077
|
};
|
|
953
1078
|
if (index >= 0) links[index] = record;
|
|
954
1079
|
else links.push(record);
|
|
955
|
-
results.push({
|
|
956
|
-
|
|
1080
|
+
results.push({
|
|
1081
|
+
slug,
|
|
1082
|
+
status: 'linked',
|
|
1083
|
+
target,
|
|
1084
|
+
note: backup
|
|
1085
|
+
? '真目录旧副本已移入回收站(保留 ' + TRASH_RETENTION_DAYS + ' 天)并建立链接'
|
|
1086
|
+
: '已建立链接',
|
|
1087
|
+
backup,
|
|
1088
|
+
backupMethod,
|
|
1089
|
+
trashDir: backup ? trashRoot : null,
|
|
1090
|
+
});
|
|
1091
|
+
try {
|
|
1092
|
+
appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup, backupMethod });
|
|
1093
|
+
} catch (_) { /* 审计失败不把已完成的链接判失败 */ }
|
|
957
1094
|
} catch (error) {
|
|
1095
|
+
// 替换失败时把旧副本从回收站移回原位,避免宿主目录留下空位。
|
|
1096
|
+
let restored = false;
|
|
1097
|
+
if (backup && !linked) {
|
|
1098
|
+
try {
|
|
1099
|
+
moveEntryAcrossDevices(backup, target, { renameEntry });
|
|
1100
|
+
restored = true;
|
|
1101
|
+
} catch (_) { /* 保留回收站副本供人工恢复 */ }
|
|
1102
|
+
}
|
|
958
1103
|
results.push({
|
|
959
1104
|
slug,
|
|
960
1105
|
status: 'error',
|
|
961
1106
|
target,
|
|
962
|
-
|
|
1107
|
+
backup: restored ? null : backup,
|
|
1108
|
+
note: error.message +
|
|
1109
|
+
(backup ? (restored ? '(旧副本已恢复原位)' : '(旧副本保留在回收站:' + backup + ')') : '') +
|
|
1110
|
+
'(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
|
|
963
1111
|
});
|
|
964
1112
|
}
|
|
965
1113
|
}
|
|
@@ -1395,6 +1543,38 @@ function singleSourceChecks(opts, add) {
|
|
|
1395
1543
|
}
|
|
1396
1544
|
}
|
|
1397
1545
|
|
|
1546
|
+
/** 0.29.5 S3:历史备份 / 暂存残留只读检查(宿主扫描目录 + Hub 自身)。 */
|
|
1547
|
+
function backupResidueChecks(opts, add) {
|
|
1548
|
+
const hubDir = path.resolve(opts.hubDir);
|
|
1549
|
+
const dirs = [];
|
|
1550
|
+
if (fs.existsSync(hubDir)) dirs.push({ dir: hubDir, label: 'Hub' });
|
|
1551
|
+
let discovery = opts.discovery;
|
|
1552
|
+
if (!discovery) {
|
|
1553
|
+
try {
|
|
1554
|
+
discovery = require('./agent-discovery').discoverHosts({ homeDir: opts.homeDir, env: opts.env });
|
|
1555
|
+
} catch (_) {
|
|
1556
|
+
discovery = { hosts: [] };
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
for (const host of discovery.hosts || []) {
|
|
1560
|
+
if (!host || !host.exists || !host.dir) continue;
|
|
1561
|
+
dirs.push({ dir: host.dir, label: host.label || host.agentId || host.dir });
|
|
1562
|
+
}
|
|
1563
|
+
const residues = findBackupResidues({ dirs });
|
|
1564
|
+
if (residues.length === 0) return;
|
|
1565
|
+
const byDir = new Map();
|
|
1566
|
+
for (const item of residues) {
|
|
1567
|
+
if (!byDir.has(item.dir)) byDir.set(item.dir, []);
|
|
1568
|
+
byDir.get(item.dir).push(item.name);
|
|
1569
|
+
}
|
|
1570
|
+
for (const [dir, names] of byDir) {
|
|
1571
|
+
add('backup_residue:' + dir, false, 'warning',
|
|
1572
|
+
'发现 ' + names.length + ' 个历史备份 / 暂存残留(宿主会把备份当技能扫描): ' + names.slice(0, 5).join('、') +
|
|
1573
|
+
(names.length > 5 ? ' 等 ' + names.length + ' 项' : ''),
|
|
1574
|
+
'清理:yotta-skills hub cleanup-backups --yes(移入回收站,保留 ' + TRASH_RETENTION_DAYS + ' 天)');
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1398
1578
|
function doctor(options) {
|
|
1399
1579
|
const opts = options || {};
|
|
1400
1580
|
const hubDir = path.resolve(opts.hubDir);
|
|
@@ -1405,7 +1585,7 @@ function doctor(options) {
|
|
|
1405
1585
|
|
|
1406
1586
|
add('hub_dir', fs.existsSync(hubDir), 'error',
|
|
1407
1587
|
fs.existsSync(hubDir) ? 'Hub 目录存在' : 'Hub 目录不存在',
|
|
1408
|
-
fs.existsSync(hubDir) ? null :
|
|
1588
|
+
fs.existsSync(hubDir) ? null : emptyHubHint(opts, hubDir));
|
|
1409
1589
|
if (fs.existsSync(hubDir)) {
|
|
1410
1590
|
try {
|
|
1411
1591
|
fs.accessSync(hubDir, fs.constants.W_OK);
|
|
@@ -1423,7 +1603,7 @@ function doctor(options) {
|
|
|
1423
1603
|
const stateSkills = Object.values(synced.state.skills || {});
|
|
1424
1604
|
if (fs.existsSync(hubDir) && stateSkills.length === 0) {
|
|
1425
1605
|
add('hub_nonempty', false, 'error', 'Hub 目录存在但没有任何技能',
|
|
1426
|
-
|
|
1606
|
+
emptyHubHint(opts, hubDir));
|
|
1427
1607
|
}
|
|
1428
1608
|
for (const skill of stateSkills) {
|
|
1429
1609
|
add('skill_present:' + skill.slug, skill.status === 'present', 'error',
|
|
@@ -1463,6 +1643,9 @@ function doctor(options) {
|
|
|
1463
1643
|
try {
|
|
1464
1644
|
singleSourceChecks(opts, add);
|
|
1465
1645
|
} catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
|
|
1646
|
+
try {
|
|
1647
|
+
backupResidueChecks(opts, add);
|
|
1648
|
+
} catch (_) { /* 只读检查失败不影响 doctor 主流程 */ }
|
|
1466
1649
|
|
|
1467
1650
|
// F3(0.29.0):宿主状态细分只读检查 —— 残留 / 仅标记给 info 提示,不 fail。
|
|
1468
1651
|
if (opts.discovery) {
|
|
@@ -1512,12 +1695,18 @@ module.exports = {
|
|
|
1512
1695
|
hashTree,
|
|
1513
1696
|
moveEntryAcrossDevices,
|
|
1514
1697
|
scanHubSkills,
|
|
1698
|
+
countHubSkills,
|
|
1515
1699
|
syncHubState,
|
|
1516
1700
|
familySlugSet,
|
|
1517
1701
|
compareVersions,
|
|
1518
1702
|
readSkillMeta,
|
|
1519
1703
|
resolveTrashRoot,
|
|
1520
1704
|
pruneTrash,
|
|
1705
|
+
BACKUP_RESIDUE_MARKERS,
|
|
1706
|
+
findBackupResidues,
|
|
1707
|
+
cleanupBackupResidues,
|
|
1708
|
+
trashRunStamp,
|
|
1709
|
+
safeHostName,
|
|
1521
1710
|
listFamilyCopies,
|
|
1522
1711
|
createDirLink,
|
|
1523
1712
|
removeDirLink,
|