dsh-paoding 0.4.6 → 0.4.7

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.
@@ -2,7 +2,8 @@
2
2
  * `dsh-paoding doctor` 的检查本体:只读体检,不动任何盘。与 health.mjs 的分工
3
3
  * ——healthCheck 管「这次保存/应用会怎样」(面向 preview / apply 响应与
4
4
  * --dry-run 报告的单次配置体检);本模块管「这台机器装得正不正」(配置文件、
5
- * 预设安装轨道、托管声明行、孤儿工作区预设目录、版本标记、预设漂移六项环境
5
+ * 预设安装轨道、托管声明行(home + profile 层双层:托管块损坏 / 整文件解析 /
6
+ * 可疑坏行 / 行对账 / 层漂移)、孤儿工作区预设目录、版本标记、预设漂移六项环境
6
7
  * 对账),结果形状是人读导向的 { items: [{ status, label, detail }] }:
7
8
  * status: 'ok' | 'warn' | 'error' | 'skip'(skip = 前置事实缺失,降级不炸——
8
9
  * 无 DSH_HOME / 无配置的干净机器上各项依此跳过)。
@@ -30,11 +31,14 @@ import {
30
31
  detectAgentRolesFlavor,
31
32
  detectHostVersionInfo,
32
33
  detectPresetSystem,
34
+ findSuspectPresetRows,
33
35
  isDeclarativeTriple,
34
36
  isPtcWorkerHost,
35
37
  parseGeneratorMarker,
36
38
  presetRolesFlavorOf,
37
39
  readHomePatchRows,
40
+ resolvePatchTargetLayer,
41
+ validatePatchYaml,
38
42
  } from './preset-system.mjs'
39
43
  import { healthCheck } from './health.mjs'
40
44
 
@@ -117,43 +121,99 @@ export async function runDoctor({ dshHome, configFile, profile = null, state = n
117
121
  }
118
122
  }
119
123
 
120
- // ── 3. 声明行托管块(~/.dsh/cordis.patch.yml)────────────────────────────
121
- // readHomePatchRows 对损坏块自捕返 [],读不出「损坏」——这里直接扫标记行补一道:
122
- // 有 BEGIN 没 END = 托管块损坏,所有托管写盘都会被拒绝。
123
- let patchText = null
124
- try {
125
- patchText = readFileSync(path.join(dshHome, 'cordis.patch.yml'), 'utf8')
126
- } catch (err) {
127
- if (err?.code === 'ENOENT') add('skip', '声明行托管块', 'cordis.patch.yml 不存在(尚无声明行;目录轨宿主也不需要)')
128
- else add('skip', '声明行托管块', `cordis.patch.yml 读取失败(${err?.message ?? err}),跳过该项`)
129
- }
130
- if (patchText !== null) {
124
+ // ── 3. 声明行托管块(home + 目标 profile 层 patch 双层对账)───────────────
125
+ // 层判定与写盘侧同一条链(resolvePatchTargetLayer:config 可用时按
126
+ // patch_layer / profile 键,否则按环境自动——compat 壳 → profile 层)。
127
+ // 每层四查:托管块损坏(error,既有口径)→ 整文件 YAML 解析(error:compat
128
+ // 壳压平的坏行会让宿主在任何插件代码运行前死掉,dsh-maoying#1)→ 可疑坏行
129
+ // (warn,只点名行号不删)→ 行对账(目录轨残留 / 孤儿行,既有口径)。最后
130
+ // 层漂移:托管块只落在非目标层 → warn(下次自愈 / 保存会写目标层并撤遗留)。
131
+ const layerTarget = resolvePatchTargetLayer(dshHome, { config })
132
+ const layerEntries = [
133
+ { layer: 'home', profileName: null, patchFile: path.join(dshHome, 'cordis.patch.yml') },
134
+ { layer: 'profile', profileName: layerTarget.profileName, patchFile: layerTarget.patchPath },
135
+ ]
136
+ const seenPatchFiles = new Set()
137
+ let blockLayerSeen = null
138
+ for (const entry of layerEntries) {
139
+ if (seenPatchFiles.has(entry.patchFile)) continue // 目标 profile 恰为 'web' 时与 home 同文件,不双报
140
+ seenPatchFiles.add(entry.patchFile)
141
+ let patchText = null
142
+ try {
143
+ patchText = readFileSync(entry.patchFile, 'utf8')
144
+ } catch (err) {
145
+ // home 层缺失维持既有 skip 口径(干净机器各项依此跳过);profile 层缺失
146
+ // 是常态(非 compat 壳 / 从未迁移过),静默。
147
+ if (entry.layer === 'home' && err?.code === 'ENOENT') {
148
+ add('skip', '声明行托管块', 'cordis.patch.yml 不存在(尚无声明行;目录轨宿主也不需要)')
149
+ } else if (err?.code !== 'ENOENT') {
150
+ add('skip', '声明行托管块', `${entry.patchFile} 读取失败(${err?.message ?? err}),跳过该层`)
151
+ }
152
+ continue
153
+ }
154
+ const layerName = entry.layer === 'home' ? '' : `(profile 层 ${entry.patchFile})`
131
155
  const markers = managedMarkerLines(patchText)
132
156
  const hasBegin = markers.has(MANAGED_BEGIN)
133
157
  const hasEnd = markers.has(MANAGED_END)
158
+ if (hasBegin) blockLayerSeen = blockLayerSeen ?? entry.layer
134
159
  if (hasBegin && !hasEnd) {
135
- add('error', '声明行托管块', '托管块损坏(只有起始标记没有结束标记):补回结束标记或删除整块,否则面板保存 / 自愈都会拒绝写盘')
136
- } else {
160
+ add('error', '声明行托管块', `${layerName}托管块损坏(只有起始标记没有结束标记):补回结束标记或删除整块,否则面板保存 / 自愈都会拒绝写盘`)
161
+ }
162
+ // 整文件 YAML 解析(宿主 yaml 包口径,与写盘校验 validatePatchYaml 同源;
163
+ // yaml 包缺失退内置子集解析器——lenient,只兜 gross 损坏挡不住坏缩进)。
164
+ const parseVerdict = validatePatchYaml(dshHome, patchText)
165
+ if (!parseVerdict.ok) {
166
+ add(
167
+ 'error',
168
+ 'patch 文件解析',
169
+ `${entry.patchFile} 无法解析:${parseVerdict.message}——宿主启动会直接失败。请删除坏行或从 .bak-1 备份恢复` +
170
+ '(Android compat 壳重写 patch 压平坏行的特征参见 dsh-maoying#1)',
171
+ )
172
+ }
173
+ // 可疑坏行(maoying 坏行形状):warn 点名行号,只诊断不删——删行可能踩
174
+ // 用户手写内容,修复交给人或清理侧的行级手术。
175
+ const suspects = findSuspectPresetRows(patchText)
176
+ if (suspects.length > 0) {
177
+ const lineNos = suspects.map((s) => s.lineNo).join('、')
178
+ add(
179
+ 'warn',
180
+ '声明行可疑行',
181
+ `${entry.patchFile} 第 ${lineNos} 行检出可疑声明行(如 "- id: ${suspects[0].id}"——id 含空白或非 ASCII,` +
182
+ '形如 compat 壳把显示名压平进 id 位产生的坏行,宿主 YAML 解析会失败,参见 dsh-maoying#1)' +
183
+ '——请删除坏行或从 .bak-1 备份恢复后重试',
184
+ )
185
+ }
186
+ // 行对账(损坏块时 readHomePatchRows 自捕返空数组——损坏已单独报,这里
187
+ // 只在块结构完好时做逐行对账,避免损坏块上报出误导性的「无声明行」)。
188
+ if (!hasBegin || hasEnd) {
137
189
  let rows = []
138
190
  try {
139
- rows = readHomePatchRows(dshHome)
191
+ rows = readHomePatchRows(dshHome, { layer: entry.layer, profileName: entry.profileName ?? 'web' })
140
192
  } catch {
141
193
  rows = []
142
194
  }
143
195
  if (rows.length === 0) {
144
- add('ok', '声明行托管块', hasBegin ? '托管块存在但无声明行' : 'cordis.patch.yml 无托管块(目录轨宿主无需声明行)')
196
+ add('ok', '声明行托管块', hasBegin ? `${layerName}托管块存在但无声明行` : `${entry.layer === 'home' ? '' : `${entry.patchFile} `}无托管块(目录轨宿主无需声明行)`)
145
197
  } else {
146
198
  const orphans = rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
147
199
  if (track === 'directory') {
148
- add('warn', '声明行托管块', `检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
200
+ add('warn', '声明行托管块', `${layerName}检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
149
201
  } else if (orphans.length > 0) {
150
- add('warn', '声明行托管块', `孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
202
+ add('warn', '声明行托管块', `${layerName}孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
151
203
  } else {
152
- add('ok', '声明行托管块', `${rows.length} 条声明行均指向存在的预设目录`)
204
+ add('ok', '声明行托管块', `${layerName}${rows.length} 条声明行均指向存在的预设目录`)
153
205
  }
154
206
  }
155
207
  }
156
208
  }
209
+ if (blockLayerSeen !== null && blockLayerSeen !== layerTarget.layer) {
210
+ add(
211
+ 'warn',
212
+ 'patch 层漂移',
213
+ `声明行托管块在 ${blockLayerSeen} 层,当前判定目标层是 ${layerTarget.layer}(${layerTarget.patchPath})` +
214
+ '——下次自愈 / 保存会写目标层并撤下这层的遗留块(compat 壳迁移行为,参见 dsh-maoying#1)',
215
+ )
216
+ }
157
217
 
158
218
  // ── 4. 孤儿工作区预设目录(.agent-presets/orchestrator-*)─────────────────
159
219
  if (!configUsable && existsSync(configFile)) {
@@ -5,9 +5,11 @@
5
5
  * 体检同源(孤儿目录 = workspacePresetOrphans,孤儿声明行 = presetId 指向的
6
6
  * 目录已不存在),本模块把「怎么删」收敛在一处,两个命令只决定「删哪些」。
7
7
  *
8
- * 备份纪律:一次清理调用对 cordis.patch.yml 至多备份一份 .bak-1(首次写盘前
9
- * 拷贝,已有同名备份覆盖——只保「本次改动前」的原貌),同一次调用里的后续写
10
- * 不再覆盖这份首备。配置文件 dsh-paoding.config.yml 是用户数据,只有 uninstall
8
+ * 备份纪律:一次清理调用对每个 patch 文件(home 层 cordis.patch.yml 与各
9
+ * profile 层 profiles/<name>/cordis.patch.yml——compat 壳把托管块搬进 profile
10
+ * 层,见 managedBlockStateAll)至多备份一份 .bak-1(首次写盘前拷贝,已有同名
11
+ * 备份覆盖——只保「本次改动前」的原貌),同一次调用里对同一文件的后续写不再
12
+ * 覆盖这份首备。配置文件 dsh-paoding.config.yml 是用户数据,只有 uninstall
11
13
  * --purge 才动,删除前列在计划里。
12
14
  *
13
15
  * 依赖 preset-system(托管块手术与轨道原语)/ workspaces(slug 名册)/
@@ -69,6 +71,38 @@ export function managedBlockState(dshHome) {
69
71
  return { present, corrupt: present && !marks.has(MANAGED_END) }
70
72
  }
71
73
 
74
+ /**
75
+ * 全层托管块在场状态(managedBlockState 的双层版;managedBlockState 本身保持
76
+ * 只报 home 层的旧口径不动):home 层恒在列表首位(文件缺失 present=false),
77
+ * 其后是 profiles/<name>/cordis.patch.yml 里真实存在的每一层。每项
78
+ * { layer: 'home'|'profile', profileName: null|<name>, patchFile, present,
79
+ * corrupt }。compat 壳把托管块搬进 profile 层后,uninstall / doctor --fix
80
+ * 必须两层都过一遍,否则卸载会在 home 层空转、profile 层残留整块声明行。
81
+ */
82
+ export function managedBlockStateAll(dshHome) {
83
+ const entries = [{ layer: 'home', profileName: null, patchFile: path.join(dshHome, 'cordis.patch.yml') }]
84
+ try {
85
+ for (const name of readdirSync(path.join(dshHome, 'profiles')).sort()) {
86
+ const patchFile = path.join(dshHome, 'profiles', name, 'cordis.patch.yml')
87
+ if (existsSync(patchFile)) entries.push({ layer: 'profile', profileName: name, patchFile })
88
+ }
89
+ } catch { /* 无 profiles 目录:只有 home 层 */ }
90
+ return entries.map((entry) => {
91
+ let text = null
92
+ try {
93
+ text = readFileSync(entry.patchFile, 'utf8')
94
+ } catch {
95
+ return { ...entry, present: false, corrupt: false }
96
+ }
97
+ const marks = new Set(String(text).split(/\r?\n/).map((l) => l.trim()))
98
+ const present = marks.has(MANAGED_BEGIN)
99
+ return { ...entry, present, corrupt: present && !marks.has(MANAGED_END) }
100
+ })
101
+ }
102
+
103
+ /** managedBlockStateAll 条目 → preset-system 手术 opts(home 层恒 home 口径)。 */
104
+ const layerOptsOf = (entry) => ({ layer: entry.layer, profileName: entry.profileName ?? 'web' })
105
+
72
106
  /**
73
107
  * 配置文件对账读法(doctor 配置文件项同一判据):缺失返回 { config: null,
74
108
  * exists: false, usable: false };存在但解析失败 → { exists: true, usable:
@@ -106,10 +140,22 @@ export function workspacePresetOrphans(dshHome, config) {
106
140
  * 不存在的 paoding-config-ui 的声明行)——判据只认「目录没了」,不认行名。
107
141
  * presetId 解析不出的行不在此列(doctor 不点名它,清理侧也不动它,交给用户)。
108
142
  * 托管块损坏时返回空数组(损坏块只能整块手工处理,行级手术做不了)。
143
+ * 双层扫描:home 与各 profile 层的孤儿行并集(rowId 去重——同一 preset 的行
144
+ * 理论上只在一层,两层都在场时清理侧会逐层各撤一遍)。
109
145
  */
110
146
  export function orphanManagedRows(dshHome) {
111
- const rows = readHomePatchRows(dshHome)
112
- return rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
147
+ const seen = new Set()
148
+ const out = []
149
+ for (const entry of managedBlockStateAll(dshHome)) {
150
+ if (!entry.present || entry.corrupt) continue
151
+ for (const row of readHomePatchRows(dshHome, layerOptsOf(entry))) {
152
+ const key = `${entry.layer}:${row.rowId}`
153
+ if (seen.has(key)) continue
154
+ seen.add(key)
155
+ if (row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId))) out.push(row)
156
+ }
157
+ }
158
+ return out
113
159
  }
114
160
 
115
161
  /**
@@ -121,13 +167,17 @@ export function orphanManagedRows(dshHome) {
121
167
  * generateAndInstall 的孤儿回收口径一致)
122
168
  * - 删完目录后 .generator-version 若仍在(理论上随基础目录已走,防御性兜底)
123
169
  * 一并删除并在结果里注明
124
- * 备份纪律见模块头:对 cordis.patch.yml 的首次写前备份 .bak-1,之后不再覆盖。
125
- * 返回结构化结果(字段见实现),全程不 throw——单步失败折进对应字段,由调用
126
- * 方决定退出码与文案。
170
+ * 备份纪律见模块头:对每个 patch 文件(home / 各 profile 层)的首次写前备份
171
+ * .bak-1,之后不再覆盖;backupPath 兼容旧字段(首个备份),backupPaths 列全。
172
+ * 托管块手术双层循环(managedBlockStateAll:compat 壳把块搬进 profile 层后,
173
+ * 只清 home 层的旧卸载会空转),损坏块不碰:preset-system 会拒绝写盘,降级
174
+ * 给调用方报需手工。返回结构化结果(字段见实现),全程不 throw——单步失败
175
+ * 折进对应字段,由调用方决定退出码与文案。
127
176
  */
128
177
  export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowIds = [], removeDirIds = [] } = {}) {
129
178
  const result = {
130
- backupPath: null,
179
+ backupPath: null, // 旧字段:首个写前备份(向后兼容 cli 打印)
180
+ backupPaths: [], // 本次实际落下的全部备份(每层 patch 至多一份)
131
181
  blockPresent: false,
132
182
  blockCorrupt: false,
133
183
  blockRemoved: false,
@@ -137,35 +187,43 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
137
187
  dirFailures: [],
138
188
  }
139
189
 
140
- const block = managedBlockState(dshHome)
141
- result.blockPresent = block.present
142
- result.blockCorrupt = block.corrupt
190
+ const layers = managedBlockStateAll(dshHome)
191
+ result.blockPresent = layers.some((l) => l.present)
192
+ result.blockCorrupt = layers.filter((l) => l.present).some((l) => l.corrupt)
193
+ const prunedSeen = new Set()
143
194
 
144
- let backupDone = false
145
- const backupOnce = () => {
146
- if (backupDone) return
147
- backupDone = true
148
- result.backupPath = backupBeforeWrite(path.join(dshHome, 'cordis.patch.yml'))
195
+ const backupDone = new Set()
196
+ const backupOnce = (patchFile) => {
197
+ if (backupDone.has(patchFile)) return
198
+ backupDone.add(patchFile)
199
+ const backupPath = backupBeforeWrite(patchFile)
200
+ if (backupPath !== null) {
201
+ result.backupPaths.push(backupPath)
202
+ if (result.backupPath === null) result.backupPath = backupPath
203
+ }
149
204
  }
150
205
 
151
- // 1) 托管块手术(损坏块不碰:preset-system 会拒绝写盘,降级给调用方报需手工)
152
- if (block.present && !block.corrupt) {
153
- const rowsOnDisk = readHomePatchRows(dshHome).map((r) => r.rowId)
206
+ // 1) 托管块手术(逐层;损坏块不碰:preset-system 会拒绝写盘,降级给调用方报需手工)
207
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
208
+ const opts = layerOptsOf(entry)
209
+ const rowsOnDisk = readHomePatchRows(dshHome, opts).map((r) => r.rowId)
154
210
  if (stripBlock) {
155
- backupOnce()
156
- result.blockRemoved = stripHomePatchRows(dshHome, null)
211
+ backupOnce(entry.patchFile)
212
+ if (stripHomePatchRows(dshHome, null, opts)) result.blockRemoved = true
157
213
  } else if (pruneRowIds.length > 0) {
158
214
  const present = pruneRowIds.filter((id) => rowsOnDisk.includes(id))
159
215
  if (present.length > 0) {
160
- backupOnce()
216
+ backupOnce(entry.patchFile)
161
217
  // stripHomePatchRows 返回 true = 指定段确已撤下;块里本来就没有的 id
162
- // 不算撤(present 已过滤),不虚报。
163
- if (stripHomePatchRows(dshHome, present)) result.prunedRowIds = present
218
+ // 不算撤(present 已过滤),不虚报。同一 rowId 两层都在场时各撤一遍,
219
+ // 结果里只记一次(Set 去重)。
220
+ if (stripHomePatchRows(dshHome, present, opts)) for (const id of present) prunedSeen.add(id)
164
221
  }
165
222
  }
166
223
  }
224
+ result.prunedRowIds = [...prunedSeen]
167
225
 
168
- // 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序)
226
+ // 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序;行逐层清)
169
227
  if (removeDirIds.length > 0) {
170
228
  for (const id of removeDirIds) {
171
229
  const dir = path.join(dshHome, '.agent-presets', id)
@@ -177,12 +235,15 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
177
235
  }
178
236
  }
179
237
  const removedIds = new Set(result.removedDirs)
180
- const danglingRowIds = readHomePatchRows(dshHome)
181
- .filter((r) => r.presetId && removedIds.has(r.presetId))
182
- .map((r) => r.rowId)
183
- if (danglingRowIds.length > 0 && !result.blockCorrupt) {
184
- backupOnce()
185
- stripHomePatchRows(dshHome, danglingRowIds)
238
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
239
+ const opts = layerOptsOf(entry)
240
+ const danglingRowIds = readHomePatchRows(dshHome, opts)
241
+ .filter((r) => r.presetId && removedIds.has(r.presetId))
242
+ .map((r) => r.rowId)
243
+ if (danglingRowIds.length > 0) {
244
+ backupOnce(entry.patchFile)
245
+ stripHomePatchRows(dshHome, danglingRowIds, opts)
246
+ }
186
247
  }
187
248
  }
188
249
 
@@ -202,17 +263,25 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
202
263
  /**
203
264
  * 卸载计划(只读侦察,不落盘):给确认提示、非 TTY 防呆打印与「无事可做」
204
265
  * 判定共用。不加载配置文件内容(卸载不依赖配置语义),只看存在性。
266
+ * blockPresent / blockCorrupt / blockRowCount 聚合双层(compat 壳的 profile 层
267
+ * 托管块也算在场);patchFile 保留 home 层路径的旧字段口径,patchFiles 列出
268
+ * 各层实际在场托管块的文件(供调用方点名 profile 层残留)。
205
269
  */
206
270
  export function planUninstall({ dshHome, configFile } = {}) {
207
- const block = managedBlockState(dshHome)
271
+ const layers = managedBlockStateAll(dshHome)
208
272
  const markerPath = generatorMarkerPath(dshHome)
273
+ let blockRowCount = 0
274
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
275
+ blockRowCount += readHomePatchRows(dshHome, layerOptsOf(entry)).length
276
+ }
209
277
  return {
210
278
  dshHome,
211
279
  configFile,
212
280
  patchFile: path.join(dshHome, 'cordis.patch.yml'),
213
- blockPresent: block.present,
214
- blockCorrupt: block.corrupt,
215
- blockRowCount: readHomePatchRows(dshHome).length,
281
+ patchFiles: layers.filter((l) => l.present).map((l) => l.patchFile),
282
+ blockPresent: layers.some((l) => l.present),
283
+ blockCorrupt: layers.filter((l) => l.present).some((l) => l.corrupt),
284
+ blockRowCount,
216
285
  presetDirs: listOrchestratorPresetIds(dshHome),
217
286
  markerPath,
218
287
  markerExists: existsSync(markerPath),