@morehao/dsh-skills-console 0.3.8 → 0.4.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/README.md +55 -65
- package/client.js +206 -267
- package/cordis.patch.yml +2 -1
- package/lib/agent-table.json +133 -64
- package/lib/core/agents.js +68 -9
- package/lib/core/exporter.js +137 -0
- package/lib/core/inventory.js +73 -39
- package/lib/core/migrations.js +178 -0
- package/lib/core/paths.js +13 -0
- package/lib/core/policy.js +373 -0
- package/lib/core/remote.js +3 -1
- package/lib/core/roots.js +182 -0
- package/lib/core/service.js +391 -137
- package/lib/core/shadow.js +195 -0
- package/lib/core/skill-md.js +23 -4
- package/lib/core/state.js +293 -42
- package/lib/core/visibility.js +145 -0
- package/lib/index.js +27 -0
- package/lib/routes.js +13 -3
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureLink, inspectTarget, removeLink } from './links.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 导出(export)= 把 canonical 里的技能显式暴露给**不读共享根**的其他 agent。
|
|
7
|
+
*
|
|
8
|
+
* 与 v1 的「接入/链接」的区别只有三条,但每条都改变行为:
|
|
9
|
+
* - 默认不导出任何 agent(`settings.exportTargets` 默认 `[]`);
|
|
10
|
+
* - DSH 自己的扫描根不参与导出(`~/.dsh/skills` 是停用桩的位置,不是可见性通道);
|
|
11
|
+
* - 只有「建链真的成功/已受管」才记账;删链失败不清账。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 建链失败的统一形状:由调用方决定是 400 还是只记 warning。 */
|
|
15
|
+
function failure(target, reason, extra = {}) {
|
|
16
|
+
return { target, reason, ...extra };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 逐 agent 导出。`record` 只在本次真的存在一条指向 canonical 的链时被调用
|
|
21
|
+
* (新建的、或此前已受管但没记账的「接管」场景),避免账本里出现不存在的链。
|
|
22
|
+
*/
|
|
23
|
+
export function exportSkills({ paths, registry, name, targets = [], record = () => {} }) {
|
|
24
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
25
|
+
const result = { created: [], skipped: [], failed: [] };
|
|
26
|
+
if (!fs.existsSync(canonicalPath)) {
|
|
27
|
+
result.failed.push(failure('*', 'not-installed'));
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
for (const target of targets) {
|
|
33
|
+
if (!target || seen.has(target)) continue;
|
|
34
|
+
seen.add(target);
|
|
35
|
+
|
|
36
|
+
let resolved;
|
|
37
|
+
try {
|
|
38
|
+
resolved = registry.resolve(target);
|
|
39
|
+
} catch {
|
|
40
|
+
result.failed.push(failure(target, 'unknown-target'));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// 导出资格由注册表统一算好(扫描根 / 共享根 / 共享 home 三种原因),
|
|
44
|
+
// 这里不再按 kind 判断身份 —— 以后宿主多一个扫描根,只需要改 roots 一张表。
|
|
45
|
+
if (resolved.exportBlocked) {
|
|
46
|
+
result.skipped.push({ target, reason: resolved.exportBlocked });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!resolved.exists) {
|
|
50
|
+
result.failed.push(failure(target, 'target-missing', { dir: resolved.dir }));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const linkPath = path.join(resolved.dir, name);
|
|
55
|
+
const outcome = ensureLink({ linkPath, canonicalPath });
|
|
56
|
+
if (outcome.created) {
|
|
57
|
+
result.created.push({ target, linkPath, mode: outcome.mode || 'relative' });
|
|
58
|
+
record({ target, agentDir: resolved.dir, linkPath, mode: outcome.mode || 'relative', created: true });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (outcome.state === 'managed' || outcome.state === 'managed-external') {
|
|
62
|
+
result.skipped.push({ target, reason: outcome.state, linkPath });
|
|
63
|
+
record({ target, agentDir: resolved.dir, linkPath, mode: outcome.mode || 'relative', created: false });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
result.skipped.push({ target, reason: outcome.state, linkPath });
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 摘掉一条导出链。默认只摘「指向 canonical」的;`allowDangling` 用于 canonical
|
|
73
|
+
* 已经不在时回收自家悬空链——此时用链名兜底,绝不按 `force` 整片删。
|
|
74
|
+
*/
|
|
75
|
+
export function unexportSkill({ linkPath, canonicalPath, name, allowDangling = false }) {
|
|
76
|
+
if (!linkPath) return { removed: false, reason: 'no-path' };
|
|
77
|
+
const direct = removeLink({ linkPath, canonicalPath, force: false });
|
|
78
|
+
if (direct.removed) return { removed: true, linkPath };
|
|
79
|
+
if (direct.reason !== 'not-managed' || !canonicalPath) return { ...direct, linkPath };
|
|
80
|
+
|
|
81
|
+
if (!allowDangling) return { ...direct, linkPath };
|
|
82
|
+
const dangling = isOurDanglingLink(linkPath, name);
|
|
83
|
+
if (!dangling) return { removed: false, reason: 'not-managed', linkPath };
|
|
84
|
+
const forced = removeLink({ linkPath, canonicalPath, force: true });
|
|
85
|
+
return { ...forced, linkPath, dangling: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 链还在、但目标已经不存在,且链名对得上 —— 只在这种情况下允许强制回收。 */
|
|
89
|
+
export function isOurDanglingLink(linkPath, name) {
|
|
90
|
+
let st;
|
|
91
|
+
try {
|
|
92
|
+
st = fs.lstatSync(linkPath);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (!st.isSymbolicLink()) return false;
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = fs.readlinkSync(linkPath);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const normalized = raw.split(path.sep).join('/');
|
|
104
|
+
return normalized.endsWith(`/skills/${name}`) || path.basename(normalized) === name;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 汇总一条技能在各 agent 目录里的**导出**现场(`/state` 的 `installs[].exports`)。
|
|
109
|
+
*
|
|
110
|
+
* 只认「指向 canonical 的链接」:`foreign-link` 指向别的副本、`real` 是本地实体目录,
|
|
111
|
+
* 都不代表这份技能被导出过去。读共享根的 Agent 也不需要导出链——它们装好即见,
|
|
112
|
+
* 现场残留的链(历史版本或手工建的)同样不列。
|
|
113
|
+
*/
|
|
114
|
+
export function agentPresence({ paths, registry, name }) {
|
|
115
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (const entry of registry.detected()) {
|
|
118
|
+
// DSH 的扫描根没有「导出态」:那里要么是真身、要么是停用桩,不列进 Agent 现场
|
|
119
|
+
if (entry.exportBlocked === 'dsh-native-root') continue;
|
|
120
|
+
// 读共享根的 Agent 不走导出这条路;把它的链算成导出会误导「为什么它在导出清单里」
|
|
121
|
+
if (entry.exportBlocked === 'shared-root') continue;
|
|
122
|
+
const linkPath = path.join(entry.dir, name);
|
|
123
|
+
const live = inspectTarget({ linkPath, canonicalPath });
|
|
124
|
+
// 只有真的指向 canonical 的链才算导出(未记账的 managed-external 仍保留:
|
|
125
|
+
// 它是「现场有、可接管」的导出,不是别人的东西)
|
|
126
|
+
if (live.state !== 'managed' && live.state !== 'managed-external') continue;
|
|
127
|
+
rows.push({
|
|
128
|
+
target: entry.target,
|
|
129
|
+
label: entry.label,
|
|
130
|
+
dir: entry.dir,
|
|
131
|
+
linkPath,
|
|
132
|
+
state: live.state,
|
|
133
|
+
broken: Boolean(live.broken),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
package/lib/core/inventory.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { contentHash } from './hash.js';
|
|
4
|
-
import {
|
|
4
|
+
import { agentPresence } from './exporter.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* CLI 全局 lock(v3)。读不到、版本不认识、结构损坏都返回空账 ——
|
|
@@ -75,13 +75,13 @@ export function readFrontmatterDescription(skillPath) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function statusOf({ install, check, locallyModified,
|
|
78
|
+
function statusOf({ install, check, locallyModified, brokenExports, subscribed }) {
|
|
79
79
|
if (!install) return 'unmanaged';
|
|
80
80
|
if (locallyModified && check?.status === 'update-available') return 'conflict';
|
|
81
81
|
if (locallyModified) return 'locally-modified';
|
|
82
82
|
if (check?.status === 'upstream-deleted') return 'upstream-deleted';
|
|
83
83
|
if (check?.status === 'update-available') return 'update-available';
|
|
84
|
-
if (
|
|
84
|
+
if (brokenExports.length > 0) return 'broken-link';
|
|
85
85
|
if (check?.status === 'current') return 'current';
|
|
86
86
|
// 没有可用的上游订阅 = 永远拿不到更新结论(检查更新只覆盖有订阅的技能)。
|
|
87
87
|
// 这跟「这次没判定出来」是两件事,混进 unknown 会让待检查计数永远降不下来。
|
|
@@ -90,42 +90,44 @@ function statusOf({ install, check, locallyModified, brokenLinks, subscribed })
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* 右栏账本:canonical + installs +
|
|
94
|
-
*
|
|
93
|
+
* 右栏账本:canonical + installs + exports + legacyShadows。
|
|
94
|
+
* 左栏(模型可见)由 `visibility.js` 扫根 + policy 判定得出,两侧差异就是诊断数据,
|
|
95
|
+
* 不做自动修复。
|
|
95
96
|
*/
|
|
96
|
-
export function buildLedger({ paths, state, registry, lock,
|
|
97
|
+
export function buildLedger({ paths, state, registry, lock, visibility, now = new Date().toISOString() }) {
|
|
97
98
|
const scanned = scanCanonical(paths);
|
|
98
|
-
const targets = registry.detected();
|
|
99
99
|
const installs = new Map(state.installs.map((item) => [item.name, item]));
|
|
100
|
-
// 只有「state 里真有这条订阅记录」才算可检查:订阅被删掉(keepInstalled)后
|
|
101
|
-
// install.subscriptionId 会指向一个不存在的 id,那种行同样永远等不到结论。
|
|
102
100
|
const subscriptions = new Set((state.subscriptions || []).map((item) => item.id));
|
|
101
|
+
const exportsBySkill = new Map();
|
|
102
|
+
for (const row of state.exports || []) {
|
|
103
|
+
const list = exportsBySkill.get(row.skill);
|
|
104
|
+
if (list) list.push(row);
|
|
105
|
+
else exportsBySkill.set(row.skill, [row]);
|
|
106
|
+
}
|
|
103
107
|
|
|
104
108
|
const rows = scanned.map((entry) => {
|
|
105
109
|
const install = installs.get(entry.name);
|
|
106
110
|
const lockEntry = lock.skills?.[entry.name] || null;
|
|
107
|
-
const
|
|
111
|
+
const recordExports = exportsBySkill.get(entry.name) || [];
|
|
112
|
+
const presence = agentPresence({ paths, registry, name: entry.name });
|
|
108
113
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
label: target.label,
|
|
121
|
-
linkPath,
|
|
122
|
-
state: stateName,
|
|
114
|
+
// 导出矩阵:账本里的记录 + 各 agent 目录里真的指向 canonical 的链
|
|
115
|
+
// (`agentPresence` 已滤掉 foreign-link / real / 共享根 Agent,它们都不算导出)
|
|
116
|
+
const exportsRows = [];
|
|
117
|
+
for (const item of presence) {
|
|
118
|
+
const recorded = recordExports.find((row) => row.target === item.target) || null;
|
|
119
|
+
exportsRows.push({
|
|
120
|
+
target: item.target,
|
|
121
|
+
label: item.label,
|
|
122
|
+
dir: item.dir,
|
|
123
|
+
linkPath: item.linkPath,
|
|
124
|
+
state: recorded && item.state === 'managed-external' ? 'managed' : item.state,
|
|
123
125
|
recorded: Boolean(recorded),
|
|
124
|
-
broken: Boolean(
|
|
126
|
+
broken: Boolean(item.broken),
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
const
|
|
130
|
+
const brokenExports = exportsRows.filter((item) => item.broken);
|
|
129
131
|
let currentHash = null;
|
|
130
132
|
let locallyModified = false;
|
|
131
133
|
if (install && entry.kind === 'dir') {
|
|
@@ -134,8 +136,14 @@ export function buildLedger({ paths, state, registry, lock, visibleNames = new M
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
const check = state.checks?.[entry.name] || null;
|
|
137
|
-
const
|
|
139
|
+
const visible = visibility?.byName?.get(entry.name) || null;
|
|
138
140
|
const subscribed = Boolean(install?.subscriptionId) && subscriptions.has(install.subscriptionId);
|
|
141
|
+
const enabled = install ? install.enabled !== false : true;
|
|
142
|
+
// 只要存在 winner 就判「赢的是不是这一份」:技能自己声明 manual-only 时也不可见,
|
|
143
|
+
// 但它确实在扫描根里,不能被 UI 说成「扫描目录里没有它」。
|
|
144
|
+
const winnerIsCanonical = visible?.winnerPath
|
|
145
|
+
? path.resolve(visible.winnerPath) === path.resolve(entry.path)
|
|
146
|
+
: null;
|
|
139
147
|
return {
|
|
140
148
|
name: entry.name,
|
|
141
149
|
path: entry.path,
|
|
@@ -157,25 +165,51 @@ export function buildLedger({ paths, state, registry, lock, visibleNames = new M
|
|
|
157
165
|
currentHash,
|
|
158
166
|
installedAt: install?.installedAt ?? null,
|
|
159
167
|
updatedAt: install?.updatedAt ?? null,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
status: statusOf({ install, check, locallyModified,
|
|
168
|
+
enabled,
|
|
169
|
+
/** @deprecated v2 起用 `enabled`;留着只为一份 minor 内的缓存客户端不白屏 */
|
|
170
|
+
mode: enabled ? 'on' : 'off',
|
|
171
|
+
exports: exportsRows,
|
|
172
|
+
brokenExports: brokenExports.length,
|
|
173
|
+
status: statusOf({ install, check, locallyModified, brokenExports, subscribed }),
|
|
166
174
|
check,
|
|
167
|
-
visible:
|
|
168
|
-
visibleSource:
|
|
169
|
-
|
|
175
|
+
visible: Boolean(visible?.visible),
|
|
176
|
+
visibleSource: visible?.winnerSource ?? null,
|
|
177
|
+
visibility: {
|
|
178
|
+
dsh: Boolean(visible?.visible),
|
|
179
|
+
shadowed: Boolean(visible?.shadowed),
|
|
180
|
+
winnerSource: visible?.winnerSource ?? null,
|
|
181
|
+
sources: visible?.sources ?? {},
|
|
182
|
+
winnerPath: visible?.winnerPath ?? null,
|
|
183
|
+
// 模型看到的那份不是这一份(同名副本在更小的 rank 上,例如 `x.bak-2026…/`)
|
|
184
|
+
winnerIsCanonical,
|
|
185
|
+
nameConflict: Boolean(visible?.nameConflict),
|
|
186
|
+
shadowedBy: visible?.shadowedBy ?? [],
|
|
187
|
+
// 被本地策略停用(键名保留一版,客户端文案已同步)
|
|
188
|
+
policyHidden: !enabled && Boolean(visible?.policyHidden),
|
|
189
|
+
// canonical 自己在 frontmatter 里声明 disable-model-invocation:
|
|
190
|
+
// 只影响模型调用,`/` 手动调用仍可用,不是「技能丢了」。
|
|
191
|
+
selfHidden: Boolean(visible?.foreignHidden && winnerIsCanonical === true),
|
|
192
|
+
hiddenBy: visible?.hiddenBy ?? [],
|
|
193
|
+
// 界面停用、但更靠前的同名副本仍然可见(项目 100/200 或 user-dsh 400):
|
|
194
|
+
// 这条差异必须让 UI 说得出来。键名保留一版,语义改为「停用不了副本」。
|
|
195
|
+
shadowScopeMismatch: !enabled && winnerIsCanonical === false,
|
|
196
|
+
},
|
|
197
|
+
note: entry.kind === 'broken'
|
|
198
|
+
? 'canonical 里是一个悬空链接'
|
|
199
|
+
: entry.hasSkillFile
|
|
200
|
+
? null
|
|
201
|
+
: '缺少 SKILL.md,DSH 无法解析',
|
|
202
|
+
scannedAt: now,
|
|
170
203
|
};
|
|
171
204
|
});
|
|
172
205
|
|
|
173
206
|
// 左栏有、右栏无:别人装的 / 别的 root 里的
|
|
174
207
|
const visibleOnly = [];
|
|
175
|
-
for (const [name,
|
|
208
|
+
for (const [name, item] of visibility?.byName || []) {
|
|
209
|
+
if (!item.visible) continue;
|
|
176
210
|
if (rows.some((row) => row.name === name)) continue;
|
|
177
|
-
visibleOnly.push({ name, source });
|
|
211
|
+
visibleOnly.push({ name, source: item.winnerSource, path: item.path, hostOnly: item.hostOnly });
|
|
178
212
|
}
|
|
179
213
|
|
|
180
|
-
return { rows, visibleOnly, scannedAt:
|
|
214
|
+
return { rows, visibleOnly, scannedAt: now };
|
|
181
215
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { removeLink } from './links.js';
|
|
4
|
+
import { reapShadowLeftovers, removeShadow } from './shadow.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 状态迁移的**文件侧**执行。结构改写已经在 `state.upgradeStateV1` / `upgradeStateV2`
|
|
8
|
+
* 做完并落盘,这里只按 migration / legacyShadows 做幂等收尾:
|
|
9
|
+
*
|
|
10
|
+
* 1. v1 遗留:删掉历史上为了「提升 rank」而建的 DSH 侧链接,剪掉悬空导出记录;
|
|
11
|
+
* **不再补建停用桩**(v0.4.0 起启停是 policy provider 的事)。
|
|
12
|
+
* 2. v2→v3:逐条清理 `legacyShadows[]` 里的旧停用桩。删不掉(foreign / link / locked)
|
|
13
|
+
* 的保留并写原因;`orphan` 不删(删了会让原本隐藏的技能突然可见);`project:*`
|
|
14
|
+
* 只清理桩,不转成全局停用。
|
|
15
|
+
* 3. 写 manifest(`v1-to-v3-*` / `v2-to-v3-*`,planned → done),7 天后清理。
|
|
16
|
+
*
|
|
17
|
+
* 不做的事:不删外来链、不删真实目录、不动 canonical、不动 v3 lock。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MANIFEST_VERSION = 2;
|
|
21
|
+
const BACKUP_KEEP_DAYS = 7;
|
|
22
|
+
|
|
23
|
+
/** orphan 与「删失败」的行会留在 legacyShadows,等用户手动处理或启用时再清。 */
|
|
24
|
+
function manifestName(migration, now) {
|
|
25
|
+
const prefix = migration?.from === 1 ? 'v1-to-v3' : 'v2-to-v3';
|
|
26
|
+
return `${prefix}-${now.replace(/[:.]/g, '-')}.json`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeManifest(paths, file, payload) {
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(paths.migrationsDir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(path.join(paths.migrationsDir, file), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
33
|
+
return path.join(paths.migrationsDir, file);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isMigrationManifest(name) {
|
|
40
|
+
return name.startsWith('v1-to-') || name.startsWith('v2-to-');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 清理过期的迁移清单(默认 7 天)。state-v<n> 的原始状态副本不在清理范围内。 */
|
|
44
|
+
export function pruneMigrationBackups({ paths, now = Date.now() }) {
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdirSync(paths.migrationsDir);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const removed = [];
|
|
52
|
+
for (const name of entries) {
|
|
53
|
+
if (!isMigrationManifest(name)) continue;
|
|
54
|
+
const file = path.join(paths.migrationsDir, name);
|
|
55
|
+
try {
|
|
56
|
+
const stat = fs.statSync(file);
|
|
57
|
+
if (now - stat.mtimeMs > BACKUP_KEEP_DAYS * 24 * 3600 * 1000) {
|
|
58
|
+
fs.rmSync(file, { force: true });
|
|
59
|
+
removed.push(name);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* 忽略 */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return removed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 旧桩删除结果 → legacyShadows 里保留的原因。 */
|
|
69
|
+
function keepReason(outcome) {
|
|
70
|
+
if (!outcome) return 'locked';
|
|
71
|
+
if (outcome.reason === 'not-managed') return 'foreign';
|
|
72
|
+
if (outcome.reason === 'absent') return 'absent';
|
|
73
|
+
return outcome.reason || 'locked';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 执行(或继续执行)迁移。返回 `{ ok, migrated, report }`。
|
|
78
|
+
* 任何一步失败都不回滚已完成的步骤——每一步本身是幂等的,下次启动接着做。
|
|
79
|
+
*/
|
|
80
|
+
export function ensureMigrated({ paths, store, logger = () => {} }) {
|
|
81
|
+
const migration = store.state.migration;
|
|
82
|
+
if (!migration || migration.status === 'done') return { ok: true, migrated: false };
|
|
83
|
+
if (store.readOnly) return { ok: false, migrated: false, reason: 'state-readonly' };
|
|
84
|
+
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
const report = {
|
|
87
|
+
version: MANIFEST_VERSION,
|
|
88
|
+
at: now,
|
|
89
|
+
from: migration.from ?? 2,
|
|
90
|
+
to: 3,
|
|
91
|
+
phase: 'planned',
|
|
92
|
+
linksRemoved: [],
|
|
93
|
+
linksKept: [],
|
|
94
|
+
exportsPruned: [],
|
|
95
|
+
legacyScopeProjectsDropped: [],
|
|
96
|
+
legacyShadowsRemoved: [],
|
|
97
|
+
legacyShadowsKept: [],
|
|
98
|
+
legacyShadowsOrphans: [],
|
|
99
|
+
};
|
|
100
|
+
const manifest = manifestName(migration, now);
|
|
101
|
+
writeManifest(paths, manifest, report);
|
|
102
|
+
|
|
103
|
+
// ① v1 遗留:删历史 DSH 侧链接:只删「指向 canonical 的同名链」,外来链/真实目录一律放过
|
|
104
|
+
for (const link of migration.legacyLinks || []) {
|
|
105
|
+
if (!link || typeof link.linkPath !== 'string') continue;
|
|
106
|
+
const canonicalPath = path.join(paths.canonical, link.skill);
|
|
107
|
+
const result = removeLink({ linkPath: link.linkPath, canonicalPath, force: false });
|
|
108
|
+
if (result.removed) report.linksRemoved.push(link.linkPath);
|
|
109
|
+
else report.linksKept.push({ linkPath: link.linkPath, skill: link.skill, reason: result.reason });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ② 剪悬空导出记录:磁盘上没有这条链,就不要留在账上
|
|
113
|
+
const aliveExports = [];
|
|
114
|
+
for (const row of store.state.exports || []) {
|
|
115
|
+
if (row.linkPath && fs.existsSync(row.linkPath)) aliveExports.push(row);
|
|
116
|
+
else report.exportsPruned.push(row.linkPath || `${row.target}:${row.skill}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ③ v1 遗留的 legacyScopeProjects 只记账后丢弃:项目级启停已移除(设计 §4.4.1)
|
|
120
|
+
const installs = (store.state.installs || []).map((install) => {
|
|
121
|
+
const next = { ...install };
|
|
122
|
+
if (Array.isArray(next.legacyScopeProjects) && next.legacyScopeProjects.length > 0) {
|
|
123
|
+
report.legacyScopeProjectsDropped.push({ name: next.name, roots: [...next.legacyScopeProjects] });
|
|
124
|
+
}
|
|
125
|
+
delete next.legacyScopeProjects;
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ④ v2→v3:清理旧停用桩(成功就消失,失败/orphan 留在 legacyShadows)
|
|
130
|
+
const legacyShadows = [];
|
|
131
|
+
for (const row of store.state.legacyShadows || []) {
|
|
132
|
+
if (row.reason === 'orphan') {
|
|
133
|
+
legacyShadows.push(row);
|
|
134
|
+
report.legacyShadowsOrphans.push({ skill: row.skill, stubPath: row.stubPath });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const outcome = removeShadow({ stubPath: row.stubPath, name: row.skill });
|
|
138
|
+
if (outcome.removed || outcome.reason === 'absent') {
|
|
139
|
+
report.legacyShadowsRemoved.push({ skill: row.skill, stubPath: row.stubPath, via: outcome.reason || 'removed' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const kept = { ...row, reason: keepReason(outcome) };
|
|
143
|
+
legacyShadows.push(kept);
|
|
144
|
+
report.legacyShadowsKept.push({ skill: row.skill, stubPath: row.stubPath, reason: kept.reason });
|
|
145
|
+
logger('migration.legacy-shadow.kept', { skill: row.skill, stubPath: row.stubPath, reason: kept.reason });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
store.update((draft) => {
|
|
149
|
+
draft.installs = installs;
|
|
150
|
+
draft.exports = aliveExports;
|
|
151
|
+
draft.legacyShadows = legacyShadows;
|
|
152
|
+
draft.migration = {
|
|
153
|
+
...(draft.migration || {}),
|
|
154
|
+
from: migration.from ?? 2,
|
|
155
|
+
to: 3,
|
|
156
|
+
status: 'done',
|
|
157
|
+
completedAt: now,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
report.phase = 'done';
|
|
162
|
+
report.completedAt = now;
|
|
163
|
+
writeManifest(paths, manifest, report);
|
|
164
|
+
pruneMigrationBackups({ paths });
|
|
165
|
+
// 以前中断留下的 `.shadow-*` 残骸,顺手清掉
|
|
166
|
+
reapShadowLeftovers(paths.dshGlobalSkills);
|
|
167
|
+
logger('state.migrated', {
|
|
168
|
+
from: report.from,
|
|
169
|
+
linksRemoved: report.linksRemoved.length,
|
|
170
|
+
linksKept: report.linksKept.length,
|
|
171
|
+
exports: aliveExports.length,
|
|
172
|
+
exportsPruned: report.exportsPruned.length,
|
|
173
|
+
legacyShadowsRemoved: report.legacyShadowsRemoved.length,
|
|
174
|
+
legacyShadowsKept: report.legacyShadowsKept.length,
|
|
175
|
+
legacyShadowsOrphans: report.legacyShadowsOrphans.length,
|
|
176
|
+
});
|
|
177
|
+
return { ok: true, migrated: true, report };
|
|
178
|
+
}
|
package/lib/core/paths.js
CHANGED
|
@@ -12,6 +12,11 @@ export function createPaths(env = process.env, overrides = {}) {
|
|
|
12
12
|
const dshHome = overrides.dshHome ?? env.DSH_HOME ?? path.join(home, '.dsh');
|
|
13
13
|
const stateDir =
|
|
14
14
|
overrides.stateDir ?? env.DSH_SKILLS_CONSOLE_HOME ?? path.join(dshHome, 'skills-console');
|
|
15
|
+
// 宿主 user-agents 扫描根。默认 `~/.agents`;DSH_AGENTS_HOME 指到别处时 canonical
|
|
16
|
+
// 就不在宿主的扫描根里了 —— 这种环境本插件不提供可见性兜底(/diagnostics 会报
|
|
17
|
+
// `canonical-not-scanned`),所以这个标志只用于如实告知,不再用来切换机制。
|
|
18
|
+
const defaultAgentsHome = path.join(home, '.agents');
|
|
19
|
+
const agentsHome = overrides.agentsHome ?? env.DSH_AGENTS_HOME ?? defaultAgentsHome;
|
|
15
20
|
// 与 skills CLI 的 getSkillLockPath 对齐:设了 XDG_STATE_HOME 时 lock 在 XDG 下
|
|
16
21
|
const lockFile = env.XDG_STATE_HOME
|
|
17
22
|
? path.join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json')
|
|
@@ -22,14 +27,22 @@ export function createPaths(env = process.env, overrides = {}) {
|
|
|
22
27
|
configHome,
|
|
23
28
|
dshHome,
|
|
24
29
|
stateDir,
|
|
30
|
+
/** 宿主 user-agents 扫描根(rank 500 的父目录) */
|
|
31
|
+
agentsHome,
|
|
32
|
+
/** 宿主 user-agents 技能根(rank 500):`<agentsHome>/skills` */
|
|
33
|
+
userAgentsSkills: path.join(agentsHome, 'skills'),
|
|
25
34
|
/** canonical 真身:CLI 唯一的落点(F1) */
|
|
26
35
|
canonical: path.join(home, '.agents', 'skills'),
|
|
36
|
+
/** canonical 是否就是宿主的 user-agents 扫描根(否则本插件不保证 DSH 可见性) */
|
|
37
|
+
canonicalScanned: path.resolve(agentsHome) === path.resolve(defaultAgentsHome),
|
|
27
38
|
/** CLI 全局 lock(F4);XDG_STATE_HOME 优先,与 CLI 一致 */
|
|
28
39
|
lockFile,
|
|
29
40
|
/** DSH 原生 user-dsh 根(rank 400) */
|
|
30
41
|
dshGlobalSkills: path.join(dshHome, 'skills'),
|
|
31
42
|
stateFile: path.join(stateDir, 'state.json'),
|
|
32
43
|
backupsDir: path.join(stateDir, 'backups'),
|
|
44
|
+
/** state 迁移的备份与 manifest(v1 → v2),与技能备份分开存 */
|
|
45
|
+
migrationsDir: path.join(stateDir, 'migrations'),
|
|
33
46
|
trashDir: path.join(stateDir, 'trash'),
|
|
34
47
|
logFile: path.join(stateDir, 'dsh-skills-console.log'),
|
|
35
48
|
};
|