@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.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.
Files changed (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1211 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.10.0 — Essence 覆盖层合并库。
4
+ //
5
+ // 术语:
6
+ // 镜像(mirror) = 只读基础 profile(DSH 版本 + 基础插件集),唯一可升级/回退的层;
7
+ // 原身(essence) = 镜像之上的可写覆盖层:装插件、改设置、登录账户、建会话;
8
+ // 分身(derived) = 从原身派生的临时实例(不落盘配置,只持久化会话数据)。
9
+ //
10
+ // 本模块只做原身覆盖层:overlay(原身意图) × mirror base(镜像基础) → merged(物化 profile)。
11
+ // 不接 HTTP、不接 UI;由 orchestrator 的 essence 服务与后续 P3 分身派生调用。
12
+ //
13
+ // 零依赖:YAML 复用 rule-registry 的 parseYamlSubset;写出用 JSON(JSON 是合法 YAML)。
14
+ // 合并是“尽力而为”:fatal 冲突不抛异常,而是记录 conflicts 并由 manager 决定是否物化。
15
+
16
+ import path from 'node:path'
17
+ import fs from 'node:fs'
18
+
19
+ import {
20
+ ensureDir,
21
+ exists,
22
+ readJson,
23
+ writeJsonAtomic,
24
+ sha256,
25
+ digestTree,
26
+ copyTree,
27
+ removeTree,
28
+ } from '../kernel/index.mjs'
29
+ import { parseYamlSubset } from './rule-registry.mjs'
30
+ import { KeyedMutex } from './mutex.mjs'
31
+ import { Semaphore } from './semaphore.mjs'
32
+
33
+ export const ESSENCE_VERSION = '1.0.0'
34
+
35
+ /** v3.0.4 官方推荐原身(内置、锁定、快速派生默认基底)。 */
36
+ export const OFFICIAL_ESSENCE_ID = 'essence-official-dev'
37
+ export const OFFICIAL_ESSENCE_NAME = '开发者推荐(官方)'
38
+ export const OFFICIAL_ESSENCE_MIN_TIER = 'vip100'
39
+ /** 官方推荐原身预装的默认插件条目(v3.0.7:自定义清单,安装≠激活)。 */
40
+ export const DEFAULT_OFFICIAL_PATCH_ENTRIES = Object.freeze([
41
+ // 安装但不激活
42
+ Object.freeze({ id: 'official-2xray', name: '@twinsearth/dsh-2xray', disabled: true }),
43
+ Object.freeze({ id: 'official-agency-agents', name: '@michengai/dsh-agency-agents', disabled: true }),
44
+ Object.freeze({ id: 'official-better-sidebar', name: 'dsh-better-sidebar', disabled: true }),
45
+ Object.freeze({ id: 'official-context', name: 'dsh-context', disabled: true }),
46
+ Object.freeze({ id: 'official-modlens', name: '@liustack/modlens', disabled: true }),
47
+ Object.freeze({ id: 'official-modsearch', name: '@liustack/modsearch', disabled: true }),
48
+ // 安装并激活
49
+ Object.freeze({ id: 'official-dshmarket', name: 'dshmarket', disabled: false }),
50
+ Object.freeze({ id: 'official-im2a', name: '@twinsearth/dsh-im2a-plugin', disabled: false }),
51
+ Object.freeze({ id: 'official-openviking-memory', name: '@openviking/dsh-memory-plugin', disabled: false }),
52
+ Object.freeze({ id: 'official-w2m', name: '@twinsearth/w2m-dsh-plugin', disabled: false }),
53
+ ])
54
+
55
+ /** VIP 配额表:原身数 / 子原身数 / 活跃会话数。归档会话不限。 */
56
+ export const VIP_QUOTAS = Object.freeze({
57
+ free: Object.freeze({ maxEssences: 5, maxSubEssences: 5, maxActiveSessions: 10 }),
58
+ vip10: Object.freeze({ maxEssences: 10, maxSubEssences: 10, maxActiveSessions: 10 }),
59
+ vip30: Object.freeze({ maxEssences: 30, maxSubEssences: 30, maxActiveSessions: 30 }),
60
+ vip50: Object.freeze({ maxEssences: 50, maxSubEssences: 50, maxActiveSessions: 50 }),
61
+ vip100: Object.freeze({ maxEssences: 100, maxSubEssences: 100, maxActiveSessions: 100 }),
62
+ })
63
+
64
+ export const VIP_TIERS = Object.freeze(Object.keys(VIP_QUOTAS))
65
+
66
+ export const CONFLICT_KIND = Object.freeze({
67
+ MISSING_PLUGIN: 'missing-plugin',
68
+ LOCKED_PLUGIN_OVERRIDDEN: 'locked-plugin-overridden',
69
+ LOCKED_PLUGIN_DISABLED: 'locked-plugin-disabled',
70
+ VERSION_INCOMPATIBLE: 'version-incompatible',
71
+ DUPLICATE_ID: 'duplicate-id',
72
+ })
73
+
74
+ export const CONFLICT_SEVERITY = Object.freeze({ FATAL: 'fatal', WARNING: 'warning' })
75
+
76
+ export const ESSENCE_STATUS = Object.freeze({ READY: 'ready', CONFLICTED: 'conflicted', STALE: 'stale' })
77
+
78
+ /** @param {unknown} tier @returns {string} */
79
+ function normalizeTier(tier) {
80
+ const text = String(tier ?? '')
81
+ return VIP_TIERS.includes(text) ? text : 'free'
82
+ }
83
+
84
+ /** @param {unknown} value @returns {Record<string, unknown>} */
85
+ function asObject(value) {
86
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
87
+ }
88
+
89
+ /** v3.0.9:冻结(archived)的元主只能删除,其它操作一律拒绝。 */
90
+ function assertNotFrozen(meta) {
91
+ if (meta && meta.archived === true) throw new Error(`essence ${meta.id} is archived/frozen(已冻结,只能删除)`)
92
+ }
93
+
94
+ /**
95
+ * 原身(顶层)配额检查。
96
+ * @param {string} tier VIP 等级
97
+ * @param {number} currentTopLevel 当前顶层原身数
98
+ * @param {number} [requested] 本次新增数量
99
+ * @returns {{ok:boolean, reason?:string, current:number, limit:number, requested:number}}
100
+ */
101
+ export function checkEssenceQuota(tier, currentTopLevel, requested = 1) {
102
+ const quota = VIP_QUOTAS[normalizeTier(tier)]
103
+ const current = Math.max(0, Number(currentTopLevel) || 0)
104
+ const want = Math.max(1, Number(requested) || 1)
105
+ const ok = current + want <= quota.maxEssences
106
+ return {
107
+ ok,
108
+ current,
109
+ limit: quota.maxEssences,
110
+ requested: want,
111
+ ...(ok ? {} : { reason: `essence quota exceeded: ${current}/${quota.maxEssences}, tier=${normalizeTier(tier)}` }),
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 子原身配额检查(每个原身独立计数)。
117
+ * @param {string} tier VIP 等级
118
+ * @param {number} currentSiblings 该父原身下已有子原身数
119
+ * @param {number} [requested] 本次新增数量
120
+ * @returns {{ok:boolean, reason?:string, current:number, limit:number, requested:number}}
121
+ */
122
+ export function checkSubEssenceQuota(tier, currentSiblings, requested = 1) {
123
+ const quota = VIP_QUOTAS[normalizeTier(tier)]
124
+ const current = Math.max(0, Number(currentSiblings) || 0)
125
+ const want = Math.max(1, Number(requested) || 1)
126
+ const ok = current + want <= quota.maxSubEssences
127
+ return {
128
+ ok,
129
+ current,
130
+ limit: quota.maxSubEssences,
131
+ requested: want,
132
+ ...(ok ? {} : { reason: `sub-essence quota exceeded: ${current}/${quota.maxSubEssences}, tier=${normalizeTier(tier)}` }),
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 活跃会话配额检查(每个原身独立;归档会话不限)。
138
+ * @param {string} tier VIP 等级
139
+ * @param {number} currentActive 当前活跃会话数
140
+ * @returns {{ok:boolean, reason?:string, current:number, limit:number, requested:number}}
141
+ */
142
+ export function checkActiveSessionQuota(tier, currentActive) {
143
+ const quota = VIP_QUOTAS[normalizeTier(tier)]
144
+ const current = Math.max(0, Number(currentActive) || 0)
145
+ const ok = current < quota.maxActiveSessions
146
+ return {
147
+ ok,
148
+ current,
149
+ limit: quota.maxActiveSessions,
150
+ requested: 1,
151
+ ...(ok ? {} : { reason: `active session quota exceeded: ${current}/${quota.maxActiveSessions}, tier=${normalizeTier(tier)}; archive old sessions first` }),
152
+ }
153
+ }
154
+
155
+ // ===========================================================================
156
+ // cordis.patch.yml 解析:支持根列表(- insert: [...])与 { insert: [...] }
157
+ // 复用 rule-registry 的零依赖 YAML 子集解析器;也接受纯 JSON(JSON 是合法 YAML)。
158
+ // ===========================================================================
159
+
160
+ /**
161
+ * @param {unknown} value
162
+ * @returns {Array<Record<string, unknown>>}
163
+ */
164
+ function extractPatchEntries(value) {
165
+ /** @type {Array<Record<string, unknown>>} */
166
+ const entries = []
167
+ if (Array.isArray(value)) {
168
+ for (const item of value) {
169
+ if (item !== null && typeof item === 'object' && Array.isArray(item.insert)) {
170
+ for (const entry of item.insert) {
171
+ if (entry !== null && typeof entry === 'object') entries.push(entry)
172
+ }
173
+ } else if (item !== null && typeof item === 'object' && typeof item.id === 'string') {
174
+ entries.push(item)
175
+ }
176
+ }
177
+ return entries
178
+ }
179
+ const object = asObject(value)
180
+ if (Array.isArray(object.insert)) {
181
+ for (const entry of object.insert) {
182
+ if (entry !== null && typeof entry === 'object') entries.push(entry)
183
+ }
184
+ }
185
+ return entries
186
+ }
187
+
188
+ /**
189
+ * 解析一份 patch 文本。
190
+ * @param {string} text
191
+ * @returns {{ entries: Array<Record<string, unknown>>, errors: string[] }}
192
+ */
193
+ export function parsePatchEntries(text) {
194
+ const raw = String(text ?? '').trim()
195
+ if (raw.length === 0) return { entries: [], errors: [] }
196
+ try {
197
+ return { entries: extractPatchEntries(JSON.parse(raw)), errors: [] }
198
+ } catch {
199
+ // 不是 JSON:走 YAML 子集解析
200
+ }
201
+ const parsed = parseYamlSubset(raw)
202
+ const errors = Array.isArray(parsed.errors) ? parsed.errors.map(error => String(error?.message ?? error)) : []
203
+ return { entries: extractPatchEntries(parsed.value), errors }
204
+ }
205
+
206
+ /**
207
+ * 序列化 patch 条目为可被 DSH loader 读取的文本(JSON 是合法 YAML)。
208
+ * @param {Array<Record<string, unknown>>} entries
209
+ * @returns {string}
210
+ */
211
+ export function serializePatchEntries(entries) {
212
+ return `${JSON.stringify({ insert: entries }, null, 2)}\n`
213
+ }
214
+
215
+ // ===========================================================================
216
+ // 纯合并逻辑
217
+ // ===========================================================================
218
+
219
+ /** @typedef {'fatal'|'warning'} ConflictSeverity */
220
+ /**
221
+ * @typedef {object} Conflict
222
+ * @property {string} kind
223
+ * @property {ConflictSeverity} severity
224
+ * @property {string} pluginId
225
+ * @property {string} message
226
+ * @property {unknown} [mirrorValue]
227
+ * @property {unknown} [essenceValue]
228
+ */
229
+ /**
230
+ * @typedef {object} MergeResult
231
+ * @property {Array<Record<string, unknown>>} mergedPatchEntries
232
+ * @property {Record<string, unknown>} mergedPackageJson
233
+ * @property {Record<string, unknown>} mergedSettings
234
+ * @property {Conflict[]} conflicts
235
+ * @property {boolean} hasFatal
236
+ */
237
+
238
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
239
+ function hasConfigDiff(a, b) {
240
+ if (a === b) return false
241
+ if (a === undefined || b === undefined) return true
242
+ if (a === null || b === null) return true
243
+ return JSON.stringify(a) !== JSON.stringify(b)
244
+ }
245
+
246
+ /** @param {unknown} a @param {unknown} b */
247
+ function deepMerge(a, b) {
248
+ const base = asObject(a)
249
+ const patch = asObject(b)
250
+ /** @type {Record<string, unknown>} */
251
+ const out = { ...base }
252
+ for (const [key, value] of Object.entries(patch)) {
253
+ const current = out[key]
254
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)
255
+ && current !== null && typeof current === 'object' && !Array.isArray(current)) {
256
+ out[key] = deepMerge(current, value)
257
+ } else {
258
+ out[key] = value
259
+ }
260
+ }
261
+ return out
262
+ }
263
+
264
+ /**
265
+ * 版本区间兼容判断:主版本相同视为兼容;无法解析时保守视为不兼容(除非完全相等)。
266
+ * @param {string} a @param {string} b @returns {boolean}
267
+ */
268
+ function areVersionsCompatible(a, b) {
269
+ if (a === b) return true
270
+ const matchA = /^[\^~]?(\d+)/.exec(String(a))
271
+ const matchB = /^[\^~]?(\d+)/.exec(String(b))
272
+ if (matchA === null || matchB === null) return false
273
+ return matchA[1] === matchB[1]
274
+ }
275
+
276
+ /**
277
+ * 按 id 合并 patch 条目。
278
+ * 语义:镜像条目保序;原身同 id 覆盖 config(深合并)、可禁用非锁定项、可新增条目;
279
+ * 锁定项禁止禁用/覆盖(fatal,镜像配置保留)。
280
+ * @param {Array<Record<string, unknown>>} mirrorEntries
281
+ * @param {Array<Record<string, unknown>>} overlayEntries
282
+ * @param {Iterable<string>} lockedIds
283
+ * @param {Conflict[]} conflicts
284
+ * @returns {Array<Record<string, unknown>>}
285
+ */
286
+ function mergePatchEntries(mirrorEntries, overlayEntries, lockedIds, conflicts) {
287
+ const locked = lockedIds instanceof Set ? lockedIds : new Set(lockedIds)
288
+ /** @type {Map<string, Record<string, unknown>>} */
289
+ const byId = new Map()
290
+ for (const entry of mirrorEntries) {
291
+ const id = String(entry.id ?? '')
292
+ if (id.length === 0) continue
293
+ byId.set(id, entry)
294
+ }
295
+
296
+ const seenInOverlay = new Set()
297
+ for (const entry of overlayEntries) {
298
+ const id = String(entry.id ?? '')
299
+ if (id.length === 0) {
300
+ conflicts.push({
301
+ kind: CONFLICT_KIND.MISSING_PLUGIN, severity: CONFLICT_SEVERITY.WARNING,
302
+ pluginId: '<unknown>', message: '原身 patch 条目缺少 id,已忽略', essenceValue: entry,
303
+ })
304
+ continue
305
+ }
306
+ if (seenInOverlay.has(id)) {
307
+ conflicts.push({
308
+ kind: CONFLICT_KIND.DUPLICATE_ID, severity: CONFLICT_SEVERITY.WARNING,
309
+ pluginId: id, message: `原身 patch 中 ${id} 重复出现,后者生效`,
310
+ })
311
+ }
312
+ seenInOverlay.add(id)
313
+
314
+ const mirrorEntry = byId.get(id)
315
+ if (locked.has(id)) {
316
+ if (entry.disabled === true) {
317
+ conflicts.push({
318
+ kind: CONFLICT_KIND.LOCKED_PLUGIN_DISABLED, severity: CONFLICT_SEVERITY.FATAL,
319
+ pluginId: id, message: `插件 ${id} 是镜像锁定项,不能被原身禁用`,
320
+ mirrorValue: mirrorEntry, essenceValue: entry,
321
+ })
322
+ continue
323
+ }
324
+ if (mirrorEntry !== undefined && hasConfigDiff(mirrorEntry.config, entry.config)) {
325
+ conflicts.push({
326
+ kind: CONFLICT_KIND.LOCKED_PLUGIN_OVERRIDDEN, severity: CONFLICT_SEVERITY.FATAL,
327
+ pluginId: id, message: `插件 ${id} 是镜像锁定项,配置不能被原身覆盖`,
328
+ mirrorValue: mirrorEntry.config, essenceValue: entry.config,
329
+ })
330
+ }
331
+ continue
332
+ }
333
+
334
+ if (mirrorEntry === undefined) {
335
+ byId.set(id, { ...entry })
336
+ continue
337
+ }
338
+ if (entry.disabled === true) {
339
+ byId.delete(id)
340
+ continue
341
+ }
342
+ byId.set(id, {
343
+ ...mirrorEntry,
344
+ ...entry,
345
+ config: deepMerge(mirrorEntry.config ?? {}, entry.config ?? {}),
346
+ })
347
+ }
348
+
349
+ return Array.from(byId.values())
350
+ }
351
+
352
+ /**
353
+ * 合并 package.json:依赖并集;主版本不兼容 → fatal,保留镜像版本;兼容 → 原身 pin 胜。
354
+ * 非依赖字段:镜像为底、原身覆盖(浅合并)。
355
+ * @param {Record<string, unknown>} mirrorPkg
356
+ * @param {Record<string, unknown>} overlayPkg
357
+ * @param {Conflict[]} conflicts
358
+ * @returns {Record<string, unknown>}
359
+ */
360
+ function mergePackageJson(mirrorPkg, overlayPkg, conflicts) {
361
+ const mirrorDeps = asObject(mirrorPkg.dependencies)
362
+ const overlayDeps = asObject(overlayPkg.dependencies)
363
+ /** @type {Record<string, string>} */
364
+ const mergedDeps = {}
365
+ for (const [name, range] of Object.entries(mirrorDeps)) mergedDeps[name] = String(range)
366
+
367
+ for (const [name, range] of Object.entries(overlayDeps)) {
368
+ const next = String(range)
369
+ const existing = mergedDeps[name]
370
+ if (existing === undefined) { mergedDeps[name] = next; continue }
371
+ if (existing === next) continue
372
+ if (areVersionsCompatible(existing, next)) { mergedDeps[name] = next; continue }
373
+ conflicts.push({
374
+ kind: CONFLICT_KIND.VERSION_INCOMPATIBLE, severity: CONFLICT_SEVERITY.FATAL,
375
+ pluginId: name, message: `依赖 ${name} 版本不兼容:镜像 ${existing} vs 原身 ${next}`,
376
+ mirrorValue: existing, essenceValue: next,
377
+ })
378
+ }
379
+
380
+ const merged = { ...mirrorPkg, ...overlayPkg, dependencies: mergedDeps }
381
+ return merged
382
+ }
383
+
384
+ /**
385
+ * 合并镜像基础 profile 与原身覆盖层(纯函数,不碰磁盘)。
386
+ * @param {{ patchEntries: Array<Record<string, unknown>>, packageJson: Record<string, unknown>, lockedPluginIds: Iterable<string> }} mirror
387
+ * @param {{ patchEntries: Array<Record<string, unknown>>, packageJson: Record<string, unknown>, settings?: Record<string, unknown> }} overlay
388
+ * @returns {MergeResult}
389
+ */
390
+ export function mergeProfiles(mirror, overlay) {
391
+ /** @type {Conflict[]} */
392
+ const conflicts = []
393
+ const mergedPatchEntries = mergePatchEntries(
394
+ mirror.patchEntries ?? [], overlay.patchEntries ?? [], mirror.lockedPluginIds ?? [], conflicts,
395
+ )
396
+ const mergedPackageJson = mergePackageJson(asObject(mirror.packageJson), asObject(overlay.packageJson), conflicts)
397
+ const mirrorSettings = asObject(asObject(mirror.packageJson).settings)
398
+ const mergedSettings = deepMerge(mirrorSettings, asObject(overlay.settings))
399
+ const hasFatal = conflicts.some(conflict => conflict.severity === CONFLICT_SEVERITY.FATAL)
400
+ return { mergedPatchEntries, mergedPackageJson, mergedSettings, conflicts, hasFatal }
401
+ }
402
+
403
+ // ===========================================================================
404
+ // EssenceManager:磁盘布局、物化、状态
405
+ // ===========================================================================
406
+
407
+ /**
408
+ * @typedef {object} EssenceManagerOptions
409
+ * @property {string} essencesRoot 文件夹 2 根(essences/)
410
+ * @property {string} [mirrorProfileDir] 镜像基础 profile 目录(默认 <mirrorRoot>/active/profile)
411
+ * @property {string} [mirrorRoot] 镜像根(用于读 meta.json / mirror-locked.json)
412
+ * @property {Iterable<string>} [mirrorLockedIds] 锁定项(优先于 mirror-locked.json)
413
+ * @property {{info?:Function, warn?:Function}} [logger]
414
+ * @property {() => number} [now]
415
+ */
416
+
417
+ export class EssenceManager {
418
+ /** @param {EssenceManagerOptions} options */
419
+ constructor(options) {
420
+ this.essencesRoot = String(options.essencesRoot ?? '')
421
+ if (this.essencesRoot.length === 0) throw new Error('EssenceManager: essencesRoot required')
422
+ this.mirrorRoot = options.mirrorRoot ? String(options.mirrorRoot) : path.join(this.essencesRoot, '..', 'mirror')
423
+ this.mirrorProfileDir = options.mirrorProfileDir
424
+ ? String(options.mirrorProfileDir)
425
+ : path.join(this.mirrorRoot, 'active', 'profile')
426
+ this.mirrorLockedIds = new Set(options.mirrorLockedIds ?? [])
427
+ this.logger = options.logger ?? {}
428
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now()
429
+ this.mutex = new KeyedMutex()
430
+ }
431
+
432
+ // ── 读取 ────────────────────────────────────────────────────────────────
433
+
434
+ /** @returns {Array<{id:string, name:string, tier:string, parentId:string|null, createdAt:number, updatedAt:number, lastMergedFingerprint:string|null, status:string, conflictCount?:number}>} */
435
+ list() {
436
+ if (!exists(this.essencesRoot)) return []
437
+ /** @type {any[]} */
438
+ const out = []
439
+ for (const entry of fs.readdirSync(this.essencesRoot, { withFileTypes: true })) {
440
+ if (!entry.isDirectory() || !entry.name.startsWith('essence-')) continue
441
+ try { out.push(this.readMeta(this.essenceRoot(entry.name))) } catch { /* 半成品目录跳过 */ }
442
+ }
443
+ out.sort((a, b) => a.createdAt - b.createdAt)
444
+ return out
445
+ }
446
+
447
+ /** @param {string} parentId @returns {any[]} */
448
+ listChildren(parentId) {
449
+ return this.list().filter(meta => meta.parentId === parentId)
450
+ }
451
+
452
+ /** @param {string} essenceId @returns {string} */
453
+ essenceRoot(essenceId) {
454
+ return path.join(this.essencesRoot, this.assertId(essenceId))
455
+ }
456
+
457
+ /** @param {string} essenceId @returns {string} 物化后的 profile 目录(供派生实例 --profile 使用) */
458
+ mergedProfileDir(essenceId) {
459
+ return path.join(this.essenceRoot(essenceId), 'merged', 'profile')
460
+ }
461
+
462
+ /**
463
+ * 幂等确保 merged/profile 最新:镜像指纹未变且状态 ready 时跳过重算。
464
+ * 同一原身的并发调用按 key 串行,只有第一个真正重算(后续看到指纹已匹配直接返回)。
465
+ * @param {string} essenceId
466
+ * @returns {Promise<{meta: Record<string, unknown>, recomputed: boolean}>}
467
+ */
468
+ async ensureFresh(essenceId) {
469
+ return this.mutex.run(essenceId, async () => {
470
+ const root = this.essenceRoot(essenceId)
471
+ const meta = this.readMeta(root)
472
+ const mirror = this.loadMirror()
473
+ if (meta.status === ESSENCE_STATUS.READY && meta.lastMergedFingerprint === mirror.fingerprint) {
474
+ return { meta, recomputed: false }
475
+ }
476
+ this.recompute(essenceId)
477
+ return { meta: this.readMeta(root), recomputed: true }
478
+ })
479
+ }
480
+
481
+ /** @param {string} essenceId */
482
+ getMeta(essenceId) {
483
+ return this.readMeta(this.essenceRoot(essenceId))
484
+ }
485
+
486
+ /** @param {string} essenceId @returns {Conflict[]} */
487
+ getConflicts(essenceId) {
488
+ return /** @type {Conflict[]} */ (readJson(path.join(this.essenceRoot(essenceId), 'conflicts.json'), []))
489
+ }
490
+
491
+ // ── 写 ──────────────────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * 创建原身。
495
+ * @param {{name:string, tier?:string, parentId?:string|null}} options
496
+ * @returns {any} meta
497
+ */
498
+ create(options) {
499
+ const name = String(options?.name ?? '').trim()
500
+ if (name.length === 0) throw new Error('essence name required')
501
+ const tier = normalizeTier(options?.tier)
502
+ const parentId = options?.parentId ? String(options.parentId) : null
503
+ if (parentId !== null) {
504
+ const parent = this.getMeta(parentId)
505
+ if (parent.parentId !== null) throw new Error('只允许两级原身:子原身不能再有自己的子原身')
506
+ }
507
+ if (options?.skipQuota !== true) {
508
+ const quota = parentId === null
509
+ ? checkEssenceQuota(tier, this.list().filter(meta => meta.parentId === null).length)
510
+ : checkSubEssenceQuota(tier, this.listChildren(parentId).length)
511
+ if (!quota.ok) throw new Error(String(quota.reason))
512
+ }
513
+
514
+ const requestedId = options?.id === undefined ? '' : String(options.id)
515
+ const id = requestedId.length > 0
516
+ ? requestedId
517
+ : `essence-${this.now()}-${sha256(`${name}:${this.now()}:${Math.random()}`).slice(0, 6)}`
518
+ const root = this.essenceRoot(id)
519
+ // v3.0.5/B6:create-only —— 已存在的原身不能被 create() 覆盖/抢占
520
+ if (exists(path.join(root, 'meta.json'))) throw new Error(`essence already exists: ${id}`)
521
+ ensureDir(path.join(root, 'overlay', 'settings'))
522
+ ensureDir(path.join(root, 'overlay', 'credentials'))
523
+ ensureDir(path.join(root, 'merged', 'profile'))
524
+ const officialSeed = options?.official === true
525
+ const meta = {
526
+ id, name, tier, parentId,
527
+ createdAt: this.now(), updatedAt: this.now(),
528
+ lastMergedFingerprint: null, status: ESSENCE_STATUS.READY,
529
+ locked: officialSeed && options?.locked === true,
530
+ official: officialSeed,
531
+ }
532
+ this.writeMeta(root, meta)
533
+ const defaultEntries = Array.isArray(options?.defaultPatchEntries) ? options.defaultPatchEntries : []
534
+ if (defaultEntries.length > 0) {
535
+ const overlay = this.loadOverlay(root)
536
+ overlay.patchEntries = [...defaultEntries.map(entry => ({ ...entry })), ...overlay.patchEntries]
537
+ this.saveOverlay(root, overlay)
538
+ }
539
+ this.recompute(id)
540
+ return this.getMeta(id)
541
+ }
542
+
543
+ /**
544
+ * 确保“官方推荐原身”存在(首次启动 seed,幂等;锁定不可删改)。
545
+ * @param {{defaultPatchEntries?: Array<Record<string, unknown>>}} [options]
546
+ * @returns {any} meta
547
+ */
548
+ ensureOfficialEssence(options = {}) {
549
+ const root = this.essenceRoot(OFFICIAL_ESSENCE_ID)
550
+ if (exists(path.join(root, 'meta.json'))) {
551
+ const meta = this.getMeta(OFFICIAL_ESSENCE_ID)
552
+ // v3.0.5/B6:已存在的官方原身必须是 locked+official;被篡改则修复(不删除数据)
553
+ if (meta.locked !== true || meta.official !== true) {
554
+ this.writeMeta(root, { ...meta, locked: true, official: true, updatedAt: this.now() })
555
+ return this.getMeta(OFFICIAL_ESSENCE_ID)
556
+ }
557
+ return meta
558
+ }
559
+ return this.create({
560
+ id: OFFICIAL_ESSENCE_ID,
561
+ name: OFFICIAL_ESSENCE_NAME,
562
+ tier: OFFICIAL_ESSENCE_MIN_TIER,
563
+ locked: true,
564
+ official: true,
565
+ skipQuota: true,
566
+ defaultPatchEntries: options?.defaultPatchEntries ?? DEFAULT_OFFICIAL_PATCH_ENTRIES,
567
+ })
568
+ }
569
+
570
+ /**
571
+ * 从母体 profile 生成元主(v3.0.9):把母体的**可写层**克隆成新元主的覆盖层。
572
+ *
573
+ * 克隆范围(只读母体):
574
+ * * `cordis.patch.yml` → overlay patch entries(插件安装/启用/配置)
575
+ * * `package.json` → overlay 的 name/private/type/dsh/dependencies(可关)
576
+ * * `settings/*.yaml` → overlay settings(可关)
577
+ * 绝不复制:credentials、sessions、storages、logs、node_modules(B8 边界)。
578
+ *
579
+ * @param {string} profileDir 母体 profile 目录(必须含 package.json)
580
+ * @param {{name?:string, tier?:string, parentId?:string|null, includeDependencies?:boolean,
581
+ * includeSettings?:boolean, defaultPatchEntries?:Array<Record<string,unknown>>, skipQuota?:boolean}} [options]
582
+ * @returns {{meta:any, captured:Record<string,unknown>, conflicts:Conflict[], status:string}}
583
+ */
584
+ captureFromProfile(profileDir, options = {}) {
585
+ const dir = path.resolve(String(profileDir ?? '').trim())
586
+ if (dir.length === 0) throw new Error('capture: mother profile dir required')
587
+ const packageFile = path.join(dir, 'package.json')
588
+ if (!exists(packageFile)) throw new Error(`capture: mother profile has no package.json: ${dir}`)
589
+ // 元主自己的物化目录不能当母体(否则是自我叠加)。
590
+ const relative = path.relative(path.resolve(this.essencesRoot), dir)
591
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
592
+ throw new Error('capture: refusing to clone from an essence directory')
593
+ }
594
+ const profilePkg = asObject(readJson(packageFile, {}))
595
+ const patchText = exists(path.join(dir, 'cordis.patch.yml'))
596
+ ? fs.readFileSync(path.join(dir, 'cordis.patch.yml'), 'utf8')
597
+ : ''
598
+ const parsed = parsePatchEntries(patchText)
599
+ if (parsed.errors.length > 0) {
600
+ throw new Error(`capture: mother patch parse failed: ${parsed.errors.join('; ')}`)
601
+ }
602
+ const fallbackName = typeof profilePkg.name === 'string' && profilePkg.name.length > 0
603
+ ? `${profilePkg.name} 元主` : `${path.basename(dir)} 元主`
604
+ const name = String(options?.name ?? '').trim() || fallbackName
605
+
606
+ const created = this.create({
607
+ name,
608
+ tier: options?.tier,
609
+ parentId: options?.parentId ?? null,
610
+ skipQuota: options?.skipQuota === true,
611
+ })
612
+ const root = this.essenceRoot(created.id)
613
+ const overlay = this.loadOverlay(root)
614
+ const seedEntries = Array.isArray(options?.defaultPatchEntries)
615
+ ? options.defaultPatchEntries.map(entry => ({ ...entry })) : []
616
+ overlay.patchEntries = [...seedEntries, ...parsed.entries, ...overlay.patchEntries]
617
+ if (options?.includeDependencies !== false) {
618
+ overlay.packageJson = { ...this.selectProfilePackage(profilePkg), ...overlay.packageJson }
619
+ }
620
+ if (options?.includeSettings !== false) {
621
+ Object.assign(overlay.settings, this.readProfileSettings(dir))
622
+ }
623
+ this.saveOverlay(root, overlay)
624
+ const merge = this.recompute(created.id)
625
+ const bundles = asObject(profilePkg.dsh).profile?.bundles
626
+ const captured = {
627
+ profileDir: dir,
628
+ profileName: typeof profilePkg.name === 'string' ? profilePkg.name : null,
629
+ patchEntries: parsed.entries.length,
630
+ dependencies: Object.keys(asObject(overlay.packageJson.dependencies)).length,
631
+ bundles: Array.isArray(bundles) ? bundles.length : 0,
632
+ settings: Object.keys(overlay.settings).length,
633
+ credentialsCopied: false,
634
+ }
635
+ return { meta: this.getMeta(created.id), captured, conflicts: merge.conflicts, status: merge.status }
636
+ }
637
+
638
+ /** 提取可安全复制进元主 overlay 的 profile package.json 字段(不含 scripts/devDeps)。 */
639
+ selectProfilePackage(profilePkg) {
640
+ /** @type {Record<string, unknown>} */
641
+ const picked = {}
642
+ for (const key of ['name', 'private', 'type']) {
643
+ if (profilePkg?.[key] !== undefined) picked[key] = profilePkg[key]
644
+ }
645
+ const dsh = asObject(profilePkg?.dsh)
646
+ if (Object.keys(dsh).length > 0) picked.dsh = dsh
647
+ const deps = asObject(profilePkg?.dependencies)
648
+ if (Object.keys(deps).length > 0) picked.dependencies = deps
649
+ return picked
650
+ }
651
+
652
+ /** 读取母体 profile 的 settings/*.yaml(仅为覆盖层,不碰其他目录)。 */
653
+ readProfileSettings(profileDir) {
654
+ /** @type {Record<string, unknown>} */
655
+ const settings = {}
656
+ const settingsDir = path.join(String(profileDir), 'settings')
657
+ if (!exists(settingsDir)) return settings
658
+ for (const file of fs.readdirSync(settingsDir)) {
659
+ if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue
660
+ const text = fs.readFileSync(path.join(settingsDir, file), 'utf8')
661
+ const name = file.replace(/\.ya?ml$/, '')
662
+ try { settings[name] = JSON.parse(text) } catch {
663
+ settings[name] = parseYamlSubset(text).value
664
+ }
665
+ }
666
+ return settings
667
+ }
668
+
669
+ /**
670
+ * 修改覆盖层并重算(mutator 直接改 overlay 对象;save 由 manager 完成)。
671
+ * @param {string} essenceId
672
+ * @param {(overlay: {patchEntries:Array<Record<string,unknown>>, packageJson:Record<string,unknown>, settings:Record<string,unknown>, credentialsPath:string}) => (void|Promise<void>)} mutator
673
+ * @returns {any} meta
674
+ */
675
+ async update(essenceId, mutator) {
676
+ const root = this.essenceRoot(essenceId)
677
+ const meta = this.readMeta(root)
678
+ if (meta.locked === true) throw new Error(`essence ${essenceId} is locked(官方推荐原身不支持修改设置)`)
679
+ assertNotFrozen(meta)
680
+ const overlay = this.loadOverlay(root)
681
+ await mutator(overlay)
682
+ this.saveOverlay(root, overlay)
683
+ this.recompute(essenceId)
684
+ return this.getMeta(essenceId)
685
+ }
686
+
687
+ /**
688
+ * 删除原身(有子原身时拒绝)。
689
+ * @param {string} essenceId
690
+ * @returns {{removed:boolean}}
691
+ */
692
+ remove(essenceId) {
693
+ const meta = this.getMeta(essenceId)
694
+ if (meta.locked === true) throw new Error(`essence ${essenceId} is locked(官方推荐原身不可删除)`)
695
+ // v3.0.9 规则:先归档(冻结)才允许删除,删除本身不可撤销。
696
+ if (meta.archived !== true) throw new Error(`essence ${essenceId} is not archived; archive it before deleting`)
697
+ if (meta.parentId === null) {
698
+ const children = this.listChildren(essenceId)
699
+ if (children.length > 0) throw new Error(`essence ${essenceId} has ${children.length} sub-essences; remove them first`)
700
+ }
701
+ removeTree(this.essenceRoot(essenceId))
702
+ return { removed: true }
703
+ }
704
+
705
+ /** @param {string} essenceId @param {string} tier */
706
+ setTier(essenceId, tier) {
707
+ const root = this.essenceRoot(essenceId)
708
+ const meta = this.readMeta(root)
709
+ if (meta.locked === true) throw new Error(`essence ${essenceId} is locked(官方推荐原身等级不可修改)`)
710
+ assertNotFrozen(meta)
711
+ const next = { ...meta, tier: normalizeTier(tier), updatedAt: this.now() }
712
+ this.writeMeta(root, next)
713
+ return this.getMeta(essenceId)
714
+ }
715
+
716
+ // ── v3.0.6 元主 CRUD / 默认元主 / 导出 ────────────────────────────────
717
+ /** 重命名(locked 拒绝;1–80 字符)。 */
718
+ rename(essenceId, name) {
719
+ const trimmed = String(name ?? '').trim()
720
+ if (trimmed.length === 0 || trimmed.length > 80) throw new Error('essence name must be 1..80 chars')
721
+ const root = this.essenceRoot(essenceId)
722
+ const meta = this.readMeta(root)
723
+ if (meta.locked === true) throw new Error(`essence ${essenceId} is locked(官方推荐原身不可重命名)`)
724
+ assertNotFrozen(meta)
725
+ const next = { ...meta, name: trimmed, updatedAt: this.now() }
726
+ this.writeMeta(root, next)
727
+ return this.getMeta(essenceId)
728
+ }
729
+
730
+ /** 归档/取消归档(locked 拒绝;归档只改元数据,不删目录)。 */
731
+ archive(essenceId, archived = true) {
732
+ const root = this.essenceRoot(essenceId)
733
+ const meta = this.readMeta(root)
734
+ if (meta.locked === true) throw new Error(`essence ${essenceId} is locked(官方推荐原身不可归档)`)
735
+ const next = {
736
+ ...meta,
737
+ archived: archived === true,
738
+ archivedAt: archived === true ? this.now() : null,
739
+ updatedAt: this.now(),
740
+ }
741
+ this.writeMeta(root, next)
742
+ return this.getMeta(essenceId)
743
+ }
744
+
745
+ /** 设置默认派生元主(快速派生使用)。 */
746
+ setDefault(essenceId) {
747
+ const meta = this.getMeta(essenceId)
748
+ ensureDir(this.essencesRoot)
749
+ writeJsonAtomic(path.join(this.essencesRoot, 'default.json'), { essenceId: meta.id, updatedAt: this.now() })
750
+ return { essenceId: meta.id, name: meta.name }
751
+ }
752
+
753
+ getDefault() {
754
+ const doc = readJson(path.join(this.essencesRoot, 'default.json'), null)
755
+ const id = typeof doc?.essenceId === 'string' ? doc.essenceId : OFFICIAL_ESSENCE_ID
756
+ try {
757
+ const meta = this.getMeta(id)
758
+ return { essenceId: meta.id, name: meta.name, official: meta.official === true }
759
+ } catch {
760
+ return { essenceId: OFFICIAL_ESSENCE_ID, name: OFFICIAL_ESSENCE_NAME, official: true }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * 复制元主(overlay 配置 + 设置;**不复制 credentials**,B8)。
766
+ * @param {string} essenceId
767
+ * @param {{name?: string}} [options]
768
+ */
769
+ copy(essenceId, options = {}) {
770
+ const source = this.getMeta(essenceId)
771
+ assertNotFrozen(source)
772
+ const sourceRoot = this.essenceRoot(essenceId)
773
+ const name = String(options?.name ?? `${source.name} 副本`).trim()
774
+ const created = this.create({
775
+ name,
776
+ tier: source.tier,
777
+ parentId: source.parentId ?? null,
778
+ })
779
+ const nextRoot = this.essenceRoot(created.id)
780
+ // v3.0.9 深复制:整份沙箱目录(overlay + merged + 自定义数据)到新文件夹。
781
+ // 边界:不复制 credentials(B8)、不复制 runtime(运行态进程目录)、不复制 Backup、不覆盖新 meta。
782
+ const skip = new Set(['meta.json', 'credentials', 'runtime', 'Backup'])
783
+ let copiedFiles = 0
784
+ for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
785
+ if (skip.has(entry.name)) continue
786
+ const from = path.join(sourceRoot, entry.name)
787
+ const to = path.join(nextRoot, entry.name)
788
+ if (exists(to)) removeTree(to)
789
+ copyTree(from, to)
790
+ // overlay/credentials 属于登录态,必须剔除(B8)。
791
+ const copiedCredentials = path.join(to, 'credentials')
792
+ if (entry.name === 'overlay' && exists(copiedCredentials)) removeTree(copiedCredentials)
793
+ if (entry.isDirectory()) {
794
+ const walk = (dir) => {
795
+ for (const child of fs.readdirSync(dir, { withFileTypes: true })) {
796
+ const file = path.join(dir, child.name)
797
+ if (child.isDirectory()) walk(file)
798
+ else if (child.isFile()) copiedFiles += 1
799
+ }
800
+ }
801
+ walk(to)
802
+ } else if (entry.isFile()) copiedFiles += 1
803
+ }
804
+ // 凭据目录在新沙箱里保持为空(不继承任何登录态)。
805
+ ensureDir(path.join(nextRoot, 'overlay', 'credentials'))
806
+ this.recompute(created.id)
807
+ const meta = this.getMeta(created.id)
808
+ return { ...meta, copy: { sourceId: source.id, copiedFiles } }
809
+ }
810
+
811
+ /** 元主当前插件条目数(用于面板卡片)。 */
812
+ pluginCount(essenceId) {
813
+ return this.loadOverlay(this.essenceRoot(essenceId)).patchEntries.length
814
+ }
815
+
816
+ /**
817
+ * 导出元主配置(overlay:patch/package/settings;**不含凭据**)。
818
+ * 供 `GET /api/essence/download` 返回 JSON 文件;超过 maxBytes 拒绝。
819
+ */
820
+ downloadBundle(essenceId, options = {}) {
821
+ const maxBytes = Math.max(1024, Math.floor(Number(options.maxBytes) || 32 * 1024 * 1024))
822
+ const root = this.essenceRoot(essenceId)
823
+ const meta = this.getMeta(essenceId)
824
+ const full = options.full === true
825
+ const base = full ? root : path.join(root, 'overlay')
826
+ const files = []
827
+ let total = 0
828
+ // full=true:整包导出(跨机迁移);跳过凭据、运行时 home 与备份目录。
829
+ const skip = new Set(full ? ['credentials', 'runtime', 'Backup'] : ['credentials'])
830
+ const walk = (dir, relative) => {
831
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
832
+ if (skip.has(entry.name)) continue
833
+ // full=true:运行态只导出它的会话沙箱(runtime/home/sessions),不含进程/依赖目录。
834
+ if (full && entry.name === 'runtime') {
835
+ const sessionsDir = path.join(dir, 'runtime', 'home', 'sessions')
836
+ if (exists(sessionsDir)) walk(sessionsDir, 'runtime/home/sessions')
837
+ continue
838
+ }
839
+ const target = path.join(dir, entry.name)
840
+ const rel = relative ? `${relative}/${entry.name}` : entry.name
841
+ if (entry.isDirectory()) { walk(target, rel); continue }
842
+ if (!entry.isFile()) continue
843
+ const bytes = fs.readFileSync(target)
844
+ total += bytes.length
845
+ if (total > maxBytes) throw new Error('essence bundle too large')
846
+ files.push({ path: full ? rel : `overlay/${rel}`, base64: bytes.toString('base64') })
847
+ }
848
+ }
849
+ if (exists(base)) walk(base, '')
850
+ if (full) {
851
+ // 迁移引导 + 清单,保证新 PC 拿到包就知道怎么导入。
852
+ const guide = [
853
+ '# dsh-Fang 元主导入引导',
854
+ '',
855
+ '1. 目标机器安装并启用 dsh-Fang(@twinsearth/fang-dsh-plugin)。',
856
+ '2. 打开瓮城面板 → 元主区 → 「导入」→ 选择本 JSON 文件(或 POST /fang/api/essence/import)。',
857
+ '3. 导入会生成新的 essences/<新id> 并自动重算;凭据不随包迁移,需要重新登录。',
858
+ '4. 打开前建议先执行「同步」,把该元主重放到目标机器当前的母体镜像。',
859
+ '',
860
+ `来源元主:${String(meta?.name ?? '')} (${essenceId})`,
861
+ `导出时间:${new Date(this.now()).toISOString()}`,
862
+ '包格式:dsh-fang-essence-bundle v1',
863
+ ].join('\n')
864
+ const manifest = JSON.stringify({
865
+ format: 'dsh-fang-essence-bundle',
866
+ version: 1,
867
+ createdAt: this.now(),
868
+ sourceId: essenceId,
869
+ name: meta?.name ?? null,
870
+ tier: meta?.tier ?? null,
871
+ files: files.length,
872
+ }, null, 2)
873
+ const appendText = (name, text) => {
874
+ const bytes = Buffer.from(text, 'utf8')
875
+ total += bytes.length
876
+ if (total > maxBytes) throw new Error('essence bundle too large')
877
+ files.push({ path: name, base64: bytes.toString('base64') })
878
+ }
879
+ appendText('IMPORT-GUIDE.md', guide)
880
+ appendText('manifest.json', manifest)
881
+ }
882
+ return { ok: true, essenceId, meta, files, bytes: total, full, createdAt: this.now() }
883
+ }
884
+
885
+ /** v3.0.9 备份:essences/<id>/Backup(固定一份,旧备份覆盖)。 */
886
+ backup(essenceId) {
887
+ const root = this.essenceRoot(essenceId)
888
+ assertNotFrozen(this.getMeta(essenceId))
889
+ const target = path.join(root, 'Backup')
890
+ if (exists(target)) removeTree(target)
891
+ // 原子写可能留下 .meta.json.*.tmp:默认过滤点文件,避免备份时 EPERM/半成品。
892
+ // runtime 是运行态进程目录(可能含 node_modules junction),不进入备份。
893
+ copyTree(root, target, {
894
+ filter: (name) => name !== 'meta.json' && name !== 'runtime' && !name.startsWith('.'),
895
+ })
896
+ const metaFile = path.join(root, 'meta.json')
897
+ if (exists(metaFile)) {
898
+ fs.writeFileSync(path.join(target, 'meta.json'), fs.readFileSync(metaFile))
899
+ }
900
+ const nested = path.join(target, 'Backup')
901
+ if (exists(nested)) removeTree(nested)
902
+ let bytes = 0
903
+ const walk = (dir) => {
904
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
905
+ const file = path.join(dir, entry.name)
906
+ if (entry.isDirectory()) walk(file)
907
+ else if (entry.isFile()) bytes += fs.statSync(file).size
908
+ }
909
+ }
910
+ walk(target)
911
+ return { ok: true, essenceId, backupDir: target, bytes, at: this.now() }
912
+ }
913
+
914
+ /** v3.0.9 还原:用 Backup 覆盖当前 overlay/merged/meta(Backup 保留一份)。 */
915
+ restoreBackup(essenceId) {
916
+ assertNotFrozen(this.getMeta(essenceId))
917
+ const root = this.essenceRoot(essenceId)
918
+ const backupDir = path.join(root, 'Backup')
919
+ if (!exists(backupDir)) throw new Error('backup-missing')
920
+ for (const entry of fs.readdirSync(backupDir, { withFileTypes: true })) {
921
+ if (entry.name === 'Backup' || entry.name === 'runtime' || entry.name === 'meta.json') continue
922
+ const from = path.join(backupDir, entry.name)
923
+ const to = path.join(root, entry.name)
924
+ if (exists(to)) removeTree(to)
925
+ copyTree(from, to)
926
+ }
927
+ this.recompute(essenceId)
928
+ return { ok: true, essenceId, restored: true, at: this.now() }
929
+ }
930
+
931
+ /** v3.0.9 导入:{meta, files:[{path,base64}]} → 新元主目录,可直接继续开发。 */
932
+ importBundle(doc, options = {}) {
933
+ const sourceMeta = asObject(doc?.meta)
934
+ const files = Array.isArray(doc?.files) ? doc.files : []
935
+ if (files.length === 0) throw new Error('bundle has no files')
936
+ const name = String(options?.name ?? sourceMeta.name ?? '导入的元主').trim() || '导入的元主'
937
+ const meta = this.create({
938
+ name,
939
+ tier: options?.tier ?? sourceMeta.tier,
940
+ parentId: options?.parentId ?? null,
941
+ skipQuota: options?.skipQuota === true,
942
+ })
943
+ const root = this.essenceRoot(meta.id)
944
+ for (const file of files) {
945
+ const rel = String(file?.path ?? '')
946
+ if (!rel || rel.includes('..') || path.isAbsolute(rel)) continue
947
+ // 包里的 meta.json / Backup 不能覆盖导入后新建的元数据。
948
+ if (rel === 'meta.json' || rel.startsWith('Backup/') || rel === 'Backup') continue
949
+ const target = path.join(root, rel)
950
+ ensureDir(path.dirname(target))
951
+ fs.writeFileSync(target, Buffer.from(String(file.base64 ?? ''), 'base64'))
952
+ }
953
+ this.recompute(meta.id)
954
+ return { ok: true, essence: this.getMeta(meta.id), files: files.length, sourceId: sourceMeta.id ?? null }
955
+ }
956
+
957
+ // ── 合并与物化 ──────────────────────────────────────────────────────────
958
+
959
+ /** 读取镜像基础快照(patch/package/json/locked/fingerprint)。 */
960
+ loadMirror() {
961
+ const root = this.mirrorProfileDir
962
+ ensureDir(root)
963
+ const patchText = exists(path.join(root, 'cordis.patch.yml'))
964
+ ? fs.readFileSync(path.join(root, 'cordis.patch.yml'), 'utf8')
965
+ : ''
966
+ const parsed = parsePatchEntries(patchText)
967
+ const packageJson = asObject(readJson(path.join(root, 'package.json'), {}))
968
+ const lockedFromFile = this.readLockedFromMirror()
969
+ const lockedPluginIds = new Set([...this.mirrorLockedIds, ...lockedFromFile])
970
+ const tree = digestTree(root)
971
+ const fingerprint = sha256(JSON.stringify(tree))
972
+ let version = 'unknown'
973
+ try { version = String(readJson(path.join(this.mirrorRoot, 'meta.json'), {}).version ?? 'unknown') } catch { version = 'unknown' }
974
+ return {
975
+ root, version, fingerprint, packageJson,
976
+ patchEntries: parsed.entries,
977
+ parseErrors: parsed.errors,
978
+ lockedPluginIds,
979
+ }
980
+ }
981
+
982
+ /** 读取原身覆盖层(*_test 可直接构造)。 */
983
+ loadOverlay(root) {
984
+ const overlayRoot = path.join(root, 'overlay')
985
+ ensureDir(overlayRoot)
986
+ const patchText = exists(path.join(overlayRoot, 'cordis.patch.yml'))
987
+ ? fs.readFileSync(path.join(overlayRoot, 'cordis.patch.yml'), 'utf8')
988
+ : ''
989
+ const parsed = parsePatchEntries(patchText)
990
+ const settings = {}
991
+ const settingsDir = path.join(overlayRoot, 'settings')
992
+ if (exists(settingsDir)) {
993
+ for (const file of fs.readdirSync(settingsDir)) {
994
+ if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue
995
+ const text = fs.readFileSync(path.join(settingsDir, file), 'utf8')
996
+ const name = file.replace(/\.ya?ml$/, '')
997
+ try { settings[name] = JSON.parse(text) } catch {
998
+ const subset = parseYamlSubset(text)
999
+ settings[name] = subset.value
1000
+ }
1001
+ }
1002
+ }
1003
+ return {
1004
+ root: overlayRoot,
1005
+ patchEntries: parsed.entries,
1006
+ packageJson: asObject(readJson(path.join(overlayRoot, 'package.json'), {})),
1007
+ settings,
1008
+ credentialsPath: path.join(overlayRoot, 'credentials'),
1009
+ parseErrors: parsed.errors,
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * 重算单个原身:合并 → 失败则标记 conflicted 并保留上一次物化结果;成功则重建 merged/profile。
1015
+ * @param {string} essenceId
1016
+ * @returns {MergeResult & { status: string, fingerprint: string }}
1017
+ */
1018
+ recompute(essenceId) {
1019
+ const root = this.essenceRoot(essenceId)
1020
+ const meta = this.readMeta(root)
1021
+ const mirror = this.loadMirror()
1022
+ const overlay = this.loadOverlay(root)
1023
+ const result = mergeProfiles(mirror, overlay)
1024
+ const parseErrors = [...(mirror.parseErrors ?? []), ...(overlay.parseErrors ?? [])]
1025
+ if (parseErrors.length > 0) {
1026
+ result.conflicts.push({
1027
+ kind: CONFLICT_KIND.MISSING_PLUGIN, severity: CONFLICT_SEVERITY.FATAL,
1028
+ pluginId: '<patch>', message: `patch 解析错误:${parseErrors.join('; ')}`,
1029
+ })
1030
+ result.hasFatal = true
1031
+ }
1032
+
1033
+ if (result.hasFatal) {
1034
+ writeJsonAtomic(path.join(root, 'conflicts.json'), result.conflicts)
1035
+ this.writeMeta(root, {
1036
+ ...meta, status: ESSENCE_STATUS.CONFLICTED, updatedAt: this.now(),
1037
+ lastMergeConflicts: result.conflicts.length,
1038
+ })
1039
+ this.logger?.warn?.(`[essence] ${essenceId} has fatal conflicts`, {
1040
+ fatal: result.conflicts.filter(conflict => conflict.severity === 'fatal').length,
1041
+ })
1042
+ return { ...result, status: ESSENCE_STATUS.CONFLICTED, fingerprint: mirror.fingerprint }
1043
+ }
1044
+
1045
+ this.materializeMerged(root, mirror, overlay, result)
1046
+ if (exists(path.join(root, 'conflicts.json'))) fs.rmSync(path.join(root, 'conflicts.json'), { force: true })
1047
+ this.writeMeta(root, {
1048
+ ...meta, status: ESSENCE_STATUS.READY, updatedAt: this.now(),
1049
+ lastMergedFingerprint: mirror.fingerprint, lastMergeConflicts: result.conflicts.length,
1050
+ })
1051
+ return { ...result, status: ESSENCE_STATUS.READY, fingerprint: mirror.fingerprint }
1052
+ }
1053
+
1054
+ /**
1055
+ * 镜像升级后批量重算:只重算 fingerprint 不匹配的原身(ensureFresh),
1056
+ * 并发上限 + 单个超时 + 失败隔离 + 进度回调 + AbortSignal。
1057
+ * @param {{concurrency?: number, perEssenceTimeoutMs?: number, signal?: AbortSignal, onProgress?: Function}} [options]
1058
+ * @returns {Promise<{recomputed: string[], skipped: string[], conflicted: string[], failed: Array<{id: string, error: string}>, aborted: boolean, durationMs: number}>}
1059
+ */
1060
+ async recomputeAll(options = {}) {
1061
+ const startedAt = this.now()
1062
+ const concurrency = Math.max(1, Math.floor(Number(options.concurrency) || 4))
1063
+ const timeoutMs = Math.max(1, Math.floor(Number(options.perEssenceTimeoutMs) || 60_000))
1064
+ const ids = this.list().map(meta => String(meta.id))
1065
+ const result = { recomputed: [], skipped: [], conflicted: [], failed: [], aborted: false, durationMs: 0 }
1066
+ if (ids.length === 0) {
1067
+ result.durationMs = this.now() - startedAt
1068
+ return result
1069
+ }
1070
+ const semaphore = new Semaphore(concurrency)
1071
+ let done = 0
1072
+ const report = (currentId) => {
1073
+ try {
1074
+ options.onProgress?.({
1075
+ done, total: ids.length, currentId,
1076
+ recomputed: result.recomputed.length,
1077
+ skipped: result.skipped.length,
1078
+ conflicted: result.conflicted.length,
1079
+ failed: result.failed.length,
1080
+ })
1081
+ } catch { /* 观察者异常不影响重算 */ }
1082
+ }
1083
+ await Promise.all(ids.map(async (id) => {
1084
+ await semaphore.acquire()
1085
+ try {
1086
+ if (options.signal?.aborted) { result.aborted = true; return }
1087
+ const fresh = await withTimeout(this.ensureFresh(id), timeoutMs, `recompute timeout for ${id}`)
1088
+ if (fresh.meta?.status === ESSENCE_STATUS.CONFLICTED) result.conflicted.push(id)
1089
+ else if (fresh.recomputed === true) result.recomputed.push(id)
1090
+ else result.skipped.push(id)
1091
+ } catch (error) {
1092
+ result.failed.push({ id, error: String(error instanceof Error ? error.message : error) })
1093
+ } finally {
1094
+ done += 1
1095
+ report(id)
1096
+ semaphore.release()
1097
+ }
1098
+ }))
1099
+ result.durationMs = this.now() - startedAt
1100
+ return result
1101
+ }
1102
+
1103
+ // ── 内部 ────────────────────────────────────────────────────────────────
1104
+
1105
+ /** @param {string} value @returns {string} */
1106
+ assertId(value) {
1107
+ const id = String(value ?? '')
1108
+ if (!/^essence-[A-Za-z0-9-]+$/.test(id)) throw new Error(`invalid essence id: ${id}`)
1109
+ return id
1110
+ }
1111
+
1112
+ /** @param {string} root */
1113
+ readMeta(root) {
1114
+ return asObject(readJson(path.join(root, 'meta.json'), {}))
1115
+ }
1116
+
1117
+ /** @param {string} root @param {Record<string, unknown>} meta */
1118
+ writeMeta(root, meta) {
1119
+ ensureDir(root)
1120
+ writeJsonAtomic(path.join(root, 'meta.json'), meta)
1121
+ }
1122
+
1123
+ /** 读取镜像锁定项文件(可选 `<mirrorRoot>/mirror-locked.json`,支持数组或 {locked:[...]})。 */
1124
+ readLockedFromMirror() {
1125
+ const file = path.join(this.mirrorRoot, 'mirror-locked.json')
1126
+ if (!exists(file)) return []
1127
+ const raw = readJson(file, [])
1128
+ if (Array.isArray(raw)) return raw.map(String)
1129
+ return Array.isArray(raw?.locked) ? raw.locked.map(String) : []
1130
+ }
1131
+
1132
+ /** @param {string} root @param {Record<string, unknown>} overlay */
1133
+ saveOverlay(root, overlay) {
1134
+ const overlayRoot = path.join(root, 'overlay')
1135
+ ensureDir(overlayRoot)
1136
+ fs.writeFileSync(path.join(overlayRoot, 'cordis.patch.yml'), serializePatchEntries(overlay.patchEntries), 'utf8')
1137
+ writeJsonAtomic(path.join(overlayRoot, 'package.json'), overlay.packageJson)
1138
+ const settingsDir = path.join(overlayRoot, 'settings')
1139
+ ensureDir(settingsDir)
1140
+ for (const [name, value] of Object.entries(overlay.settings)) {
1141
+ fs.writeFileSync(path.join(settingsDir, `${name}.yaml`), `${JSON.stringify(value, null, 2)}\n`, 'utf8')
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * 物化 merged/profile:镜像骨架 + 合并后的 patch/package/settings + 原身凭据(镜像凭据剔除)。
1147
+ * v3.1.8:保留已有 merged/profile/node_modules(元主模块树可能是 GB 级、且可能已包含运行态
1148
+ * 捕获回来的插件变更,重建成本高);overlay 里的 pnpm 锁/设置文件一并投影到 merged,供
1149
+ * 运行态复用时保持可复现的依赖图。
1150
+ */
1151
+ materializeMerged(root, mirror, overlay, result) {
1152
+ const mergedRoot = path.join(root, 'merged')
1153
+ const mergedProfile = path.join(mergedRoot, 'profile')
1154
+ const preservedModules = path.join(root, '.modules-preserve')
1155
+ let hadModules = false
1156
+ if (exists(preservedModules)) removeTree(preservedModules)
1157
+ if (exists(path.join(mergedProfile, 'node_modules'))) {
1158
+ try {
1159
+ fs.renameSync(path.join(mergedProfile, 'node_modules'), preservedModules)
1160
+ hadModules = true
1161
+ } catch { hadModules = false }
1162
+ }
1163
+ if (exists(mergedRoot)) removeTree(mergedRoot)
1164
+ ensureDir(mergedProfile)
1165
+ copyTree(mirror.root, mergedProfile)
1166
+ if (hadModules) {
1167
+ const freshModules = path.join(mergedProfile, 'node_modules')
1168
+ if (exists(freshModules)) removeTree(freshModules)
1169
+ fs.renameSync(preservedModules, freshModules)
1170
+ }
1171
+ fs.writeFileSync(path.join(mergedProfile, 'cordis.patch.yml'), serializePatchEntries(result.mergedPatchEntries), 'utf8')
1172
+ writeJsonAtomic(path.join(mergedProfile, 'package.json'), result.mergedPackageJson)
1173
+ const settingsDir = path.join(mergedProfile, 'settings')
1174
+ ensureDir(settingsDir)
1175
+ for (const [name, value] of Object.entries(result.mergedSettings)) {
1176
+ fs.writeFileSync(path.join(settingsDir, `${name}.yaml`), `${JSON.stringify(value, null, 2)}\n`, 'utf8')
1177
+ }
1178
+ // v3.1.8:overlay 里保管的 pnpm 依赖图文件(运行态插件变更捕获)投影到 merged profile。
1179
+ for (const name of ['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc']) {
1180
+ const source = path.join(root, 'overlay', name)
1181
+ if (!exists(source)) continue
1182
+ try { fs.copyFileSync(source, path.join(mergedProfile, name)) } catch { /* 单个文件失败不阻塞物化 */ }
1183
+ }
1184
+ // v3.0.5/B8:凭据永不进入 merged/profile —— 派生 profile 不携带任何 credentials
1185
+ const mergedCredentials = path.join(mergedProfile, 'credentials')
1186
+ if (exists(mergedCredentials)) removeTree(mergedCredentials)
1187
+ writeJsonAtomic(path.join(mergedRoot, 'fingerprint.json'), {
1188
+ mirrorVersion: mirror.version,
1189
+ mirrorFingerprint: mirror.fingerprint,
1190
+ mergedAt: this.now(),
1191
+ conflicts: result.conflicts.length,
1192
+ })
1193
+ }
1194
+ }
1195
+
1196
+ /** Promise 超时包装(v3.0.9:recomputeAll 单原身超时)。 */
1197
+ function withTimeout(promise, timeoutMs, message) {
1198
+ return new Promise((resolve, reject) => {
1199
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
1200
+ timer.unref?.()
1201
+ promise.then(
1202
+ value => { clearTimeout(timer); resolve(value) },
1203
+ error => { clearTimeout(timer); reject(error) },
1204
+ )
1205
+ })
1206
+ }
1207
+
1208
+ export const _internals = Object.freeze({
1209
+ mergePatchEntries, mergePackageJson, deepMerge, hasConfigDiff, areVersionsCompatible,
1210
+ extractPatchEntries, normalizeTier, withTimeout,
1211
+ })