@yottameta/yotta-skills-plugin 0.29.0 → 0.29.2

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,183 @@
1
+ 'use strict';
2
+ /**
3
+ * Hub 迁移(0.29.2 U4)—— `hub config set --hub <new> --move` 的两段式实现。
4
+ *
5
+ * 1) stageHubCopy({ from, to }):复制旧 Hub → 逐技能 treeHash 校验;失败
6
+ * fail-closed(清理暂存副本、旧 Hub 不动、配置不切换)。
7
+ * 2) relinkAndRetireOldHub({ from, to, targets }):`hub link` 以新 Hub 为源、
8
+ * --force 重链各宿主 → 全部成功才把旧 Hub 移入回收站(7 天);有失败则
9
+ * 保留旧 Hub 供重试(此时新旧内容一致,链接混合指向两个 Hub 均可用)。
10
+ *
11
+ * 配置切换(config.json 写入)由调用方在两段之间执行;写入失败时调用
12
+ * discardStagedCopy() 清理暂存副本。
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const hubLib = require('./hub');
17
+
18
+ function nowStamp() {
19
+ return new Date().toISOString().replace(/[:.]/g, '-');
20
+ }
21
+
22
+ function safeRealpath(target) {
23
+ try {
24
+ return fs.realpathSync.native(target);
25
+ } catch (_) {
26
+ try {
27
+ return fs.realpathSync(target);
28
+ } catch (_) {
29
+ return path.resolve(target);
30
+ }
31
+ }
32
+ }
33
+
34
+ function samePath(left, right) {
35
+ const a = safeRealpath(left);
36
+ const b = safeRealpath(right);
37
+ if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
38
+ return a === b;
39
+ }
40
+
41
+ function isInside(root, target) {
42
+ const relative = path.relative(path.resolve(root), path.resolve(target));
43
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
44
+ }
45
+
46
+ function listEntries(dir) {
47
+ try {
48
+ return fs.readdirSync(dir);
49
+ } catch (_) {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /** 清理暂存副本(仅在 stage 阶段失败或配置写入失败时调用)。 */
55
+ function discardStagedCopy(options) {
56
+ const opts = options || {};
57
+ const to = path.resolve(opts.to);
58
+ try {
59
+ if (opts.existed) {
60
+ for (const name of listEntries(to)) fs.rmSync(path.join(to, name), { recursive: true, force: true });
61
+ } else {
62
+ fs.rmSync(to, { recursive: true, force: true });
63
+ }
64
+ } catch (_) { /* 保留现场供排查 */ }
65
+ }
66
+
67
+ /** 复制 + 校验(不切配置)。返回 { ok, error?, from, to, existed, verifiedSkills }。 */
68
+ function stageHubCopy(options) {
69
+ const opts = options || {};
70
+ const from = path.resolve(opts.from);
71
+ const to = path.resolve(opts.to);
72
+ if (samePath(from, to)) return { ok: false, error: '新旧 Hub 路径相同:' + from };
73
+ if (isInside(from, to) || isInside(to, from)) return { ok: false, error: '新旧 Hub 不能互相嵌套:' + from + ' / ' + to };
74
+ let stat = null;
75
+ try {
76
+ stat = fs.statSync(from);
77
+ } catch (_) { /* handled below */ }
78
+ if (!stat || !stat.isDirectory()) return { ok: false, error: '旧 Hub 不存在或不是目录:' + from };
79
+ const existed = fs.existsSync(to);
80
+ if (existed && listEntries(to).length > 0) {
81
+ return { ok: false, error: '新 Hub 目录非空(fail-closed,不合并):' + to };
82
+ }
83
+
84
+ try {
85
+ fs.mkdirSync(to, { recursive: true });
86
+ fs.cpSync(from, to, { recursive: true });
87
+ } catch (error) {
88
+ discardStagedCopy({ to, existed });
89
+ return { ok: false, error: '复制失败:' + error.message };
90
+ }
91
+
92
+ const mismatches = [];
93
+ for (const name of listEntries(from)) {
94
+ if (!fs.existsSync(path.join(to, name))) mismatches.push('entry:' + name);
95
+ }
96
+ const skills = hubLib.scanHubSkills(from);
97
+ for (const skill of skills) {
98
+ let sourceStat = null;
99
+ try {
100
+ sourceStat = fs.lstatSync(skill.dir);
101
+ } catch (_) {
102
+ continue;
103
+ }
104
+ // in-place / 外部链接条目:随链接原样复制,不做内容校验。
105
+ if (sourceStat.isSymbolicLink() || !isInside(from, skill.dir)) continue;
106
+ const copied = path.join(to, path.relative(from, skill.dir));
107
+ let copiedStat = null;
108
+ try {
109
+ copiedStat = fs.lstatSync(copied);
110
+ } catch (_) { /* mismatch below */ }
111
+ if (!copiedStat || !copiedStat.isDirectory()) {
112
+ mismatches.push(skill.slug);
113
+ continue;
114
+ }
115
+ try {
116
+ if (hubLib.hashTree(skill.dir) !== hubLib.hashTree(copied)) mismatches.push(skill.slug);
117
+ } catch (_) {
118
+ mismatches.push(skill.slug);
119
+ }
120
+ }
121
+ if (mismatches.length > 0) {
122
+ discardStagedCopy({ to, existed });
123
+ return {
124
+ ok: false,
125
+ error: '迁移校验失败(未切换配置;已清理暂存副本):' + mismatches.slice(0, 8).join(', ') +
126
+ (mismatches.length > 8 ? ' 等 ' + mismatches.length + ' 项' : ''),
127
+ };
128
+ }
129
+ return { ok: true, from, to, existed, verifiedSkills: skills.length };
130
+ }
131
+
132
+ /** 重链(新 Hub 为源)+ 旧 Hub 入回收站(仅当重链无失败)。 */
133
+ function relinkAndRetireOldHub(options) {
134
+ const opts = options || {};
135
+ const from = path.resolve(opts.from);
136
+ const to = path.resolve(opts.to);
137
+ const targets = Array.isArray(opts.targets) ? opts.targets : [];
138
+ const relink = [];
139
+ let incomplete = 0;
140
+ for (const target of targets) {
141
+ if (!target || !target.dir) continue;
142
+ if (samePath(target.dir, from) || samePath(target.dir, to)) continue;
143
+ const result = hubLib.linkSkills({
144
+ hubDir: to,
145
+ targetDir: target.dir,
146
+ agentId: target.agentId,
147
+ label: target.label,
148
+ force: true,
149
+ manifest: opts.manifest,
150
+ homeDir: opts.homeDir,
151
+ env: opts.env,
152
+ });
153
+ const notLinked = result.results.filter((item) => item.status !== 'linked' && item.status !== 'missing');
154
+ incomplete += notLinked.length;
155
+ relink.push({
156
+ dir: target.dir,
157
+ label: target.label,
158
+ linked: result.results.filter((item) => item.status === 'linked').length,
159
+ incomplete: notLinked.map((item) => item.slug + ': ' + item.note),
160
+ });
161
+ }
162
+
163
+ let trashedTo = null;
164
+ let trashError = null;
165
+ if (incomplete === 0) {
166
+ try {
167
+ const trashRoot = hubLib.resolveTrashRoot(from, { env: opts.env });
168
+ const dest = path.join(trashRoot, 'hub-migrate-' + nowStamp(), path.basename(from));
169
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
170
+ hubLib.moveEntryAcrossDevices(from, dest);
171
+ trashedTo = dest;
172
+ } catch (error) {
173
+ trashError = error.message;
174
+ }
175
+ }
176
+ return { ok: incomplete === 0, from, to, relink, incomplete, trashedTo, trashError };
177
+ }
178
+
179
+ module.exports = {
180
+ discardStagedCopy,
181
+ stageHubCopy,
182
+ relinkAndRetireOldHub,
183
+ };
@@ -13,6 +13,10 @@ const { spawnSync } = require('child_process');
13
13
  const depsLib = require('./deps');
14
14
  const gateLib = require('./verify-gate');
15
15
  const trustedVerifierLib = require('./trusted-verifier');
16
+ const scanPolicyLib = require('./scan-policy');
17
+ const hubLib = require('./hub');
18
+
19
+ const POLICY_PATH = path.join(__dirname, '..', 'scan-policy.json');
16
20
 
17
21
  function currentCommand() {
18
22
  const args = process.argv.slice(2).map((a) => (/\s/.test(a) ? '"' + a + '"' : a));
@@ -52,6 +56,21 @@ function runScan(engine, skillDir, opts) {
52
56
  return gateLib.runVerifier(engine, skillDir, { python, spawnSync });
53
57
  }
54
58
 
59
+ /**
60
+ * 复用安装管线的 scanPolicy 复核(0.29.2):版本 + treeHash 绑定,fail-closed。
61
+ * 未命中例外表 / 绑定不匹配时返回原始 scan(不豁免任何发现)。
62
+ */
63
+ function reviewScanWithPolicy(scan, context) {
64
+ const ctx = context || {};
65
+ const policy = scanPolicyLib.loadPolicy(ctx.policyPath || POLICY_PATH);
66
+ return scanPolicyLib.applyScanPolicy(scan, {
67
+ slug: ctx.slug,
68
+ version: ctx.version,
69
+ pkgDir: ctx.pkgDir,
70
+ policy,
71
+ });
72
+ }
73
+
55
74
  /** Hub adopt scan engine: Hub itself first, then every discovered host dir. */
56
75
  function scanEngineForHub(hubDir, discovery, opts) {
57
76
  const direct = findVerifyEngine(hubDir, opts);
@@ -69,7 +88,7 @@ function scanEngineForHub(hubDir, discovery, opts) {
69
88
  * Scan one candidate directory for Hub adoption.
70
89
  * Returns { ok, verdict, counts, block } or { ok:false, error }.
71
90
  */
72
- function hubScanSkill(hubDir, discovery, opts, skillDir) {
91
+ function hubScanSkill(hubDir, discovery, opts, skillDir, context) {
73
92
  const options = opts || {};
74
93
  if (options.skipScan) {
75
94
  return { ok: true, verdict: 'explicit-unverified', counts: null, block: false };
@@ -83,8 +102,22 @@ function hubScanSkill(hubDir, discovery, opts, skillDir) {
83
102
  }
84
103
  const scan = runScan(engine, skillDir, options);
85
104
  if (!scan.ok) return scan;
86
- const verdict = gateLib.evaluateVerdict(scan.verdict);
87
- return { ok: true, verdict: scan.verdict, counts: scan.counts, block: verdict.block };
105
+ const ctx = context || {};
106
+ const meta = hubLib.readSkillMeta(skillDir);
107
+ const reviewed = reviewScanWithPolicy(scan, {
108
+ slug: ctx.slug || (meta && (meta.slug || meta.name)) || null,
109
+ version: ctx.version || (meta && meta.version) || null,
110
+ pkgDir: skillDir,
111
+ policyPath: options.policyPath,
112
+ });
113
+ const verdict = gateLib.evaluateVerdict(reviewed.verdict);
114
+ return {
115
+ ok: true,
116
+ verdict: reviewed.verdict,
117
+ counts: reviewed.counts,
118
+ block: verdict.block,
119
+ policy: reviewed.policy || null,
120
+ };
88
121
  }
89
122
 
90
123
  module.exports = {
@@ -92,6 +125,7 @@ module.exports = {
92
125
  findVerifyEngine,
93
126
  findPython,
94
127
  runScan,
128
+ reviewScanWithPolicy,
95
129
  scanEngineForHub,
96
130
  hubScanSkill,
97
131
  };
@@ -35,6 +35,7 @@ const usageLib = require('./usage-journal');
35
35
  const cliHelpLib = require('./cli-help');
36
36
  const hostsRegistryLib = require('./hosts-registry');
37
37
  const hostPurgeLib = require('./host-purge');
38
+ const skillsConfigLib = require('./skills-config');
38
39
 
39
40
  const DEFAULT_HOST = '127.0.0.1';
40
41
  const DEFAULT_PORT = 8789;
@@ -44,7 +45,7 @@ const DEFAULT_LIMIT = 100;
44
45
  const MAX_LIMIT = 500;
45
46
  const TOKEN_HEADER = 'x-yotta-view-token';
46
47
  const TOKEN_PLACEHOLDER = '__YOTTA_VIEW_TOKEN__';
47
- const CONFIRM = { unlink: 'unlink', rollback: 'rollback', remove: 'remove', purge: 'purge' };
48
+ const CONFIRM = { unlink: 'unlink', rollback: 'rollback', remove: 'remove', purge: 'purge', hubConfig: 'hub-config' };
48
49
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
49
50
 
50
51
  function nowIso() {
@@ -164,9 +165,19 @@ function overviewPayload(ctx) {
164
165
  discovery: discovery(ctx),
165
166
  });
166
167
  const audit = readJsonlTail(hubAuditPath(ctx.hubDir), 8);
168
+ const hubNow = skillsConfigLib.resolveHub({ homeDir: ctx.homeDir, env: ctx.env });
167
169
  return {
168
170
  version: ctx.version,
169
171
  hubDir: ctx.hubDir,
172
+ // 0.29.2 U4:Hub 位置来源 / 配置覆盖 / 重启提示(面板「Hub 位置」区)。
173
+ hub: {
174
+ dir: ctx.hubDir,
175
+ source: ctx.hubSource || 'default',
176
+ configured: hubNow.configured,
177
+ configFile: hubNow.configFile,
178
+ effectiveNow: hubNow.dir,
179
+ restartRequired: !ctx.hubFlag && !sameDir(hubNow.dir, ctx.hubDir),
180
+ },
170
181
  standard: hubLib.STANDARD_ID,
171
182
  generatedAt: nowIso(),
172
183
  status: {
@@ -204,12 +215,17 @@ function hostsPayload(ctx) {
204
215
  brokenLinks: hostLinks.filter((link) => link.status !== 'ok').length,
205
216
  };
206
217
  });
218
+ const registry = hostsRegistryLib.readHostsRegistry({ homeDir: ctx.homeDir, env: ctx.env });
207
219
  return {
208
220
  generatedAt: found.generatedAt,
209
221
  hubDir: ctx.hubDir,
210
222
  standard: hubLib.STANDARD_ID,
211
223
  hosts,
212
224
  installed: found.installed,
225
+ // 0.29.1 U2:面板只渲染未配对标记(与 CLI hubHostLines 共用单一真源)。
226
+ unpaired: discoveryLib.unpairedMarkers(found),
227
+ excluded: registry.excluded || [],
228
+ overrides: registry.overrides || [],
213
229
  links,
214
230
  hubSkills,
215
231
  summary: {
@@ -219,6 +235,9 @@ function hostsPayload(ctx) {
219
235
  discovered: hosts.filter((host) => host.exists && !host.verified && !host.bridgeOnly).length,
220
236
  bridge: hosts.filter((host) => host.exists && host.bridgeOnly).length,
221
237
  installedMarks: found.installed.length,
238
+ unpairedMarks: discoveryLib.unpairedMarkers(found).length,
239
+ excluded: (registry.excluded || []).length,
240
+ overrides: (registry.overrides || []).length,
222
241
  links: links.length,
223
242
  brokenLinks: links.filter((link) => link.status !== 'ok').length,
224
243
  // 0.29.0 F3:宿主生命周期状态细分(面板分组 / 筛选)。
@@ -603,6 +622,110 @@ function writeHostPurge(ctx, body) {
603
622
  };
604
623
  }
605
624
 
625
+ // ── 宿主标记 / 目录覆盖 / 不接管(0.29.1 U2;与 CLI 同源 hosts.json) ─────────
626
+ function writeHostMark(ctx, body) {
627
+ const dir = String(body.dir || '').trim();
628
+ if (!dir) return { code: 400, payload: { error: '缺少目录。' } };
629
+ const state = String(body.state || '').trim().toLowerCase();
630
+ const result = hostsRegistryLib.markHost({ homeDir: ctx.homeDir, env: ctx.env }, { dir, state });
631
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
632
+ auditViewHostEvent(ctx, { event: 'hosts.mark', dir: result.entry.dir, state, via: 'view' });
633
+ return { code: 200, payload: { action: 'hosts.mark', entry: result.entry } };
634
+ }
635
+
636
+ function writeHostSet(ctx, body) {
637
+ const agentId = String(body.agentId || '').trim().toLowerCase();
638
+ if (!agentId) return { code: 400, payload: { error: '缺少 agentId。' } };
639
+ const registryOpts = { homeDir: ctx.homeDir, env: ctx.env };
640
+ if (body.clear) {
641
+ const result = hostsRegistryLib.clearHostOverride(registryOpts, { agentId });
642
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
643
+ auditViewHostEvent(ctx, { event: 'hosts.set', agentId, cleared: true, dir: result.removed.dir, via: 'view' });
644
+ return { code: 200, payload: { action: 'hosts.set', cleared: true, removed: result.removed } };
645
+ }
646
+ const dir = String(body.dir || '').trim();
647
+ const result = hostsRegistryLib.setHostOverride(registryOpts, { agentId, dir, label: body.label });
648
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
649
+ auditViewHostEvent(ctx, {
650
+ event: 'hosts.set',
651
+ agentId,
652
+ dir: result.entry.dir,
653
+ label: result.entry.label,
654
+ via: 'view',
655
+ });
656
+ return { code: 200, payload: { action: 'hosts.set', entry: result.entry } };
657
+ }
658
+
659
+ function writeHostExclude(ctx, body) {
660
+ const target = String(body.target || '').trim();
661
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
662
+ const result = hostsRegistryLib.excludeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
663
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
664
+ auditViewHostEvent(ctx, {
665
+ event: 'hosts.exclude',
666
+ kind: result.entry.kind,
667
+ value: result.entry.value,
668
+ label: result.entry.label,
669
+ via: 'view',
670
+ });
671
+ return { code: 200, payload: { action: 'hosts.exclude', entry: result.entry, already: Boolean(result.already) } };
672
+ }
673
+
674
+ function writeHostInclude(ctx, body) {
675
+ const target = String(body.target || '').trim();
676
+ if (!target) return { code: 400, payload: { error: '缺少 target(agentId 或目录)。' } };
677
+ const result = hostsRegistryLib.includeHost({ homeDir: ctx.homeDir, env: ctx.env }, { target });
678
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
679
+ auditViewHostEvent(ctx, {
680
+ event: 'hosts.include',
681
+ kind: result.removed.kind,
682
+ value: result.removed.value,
683
+ label: result.removed.label,
684
+ via: 'view',
685
+ });
686
+ return { code: 200, payload: { action: 'hosts.include', removed: result.removed } };
687
+ }
688
+
689
+ /** 0.29.2 U4:面板「Hub 位置」设置 / 清除(写 config.json;重启 view 生效)。 */
690
+ function writeHubConfig(ctx, body) {
691
+ if (String(body.confirm || '') !== CONFIRM.hubConfig) {
692
+ return { code: 400, payload: { error: '确认串不匹配(需要 "' + CONFIRM.hubConfig + '")。' } };
693
+ }
694
+ const configOpts = { homeDir: ctx.homeDir, env: ctx.env };
695
+ const current = skillsConfigLib.resolveHub(configOpts);
696
+ if (body.clear) {
697
+ const result = skillsConfigLib.clearHub(configOpts);
698
+ if (!result.ok) return { code: 400, payload: { error: result.error } };
699
+ if (fs.existsSync(current.dir)) {
700
+ try {
701
+ hubLib.appendAudit(current.dir, { event: 'config.clear', removed: result.removed, via: 'view' });
702
+ } catch (_) { /* best-effort */ }
703
+ }
704
+ return { code: 200, payload: { action: 'config.clear', removed: result.removed, restartRequired: true } };
705
+ }
706
+ const hub = String(body.hub || '').trim();
707
+ const guard = skillsConfigLib.validateHubPath(hub, configOpts);
708
+ 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;
712
+ if (fs.existsSync(auditDir)) {
713
+ try {
714
+ hubLib.appendAudit(auditDir, { event: 'config.set', hub: result.hub, previousHub: current.dir, via: 'view' });
715
+ } catch (_) { /* best-effort */ }
716
+ }
717
+ return {
718
+ code: 200,
719
+ payload: {
720
+ action: 'config.set',
721
+ hub: result.hub,
722
+ previous: current.dir,
723
+ configFile: result.configFile,
724
+ restartRequired: true,
725
+ },
726
+ };
727
+ }
728
+
606
729
  function json(res, code, value) {
607
730
  if (res.writableEnded) return;
608
731
  res.writeHead(code, {
@@ -764,6 +887,11 @@ function handlePost(ctx, pathname, req, res) {
764
887
  if (pathname === '/api/hosts/add') return writeHostAdd(ctx, body);
765
888
  if (pathname === '/api/hosts/remove') return writeHostRemove(ctx, body);
766
889
  if (pathname === '/api/hosts/purge') return writeHostPurge(ctx, body);
890
+ if (pathname === '/api/hosts/mark') return writeHostMark(ctx, body);
891
+ if (pathname === '/api/hosts/set') return writeHostSet(ctx, body);
892
+ if (pathname === '/api/hosts/exclude') return writeHostExclude(ctx, body);
893
+ if (pathname === '/api/hosts/include') return writeHostInclude(ctx, body);
894
+ if (pathname === '/api/hub/config') return writeHubConfig(ctx, body);
767
895
  return { code: 404, payload: { error: 'not found' } };
768
896
  };
769
897
  ctx.queue(run)
@@ -781,6 +909,8 @@ function createHubViewServer(options) {
781
909
  let chain = Promise.resolve();
782
910
  const ctx = {
783
911
  hubDir: path.resolve(opts.hubDir || hubLib.resolveHubDir({})),
912
+ hubSource: opts.hubSource || 'default',
913
+ hubFlag: Boolean(opts.hubFlag),
784
914
  homeDir: opts.homeDir || os.homedir(),
785
915
  env: opts.env || process.env,
786
916
  manifest: opts.manifest || [],
@@ -14,6 +14,7 @@ const os = require('os');
14
14
  const path = require('path');
15
15
  const scanLib = require('./skills-scan');
16
16
  const skillsCliLock = require('./skills-cli-lock');
17
+ const skillsConfigLib = require('./skills-config');
17
18
 
18
19
  const STANDARD_ID = 'yotta-skills-hub/v1';
19
20
  const STATE_VERSION = 1;
@@ -45,11 +46,13 @@ function nowIso() {
45
46
  return new Date().toISOString();
46
47
  }
47
48
 
49
+ /** 解析生效 Hub(含来源):--hub > YOTTA_SKILLS_HUB > config.json > 默认。 */
50
+ function resolveHub(opts) {
51
+ return skillsConfigLib.resolveHub(opts);
52
+ }
53
+
48
54
  function resolveHubDir(opts) {
49
- const options = opts || {};
50
- const explicit = options.hub || process.env.YOTTA_SKILLS_HUB;
51
- if (explicit) return path.resolve(explicit);
52
- return path.join(options.homeDir || os.homedir(), '.yottaskills', 'hub');
55
+ return resolveHub(opts).dir;
53
56
  }
54
57
 
55
58
  function hubPaths(hubDir) {
@@ -1498,6 +1501,7 @@ module.exports = {
1498
1501
  AUDIT_FILE,
1499
1502
  TRASH_RETENTION_DAYS,
1500
1503
  HUB_FAMILY_EXTRAS,
1504
+ resolveHub,
1501
1505
  resolveHubDir,
1502
1506
  hubPaths,
1503
1507
  readHubState,
@@ -8,6 +8,7 @@ const lifecycleLib = require('./install-lifecycle');
8
8
  const healthLib = require('./install-health');
9
9
  const snapshotLib = require('./install-snapshot');
10
10
  const hookAdapterLib = require('./hook-adapter');
11
+ const scanLib = require('./skills-scan');
11
12
 
12
13
  function nowTag() {
13
14
  return new Date().toISOString().replace(/[:.]/g, '-');
@@ -60,6 +61,15 @@ function restoreBackup(target, backup, hadTarget) {
60
61
  return false;
61
62
  }
62
63
 
64
+ /** 0.29.1 U1「只升不降」跳过原因文案(equal / local-ahead / unknown)。 */
65
+ function versionSkipNote(relation, installed, target) {
66
+ if (relation === 'equal') return '已是最新';
67
+ if (relation === 'local-ahead') {
68
+ return '本地领先 v' + installed + ' > 目标 v' + target + ',保留不降级(--force 可强制)';
69
+ }
70
+ return '无法比较版本(本地 v' + installed + ' / 目标 v' + target + '),保留(--force 可强制)';
71
+ }
72
+
63
73
  function createInstaller(deps) {
64
74
  const homeDir = deps.homeDir || os.homedir();
65
75
  const runPhase = deps.runPhase || lifecycleLib.runPhase;
@@ -83,8 +93,18 @@ function createInstaller(deps) {
83
93
  return function installOne(skill, dest, opts) {
84
94
  const target = path.join(dest, skill.slug);
85
95
  const existingVersion = deps.readInstalledVersion(target);
86
- if (!opts.force && existingVersion === skill.version) {
87
- return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
96
+ // 0.29.1 U1「只升不降」:pin 模式目标即最终版本,入口即可判定;
97
+ // range / latest 模式(目标非最终版本)在 npm pack 解析出精确版本后复判(见下方)。
98
+ const relation = scanLib.versionRelation(existingVersion, skill.version);
99
+ if (!opts.force && opts.pin !== false &&
100
+ (relation === 'equal' || relation === 'local-ahead' || relation === 'unknown')) {
101
+ return {
102
+ skill,
103
+ status: 'skip',
104
+ version: existingVersion,
105
+ note: versionSkipNote(relation, existingVersion, skill.version),
106
+ exitCode: 0,
107
+ };
88
108
  }
89
109
  if (opts.dryRun) {
90
110
  // 0.29.0 D1:只读预览 —— 不发网络请求、不写任何文件(含台账)。
@@ -124,6 +144,23 @@ function createInstaller(deps) {
124
144
  return { skill, status: 'fail', version: null, note: packed.error, exitCode: 1 };
125
145
  }
126
146
 
147
+ // 0.29.1 U1:解析出精确版本后、任何解压 / 快照 / 写入之前复判(latest / range 路径)。
148
+ const resolvedVersion = packed.resolved || (skill.version === 'latest' ? null : skill.version);
149
+ if (!opts.force && existingVersion) {
150
+ const resolvedRelation = resolvedVersion
151
+ ? scanLib.versionRelation(existingVersion, resolvedVersion)
152
+ : 'unknown';
153
+ if (resolvedRelation === 'equal' || resolvedRelation === 'local-ahead' || resolvedRelation === 'unknown') {
154
+ return {
155
+ skill,
156
+ status: 'skip',
157
+ version: existingVersion,
158
+ note: versionSkipNote(resolvedRelation, existingVersion, resolvedVersion || skill.version),
159
+ exitCode: 0,
160
+ };
161
+ }
162
+ }
163
+
127
164
  const extractDir = path.join(tmp, 'extract');
128
165
  fs.mkdirSync(extractDir, { recursive: true });
129
166
  extracted = deps.extractTarball(packed.tarball, extractDir);
@@ -194,12 +194,16 @@ function whereInfo(opts) {
194
194
  const options = opts || {};
195
195
  const running = safeRealpath(process.argv[1] || '');
196
196
  const registry = readSelfRegistry(options);
197
+ const hubResolved = hubLib.resolveHub(options);
197
198
  return {
198
199
  action: 'where',
199
200
  running,
200
201
  version: readPackageVersion(packageRoot()),
201
202
  defaultDir: defaultSelfDir(options),
202
- hubDir: hubLib.resolveHubDir(options),
203
+ hubDir: hubResolved.dir,
204
+ hubSource: hubResolved.source,
205
+ hubConfigured: hubResolved.configured,
206
+ hubConfigFile: hubResolved.configFile,
203
207
  registry: selfRegistryPath(options),
204
208
  installs: registry.installs.map((item) => ({
205
209
  dir: item.dir,