@morehao/dsh-skills-console 0.3.7 → 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.
@@ -0,0 +1,195 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * 停用标记(shadow):在某个 DSH 扫描根里放一个**真实目录**,用官方 frontmatter 语义
7
+ * 把同名技能挡在模型目录之外。
8
+ *
9
+ * 为什么不是软链、也不是改 canonical:
10
+ * - 改 canonical 的 `SKILL.md` 会被安装/更新整份覆盖(F13),停用态会丢;
11
+ * - 软链在这里没有意义:扫描器只看目录内容,链接指向的仍是可用的 SKILL.md;
12
+ * - 停用必须是「可见性」事实,而不是「链接存在与否」的推断。
13
+ */
14
+
15
+ export const SHADOW_MARKER = '.dsh-skills-console.json';
16
+ export const SHADOW_TOOL = '@morehao/dsh-skills-console';
17
+ export const SHADOW_DESCRIPTION = '已停用(由 DSH 技能控制台生成,请勿使用)';
18
+
19
+ const MARKER_VERSION = 1;
20
+
21
+ /** 停用态 SKILL.md:三个字段缺一不可,见 design §5.2。 */
22
+ export function shadowSkillMd({ name, description = SHADOW_DESCRIPTION }) {
23
+ return [
24
+ '---',
25
+ `name: ${name}`,
26
+ `description: "${description}"`,
27
+ 'disable-model-invocation: true',
28
+ 'user-invocable: false',
29
+ '---',
30
+ '',
31
+ `# ${name}(已停用)`,
32
+ '',
33
+ '本目录由 DSH 技能控制台生成,用来在当前扫描根内隐藏同名技能。',
34
+ '技能真身仍在 `~/.agents/skills`,没有删除。',
35
+ '',
36
+ '恢复方式二选一:',
37
+ '- 在技能控制台里点「启用」;',
38
+ '- 直接删除本目录(会退化为「可见」,不会丢技能)。',
39
+ '',
40
+ ].join('\n');
41
+ }
42
+
43
+ function isPlainObject(value) {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ /** 读标记;不存在或不是本工具的标记都返回 null。 */
48
+ export function readShadowMarker(dirPath) {
49
+ try {
50
+ const raw = fs.readFileSync(path.join(dirPath, SHADOW_MARKER), 'utf8');
51
+ const parsed = JSON.parse(raw);
52
+ if (!isPlainObject(parsed)) return null;
53
+ if (parsed.tool !== SHADOW_TOOL || parsed.kind !== 'shadow') return null;
54
+ return parsed;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 判定一个路径是不是「我们建的停用标记」。
62
+ * - `absent`:什么都没有(可以建);
63
+ * - `ours`:标记与 SKILL.md 都在(幂等);
64
+ * - `broken`:标记在但 SKILL.md 丢了/被改(要修,不能当成功);
65
+ * - `foreign`:同名目录是别的东西(真实技能 / 别人的目录)——不覆盖;
66
+ * - `link`:是软链(export 模式下的导出链),要先摘链才能建标记。
67
+ */
68
+ export function inspectShadow(stubPath, name) {
69
+ let st;
70
+ try {
71
+ st = fs.lstatSync(stubPath);
72
+ } catch {
73
+ return { state: 'absent', stubPath };
74
+ }
75
+ if (st.isSymbolicLink()) return { state: 'link', stubPath };
76
+ if (!st.isDirectory()) return { state: 'foreign', stubPath, reason: 'not-a-directory' };
77
+
78
+ const marker = readShadowMarker(stubPath);
79
+ if (!marker) return { state: 'foreign', stubPath, reason: 'no-marker' };
80
+ if (marker.skill !== name) return { state: 'foreign', stubPath, reason: 'marker-name-mismatch' };
81
+
82
+ const skillMdPath = path.join(stubPath, 'SKILL.md');
83
+ let raw;
84
+ try {
85
+ raw = fs.readFileSync(skillMdPath, 'utf8');
86
+ } catch {
87
+ return { state: 'broken', stubPath, reason: 'skill-md-missing', marker };
88
+ }
89
+ if (!/^disable-model-invocation:\s*true\s*$/m.test(raw)) {
90
+ return { state: 'broken', stubPath, reason: 'flag-missing', marker };
91
+ }
92
+ if (!new RegExp(`^name:\\s*${name}\\s*$`, 'm').test(raw)) {
93
+ return { state: 'broken', stubPath, reason: 'name-mismatch', marker };
94
+ }
95
+ return { state: 'ours', stubPath, marker };
96
+ }
97
+
98
+ /**
99
+ * 建停用标记:同层临时目录写完再 rename,避免扫描器看到半个目录。
100
+ * `stubPath` 已存在且不是 ours 时直接拒绝(调用方转 409,带 detail)。
101
+ */
102
+ export function createShadow({ stubPath, name, scope, canonicalHash = null, now = new Date().toISOString() }) {
103
+ const current = inspectShadow(stubPath, name);
104
+ if (current.state === 'ours') return { created: false, already: true, stubPath };
105
+ if (current.state !== 'absent') {
106
+ const error = new Error(`无法在 ${stubPath} 建停用标记:${current.state}${current.reason ? `/${current.reason}` : ''}`);
107
+ error.code = 'error.skills.shadow-conflict';
108
+ error.detail = { stubPath, state: current.state, reason: current.reason || null };
109
+ throw error;
110
+ }
111
+
112
+ const parent = path.dirname(stubPath);
113
+ fs.mkdirSync(parent, { recursive: true });
114
+ const staging = path.join(parent, `.shadow-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
115
+ fs.mkdirSync(staging, { recursive: false });
116
+ try {
117
+ fs.writeFileSync(path.join(staging, 'SKILL.md'), shadowSkillMd({ name }), 'utf8');
118
+ const marker = {
119
+ tool: SHADOW_TOOL,
120
+ kind: 'shadow',
121
+ version: MARKER_VERSION,
122
+ skill: name,
123
+ scope,
124
+ createdAt: now,
125
+ canonicalHash,
126
+ description: SHADOW_DESCRIPTION,
127
+ };
128
+ fs.writeFileSync(path.join(staging, SHADOW_MARKER), `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
129
+ if (fs.existsSync(stubPath)) {
130
+ // 竞态:staging 期间有人抢先建了同名目录
131
+ const error = new Error(`无法在 ${stubPath} 建停用标记:已被占用`);
132
+ error.code = 'error.skills.shadow-conflict';
133
+ error.detail = { stubPath, state: 'race' };
134
+ throw error;
135
+ }
136
+ fs.renameSync(staging, stubPath);
137
+ return { created: true, stubPath, marker };
138
+ } catch (error) {
139
+ fs.rmSync(staging, { recursive: true, force: true });
140
+ throw error;
141
+ } finally {
142
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
143
+ }
144
+ }
145
+
146
+ /**
147
+ * 删停用标记:先 rename 出扫描根(原子),再递归删。
148
+ * 删不掉就返回 removed:false 让调用方保留记账——**不许先清账再删**。
149
+ *
150
+ * v0.4.0 起只服务迁移与 legacy 清理:`foreign` / `link` 一律保留并报告,绝不替用户
151
+ * 拆掉别人的软链或真实目录。
152
+ */
153
+ export function removeShadow({ stubPath, name }) {
154
+ const current = inspectShadow(stubPath, name);
155
+ if (current.state === 'absent') return { removed: false, reason: 'absent', stubPath };
156
+ if (current.state === 'foreign') return { removed: false, reason: 'not-managed', stubPath };
157
+ if (current.state === 'link') return { removed: false, reason: 'link', stubPath };
158
+
159
+ const parent = path.dirname(stubPath);
160
+ const graveyard = path.join(parent, `.shadow-trash-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
161
+ try {
162
+ fs.renameSync(stubPath, graveyard);
163
+ } catch (error) {
164
+ // Windows 上被占用时 rename 会失败;退回原地删,删不掉就如实返回
165
+ try {
166
+ fs.rmSync(stubPath, { recursive: true, force: false });
167
+ return { removed: true, stubPath, via: 'rm' };
168
+ } catch (rmError) {
169
+ return { removed: false, reason: 'locked', stubPath, error: rmError.message || String(rmError) };
170
+ }
171
+ }
172
+ try {
173
+ fs.rmSync(graveyard, { recursive: true, force: true });
174
+ } catch {
175
+ /* 已移出扫描根,删不掉也不影响可见性 */
176
+ }
177
+ return { removed: true, stubPath };
178
+ }
179
+
180
+ /** 回收 `.shadow-*` 残骸(进程中断留下的 staging / trash 目录)。 */
181
+ export function reapShadowLeftovers(dirPath) {
182
+ let entries;
183
+ try {
184
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
185
+ } catch {
186
+ return [];
187
+ }
188
+ const reaped = [];
189
+ for (const entry of entries) {
190
+ if (!entry.name.startsWith('.shadow-')) continue;
191
+ fs.rmSync(path.join(dirPath, entry.name), { recursive: true, force: true });
192
+ reaped.push(entry.name);
193
+ }
194
+ return reaped;
195
+ }
@@ -2,11 +2,16 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  /**
5
- * SKILL.md 的极简 frontmatter 解析:只取安装与清单需要的 `name` / `description`,
5
+ * SKILL.md 的极简 frontmatter 解析:取安装与清单需要的 `name` / `description`,
6
+ * 以及宿主用来决定「模型/用户能不能调用」的 `disable-model-invocation` / `user-invocable`,
6
7
  * 不引入 YAML 依赖。支持 `|` / `>` 块与单/双引号,解析失败按“没有 frontmatter”处理。
7
8
  *
8
- * Service(探测补名)与 Installer(落盘发现)共用同一份实现,避免两处规则漂移。
9
+ * Service(探测补名)、Installer(落盘发现)、roots(扫描可见性)与 policy provider
10
+ * 共用同一份实现,避免规则漂移。
9
11
  */
12
+ const STRING_FIELDS = new Set(['name', 'description']);
13
+ const BOOLEAN_FIELDS = new Set(['disable-model-invocation', 'user-invocable']);
14
+
10
15
  export function parseSkillFrontmatter(text) {
11
16
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
12
17
  if (!match) return {};
@@ -18,7 +23,7 @@ export function parseSkillFrontmatter(text) {
18
23
  const colon = line.indexOf(':');
19
24
  if (colon <= 0) continue;
20
25
  const key = line.slice(0, colon).trim();
21
- if (key !== 'name' && key !== 'description') continue;
26
+ if (!STRING_FIELDS.has(key) && !BOOLEAN_FIELDS.has(key)) continue;
22
27
  let value = line.slice(colon + 1).trim();
23
28
  const block = /^([|>])[+-]?$/.exec(value);
24
29
  if (block) {
@@ -34,7 +39,9 @@ export function parseSkillFrontmatter(text) {
34
39
  value = value.slice(1, -1);
35
40
  if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
36
41
  }
37
- fields[key] = value.trim();
42
+ value = value.trim();
43
+ if (BOOLEAN_FIELDS.has(key)) fields[key] = value === 'true' ? true : value === 'false' ? false : value;
44
+ else fields[key] = value;
38
45
  }
39
46
  return fields;
40
47
  }
@@ -47,3 +54,15 @@ export function readSkillFrontmatter(skillDir) {
47
54
  return {};
48
55
  }
49
56
  }
57
+
58
+ /**
59
+ * 读一份 SKILL.md:frontmatter 字段 + 去掉 frontmatter 的正文。
60
+ * 供 policy provider 的 `get` 使用;读不到就抛(调用方决定 fail-open 还是报错)。
61
+ */
62
+ export function readSkillDocument(skillFile) {
63
+ const text = fs.readFileSync(skillFile, 'utf8');
64
+ const fields = parseSkillFrontmatter(text);
65
+ const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(text);
66
+ const body = match ? text.slice(match[0].length) : text;
67
+ return { fields, body, text };
68
+ }
package/lib/core/state.js CHANGED
@@ -1,17 +1,29 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- export const STATE_VERSION = 1;
4
+ /**
5
+ * 状态版本史:
6
+ * - v1:`installs[].mode` + `installs[].scopeProjects` + 顶层 `links[]`(DSH 侧软链 = 可见性)。
7
+ * - v2:`installs[].enabled` + `exports[]`(只给其他 agent 的显式导出)+ `shadows[]`(DSH 扫描根里的停用标记)。
8
+ * - v3:`installs[].enabled` 是**唯一**启停开关;`shadows[]` 退休,`legacyShadows[]` 只放
9
+ * 迁移时删不掉的旧桩与 orphan(v0.4.0 起启停由每个 live agent scope 的 policy provider 负责)。
10
+ *
11
+ * 迁移分两步:`upgradeStateV1` / `upgradeStateV2` 只做**纯结构**改写(可测、无副作用),
12
+ * 删链/删旧桩/剪悬空导出这类要做 IO 的事在 `migrations.js` 里按 manifest 执行。
13
+ */
14
+ export const STATE_VERSION = 3;
5
15
 
6
16
  export function emptyState() {
7
17
  return {
8
18
  version: STATE_VERSION,
9
- settings: { autoLinkTargets: null, placement: 'settings' },
19
+ settings: { exportTargets: [], placement: 'settings' },
10
20
  agents: { custom: [], dismissed: [] },
11
21
  subscriptions: [],
12
22
  installs: [],
13
- links: [],
23
+ exports: [],
24
+ legacyShadows: [],
14
25
  checks: {},
26
+ migration: null,
15
27
  };
16
28
  }
17
29
 
@@ -19,6 +31,219 @@ function isPlainObject(value) {
19
31
  return typeof value === 'object' && value !== null && !Array.isArray(value);
20
32
  }
21
33
 
34
+ function isoOrNull(value) {
35
+ return typeof value === 'string' && value ? value : null;
36
+ }
37
+
38
+ /**
39
+ * v1 的 `autoLinkTargets` → v2 的 `exportTargets`。
40
+ * - `null` / 缺失:v1 语义是「全部已装 agent」,v2 默认是「一个都不导出」——**不继承**,
41
+ * 否则升级会把「以前默认全都接」变成「以后默认全都导出」,与 v2 的设计前提相反。
42
+ * - 显式数组:用户点选过,原样继承(只保留 `agent:*`,其余目标在 v2 里已无意义)。
43
+ */
44
+ function legacyExportTargets(settings) {
45
+ const raw = settings?.autoLinkTargets;
46
+ if (!Array.isArray(raw)) return [];
47
+ return [...new Set(raw.filter((target) => typeof target === 'string' && target.startsWith('agent:')))];
48
+ }
49
+
50
+ /**
51
+ * v1 → v2 的结构升级。**不读写任何文件**,只决定数据怎么摆:
52
+ * - `mode: 'off'` → `enabled: false`(v3 起只靠它启停,不再建标记);
53
+ * - 顶层 `links[]` 拆成 `exports[]`(agent 链,保留)+ `migration.legacyLinks`(DSH 链/项目链,待删);
54
+ * - `scopeProjects` 先挂在 `legacyScopeProjects` 上,migrations.js 处理完再删。
55
+ */
56
+ export function upgradeStateV1(value, { now = new Date().toISOString() } = {}) {
57
+ const links = Array.isArray(value.links) ? value.links : [];
58
+ const exportsRows = [];
59
+ const legacyLinks = [];
60
+ for (const link of links) {
61
+ if (!isPlainObject(link) || typeof link.skill !== 'string' || typeof link.target !== 'string') continue;
62
+ const row = {
63
+ skill: link.skill,
64
+ target: link.target,
65
+ agentDir: typeof link.agentDir === 'string' ? link.agentDir : null,
66
+ linkPath: typeof link.linkPath === 'string' ? link.linkPath : null,
67
+ mode: link.mode === 'junction' ? 'junction' : 'relative',
68
+ createdAt: isoOrNull(link.createdAt) || now,
69
+ };
70
+ if (link.target.startsWith('agent:')) exportsRows.push(row);
71
+ else if (link.target === 'dsh-global' || link.target.startsWith('project:')) legacyLinks.push(row);
72
+ }
73
+
74
+ const installs = (Array.isArray(value.installs) ? value.installs : []).map((item) => {
75
+ const { mode, scopeProjects, ...rest } = isPlainObject(item) ? item : {};
76
+ const next = { ...rest, enabled: mode !== 'off' };
77
+ if (Array.isArray(scopeProjects) && scopeProjects.length > 0) next.legacyScopeProjects = scopeProjects;
78
+ return next;
79
+ });
80
+
81
+ const next = {
82
+ ...value,
83
+ version: 2,
84
+ settings: {
85
+ exportTargets: legacyExportTargets(value.settings),
86
+ placement: value.settings?.placement === 'sidebar' ? 'sidebar' : 'settings',
87
+ },
88
+ installs,
89
+ exports: exportsRows,
90
+ shadows: [],
91
+ migration: { from: 1, at: now, status: 'links-pending', legacyLinks },
92
+ };
93
+ // 拆完就删:v2/v3 不再有顶层 links[],留着会被后续 upgrade 一层层带下去
94
+ delete next.links;
95
+ return next;
96
+ }
97
+
98
+ /**
99
+ * v2 → v3 的结构升级。**不读写任何文件**:
100
+ * - `shadows[]` 全部搬进 `legacyShadows[]` 当待办清单(`reason` 先写占位,文件侧迁移再改成
101
+ * `locked` / `foreign` / `link` 或删除成功);
102
+ * - `scope === 'user'` 且能找到同名 install → 同步 `enabled = false`(幂等);
103
+ * - `scope === 'user'` 却找不到 install → 不凭空造 install,标 `orphan`;
104
+ * - `project:*` 只清理桩,**不**转成全局停用;
105
+ * - 删除 `shadows`,版本写 3;v1 遗留的链清理任务保留在 `migration` 里。
106
+ */
107
+ export function upgradeStateV2(value, { now = new Date().toISOString() } = {}) {
108
+ const installs = Array.isArray(value.installs) ? value.installs.map((item) => ({ ...item })) : [];
109
+ const known = new Set(installs.map((item) => item.name));
110
+ const legacyShadows = [];
111
+ for (const row of Array.isArray(value.shadows) ? value.shadows : []) {
112
+ if (!isPlainObject(row) || typeof row.skill !== 'string' || typeof row.stubPath !== 'string') continue;
113
+ const scope = typeof row.scope === 'string' ? row.scope : 'user';
114
+ const at = isoOrNull(row.createdAt) || isoOrNull(row.at) || now;
115
+ let reason = 'pending';
116
+ if (scope.startsWith('project:')) {
117
+ reason = 'project-scope';
118
+ } else if (scope === 'user') {
119
+ if (known.has(row.skill)) {
120
+ const install = installs.find((item) => item.name === row.skill);
121
+ if (install) install.enabled = false;
122
+ } else {
123
+ reason = 'orphan';
124
+ }
125
+ }
126
+ legacyShadows.push({ skill: row.skill, stubPath: row.stubPath, reason, at });
127
+ }
128
+
129
+ const previous = isPlainObject(value.migration) ? value.migration : null;
130
+ const previousPending = Boolean(previous && previous.status !== 'done');
131
+ const pending = previousPending || legacyShadows.length > 0;
132
+ const migration = previousPending
133
+ ? { ...previous, to: 3, status: 'pending' }
134
+ : {
135
+ from: 2,
136
+ at: now,
137
+ to: 3,
138
+ status: pending ? 'pending' : 'done',
139
+ ...(pending ? {} : { completedAt: now }),
140
+ };
141
+
142
+ const next = {
143
+ ...value,
144
+ version: 3,
145
+ installs,
146
+ legacyShadows,
147
+ migration,
148
+ };
149
+ delete next.shadows;
150
+ return next;
151
+ }
152
+
153
+ function validateV3(state) {
154
+ for (const key of ['subscriptions', 'installs', 'exports', 'legacyShadows']) {
155
+ if (!Array.isArray(state[key])) return { ok: false, reason: `state.${key} is not an array` };
156
+ }
157
+ if (!isPlainObject(state.settings)) return { ok: false, reason: 'state.settings is not an object' };
158
+ if (!isPlainObject(state.agents)) return { ok: false, reason: 'state.agents is not an object' };
159
+ if (!isPlainObject(state.checks)) state.checks = {};
160
+ if (!Array.isArray(state.agents.custom)) state.agents.custom = [];
161
+ if (!Array.isArray(state.agents.dismissed)) state.agents.dismissed = [];
162
+ if (state.migration !== null && state.migration !== undefined && !isPlainObject(state.migration)) {
163
+ state.migration = null;
164
+ }
165
+ if (state.migration === undefined) state.migration = null;
166
+
167
+ const settings = state.settings;
168
+ if (!Array.isArray(settings.exportTargets)) {
169
+ // v1 的老字段在升级时已经丢掉;这里只兜手工编辑把类型写坏的情况。
170
+ settings.exportTargets = [];
171
+ }
172
+ settings.exportTargets = [
173
+ ...new Set(
174
+ settings.exportTargets.filter((item) => typeof item === 'string' && item.startsWith('agent:')),
175
+ ),
176
+ ];
177
+ if (settings.placement !== 'sidebar') settings.placement = 'settings';
178
+
179
+ for (const row of state.installs) {
180
+ if (!isPlainObject(row) || typeof row.name !== 'string' || !row.name) {
181
+ return { ok: false, reason: 'state.installs[] has a row without name' };
182
+ }
183
+ if (typeof row.enabled !== 'boolean') row.enabled = true;
184
+ }
185
+ for (const [key, fields] of [
186
+ ['exports', ['skill', 'target']],
187
+ ['legacyShadows', ['skill', 'stubPath']],
188
+ ]) {
189
+ for (const row of state[key]) {
190
+ if (!isPlainObject(row)) return { ok: false, reason: `state.${key}[] is not an object` };
191
+ for (const field of fields) {
192
+ if (typeof row[field] !== 'string' || !row[field]) {
193
+ return { ok: false, reason: `state.${key}[] is missing ${field}` };
194
+ }
195
+ }
196
+ if (key === 'legacyShadows') {
197
+ if (typeof row.reason !== 'string' || !row.reason) row.reason = 'pending';
198
+ if (typeof row.at !== 'string' || !row.at) row.at = new Date().toISOString();
199
+ }
200
+ }
201
+ }
202
+ return { ok: true, state };
203
+ }
204
+
205
+ export function validateState(value) {
206
+ if (!isPlainObject(value)) return { ok: false, reason: 'state is not an object' };
207
+ if (typeof value.version !== 'number' || !Number.isInteger(value.version)) {
208
+ return { ok: false, reason: 'state.version missing' };
209
+ }
210
+ if (value.version > STATE_VERSION) {
211
+ return { ok: false, reason: `state.version ${value.version} newer than ${STATE_VERSION}` };
212
+ }
213
+ if (value.version < 1) return { ok: false, reason: `state.version ${value.version} invalid` };
214
+
215
+ // v2 的旧桩清单在结构升级时会被搬进 legacyShadows;先按原结构校验,
216
+ // 避免「少字段的旧桩」被静默丢掉、状态却看起来正常。
217
+ if (value.version === 2 && value.shadows !== undefined && !Array.isArray(value.shadows)) {
218
+ return { ok: false, reason: 'state.shadows is not an array' };
219
+ }
220
+ if (value.version === 2 && Array.isArray(value.shadows)) {
221
+ for (const row of value.shadows) {
222
+ if (!isPlainObject(row) || typeof row.skill !== 'string' || !row.skill) {
223
+ return { ok: false, reason: 'state.shadows[] is missing skill' };
224
+ }
225
+ if (typeof row.stubPath !== 'string' || !row.stubPath) {
226
+ return { ok: false, reason: 'state.shadows[] is missing stubPath' };
227
+ }
228
+ }
229
+ }
230
+
231
+ let upgraded;
232
+ if (value.version === 1) upgraded = upgradeStateV2(upgradeStateV1(value));
233
+ else if (value.version === 2) upgraded = upgradeStateV2(value);
234
+ else upgraded = value;
235
+
236
+ const state = { ...emptyState(), ...upgraded };
237
+ delete state.shadows;
238
+ // v1 的顶层 links[] 在 v2 升级时已拆成 exports[] + migration.legacyLinks;
239
+ // 旧版本只搬家没删原字段,v3 里读到就剪掉,不再往外写。
240
+ delete state.links;
241
+ const verdict = validateV3(state);
242
+ if (!verdict.ok) return verdict;
243
+ state.version = STATE_VERSION;
244
+ return { ok: true, state: verdict.state };
245
+ }
246
+
22
247
  /**
23
248
  * 状态读写。写入规则:全量读 → 校验 → 临时文件 → rename。
24
249
  * 损坏时**只读降级**,绝不 fail-open:宁可只读,也不要拿坏状态去覆盖链接。
@@ -70,12 +295,77 @@ export class StateStore {
70
295
  this.state = verdict.state;
71
296
  this.readOnly = false;
72
297
  this.problem = null;
298
+ const versionUpgrade = parsed.version !== STATE_VERSION;
299
+ // v1 迁移漏删的顶层 links:v3 不再有它,读到就写回干净状态,否则会一直留在文件里
300
+ const hasLegacyLinks = 'links' in parsed;
301
+ const legacyLinks = hasLegacyLinks ? parsed.links : null;
302
+ // 升级过的状态还没落盘:必须立刻写回,否则下一次 load 又按旧版升级一遍。
303
+ // 写回之前先留原文件副本——迁移是单向的,没有副本就没有回滚。
304
+ if (versionUpgrade || hasLegacyLinks) {
305
+ if (versionUpgrade) {
306
+ if (!this.backupRaw(raw, parsed.version)) {
307
+ this.readOnly = true;
308
+ this.problem = `state upgrade aborted: cannot back up v${parsed.version} state`;
309
+ return this.state;
310
+ }
311
+ // v1→v3 跨两级:额外留一份 v2 形状的快照,出问题能看到中间态。
312
+ if (parsed.version === 1) this.backupValue(upgradeStateV1(parsed), 2);
313
+ }
314
+ try {
315
+ this.save(this.state);
316
+ if (versionUpgrade) this.logger('state.upgraded', { from: parsed.version, to: STATE_VERSION });
317
+ else {
318
+ this.logger('state.links.pruned', {
319
+ count: Array.isArray(legacyLinks) ? legacyLinks.length : 0,
320
+ });
321
+ }
322
+ } catch (error) {
323
+ // 剪 v1 残留失败不该把整个状态打成只读:内存里已经是干净结构,下次写入会再试
324
+ if (versionUpgrade) {
325
+ this.readOnly = true;
326
+ this.problem = `state upgrade failed: ${error.message}`;
327
+ this.logger('state.upgrade.failed', { reason: this.problem });
328
+ } else {
329
+ this.logger('state.links.prune.failed', { message: error.message });
330
+ }
331
+ }
332
+ }
73
333
  return this.state;
74
334
  }
75
335
 
336
+ /** 迁移前把原文件整份留一份(`migrations/state-v<n>-<ts>.json`)。 */
337
+ backupRaw(raw, fromVersion) {
338
+ try {
339
+ fs.mkdirSync(this.paths.migrationsDir, { recursive: true });
340
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
341
+ fs.writeFileSync(path.join(this.paths.migrationsDir, `state-v${fromVersion}-${stamp}.json`), raw, 'utf8');
342
+ return true;
343
+ } catch (error) {
344
+ this.logger('state.backup.failed', { message: error.message });
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /** 把升级中间态写一份副本(`migrations/state-v<n>-<ts>.json`)。 */
350
+ backupValue(value, version) {
351
+ try {
352
+ fs.mkdirSync(this.paths.migrationsDir, { recursive: true });
353
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
354
+ const file = path.join(this.paths.migrationsDir, `state-v${version}-${stamp}.json`);
355
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
356
+ return true;
357
+ } catch (error) {
358
+ this.logger('state.backup.failed', { message: error.message });
359
+ return false;
360
+ }
361
+ }
362
+
76
363
  save(next) {
77
364
  if (this.readOnly) throw new StateReadOnlyError(this.problem);
78
365
  const state = { ...next, version: STATE_VERSION };
366
+ delete state.shadows;
367
+ // v1 的顶层 links[] 已退休;任何调用方误带也不能再写回文件
368
+ delete state.links;
79
369
  fs.mkdirSync(this.paths.stateDir, { recursive: true });
80
370
  const tmp = path.join(this.paths.stateDir, `.state.${process.pid}.${Date.now()}.tmp`);
81
371
  fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
@@ -108,42 +398,3 @@ export class StateReadOnlyError extends Error {
108
398
  this.problem = problem;
109
399
  }
110
400
  }
111
-
112
- export function validateState(value) {
113
- if (!isPlainObject(value)) return { ok: false, reason: 'state is not an object' };
114
- if (typeof value.version !== 'number' || !Number.isInteger(value.version)) {
115
- return { ok: false, reason: 'state.version missing' };
116
- }
117
- if (value.version > STATE_VERSION) {
118
- return { ok: false, reason: `state.version ${value.version} newer than ${STATE_VERSION}` };
119
- }
120
- if (value.version < 1) return { ok: false, reason: `state.version ${value.version} invalid` };
121
-
122
- const state = { ...emptyState(), ...value };
123
- for (const key of ['subscriptions', 'installs', 'links']) {
124
- if (!Array.isArray(state[key])) return { ok: false, reason: `state.${key} is not an array` };
125
- }
126
- if (!isPlainObject(state.settings)) return { ok: false, reason: 'state.settings is not an object' };
127
- if (!isPlainObject(state.agents)) return { ok: false, reason: 'state.agents is not an object' };
128
- if (!isPlainObject(state.checks)) state.checks = {};
129
- if (!Array.isArray(state.agents.custom)) state.agents.custom = [];
130
- if (!Array.isArray(state.agents.dismissed)) state.agents.dismissed = [];
131
- // v1 的老状态只有布尔 autoLinkAgents:true 表示"所有探测到的 Agent"(null),false 表示一个都不选。
132
- const settings = state.settings;
133
- if (settings.autoLinkTargets === undefined) {
134
- settings.autoLinkTargets = settings.autoLinkAgents === false ? [] : null;
135
- }
136
- if (settings.autoLinkTargets !== null && !Array.isArray(settings.autoLinkTargets)) {
137
- settings.autoLinkTargets = null;
138
- }
139
- if (Array.isArray(settings.autoLinkTargets)) {
140
- settings.autoLinkTargets = settings.autoLinkTargets.filter(
141
- (item) => typeof item === 'string' && item.startsWith('agent:'),
142
- );
143
- }
144
- delete settings.autoLinkAgents;
145
- // 入口位置:settings = 设置页分区(默认),sidebar = 左侧栏独立面板。
146
- if (settings.placement !== 'sidebar') settings.placement = 'settings';
147
- state.version = STATE_VERSION;
148
- return { ok: true, state };
149
- }