@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.
@@ -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
- /** 0.29.2 U4:面板「Hub 位置」设置 / 清除(写 config.json;重启 view 生效)。 */
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 result = skillsConfigLib.setHub(configOpts, { hub });
710
- if (!result.ok) return { code: 400, payload: { error: result.error } };
711
- const auditDir = fs.existsSync(result.hub) ? result.hub : current.dir;
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, { event: 'config.set', hub: result.hub, previousHub: current.dir, via: 'view' });
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
- hub: result.hub,
887
+ mode: 'move',
888
+ hub: target,
722
889
  previous: current.dir,
723
- configFile: result.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({ slug, status: 'would-backup', target, note: '将备份真目录后建立链接' });
1032
+ results.push({
1033
+ slug,
1034
+ status: 'would-backup',
1035
+ target,
1036
+ note: '将把真目录旧副本移入回收站(保留 ' + TRASH_RETENTION_DAYS + ' 天)后建立链接',
1037
+ });
921
1038
  continue;
922
1039
  }
923
- backup = target + '.yottaskills-backup-' + Date.now();
924
- fs.renameSync(target, backup);
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({ slug, status: 'linked', target, note: backup ? '已备份原目录并建立链接' : '已建立链接', backup });
956
- appendAudit(hubDir, { event: 'link', slug, target, hubDir: skill.dir, backup });
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
- note: error.message + '(未静默降级为复制;请检查目录权限或改用 --dir 指定可写目录)',
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 : '先运行 yotta-skills hub install 或 hub adopt --apply');
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
- '先运行 yotta-skills hub install 或 hub adopt --apply');
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,