dsh-paoding 0.4.5 → 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.
- package/README.md +5 -4
- package/README_en.md +5 -4
- package/bin/dsh-paoding.mjs +22 -10
- package/docs/configuration.md +23 -0
- package/docs/configuration_en.md +23 -0
- package/docs/installation.md +23 -9
- package/docs/installation_en.md +20 -8
- package/package.json +1 -1
- package/plugins/paoding-config-ui/api-core.mjs +229 -8
- package/plugins/paoding-config-ui/index.mjs +81 -4
- package/plugins/paoding-config-ui/lib/base.css +253 -0
- package/plugins/paoding-config-ui/lib/client.js +510 -43
- package/plugins/paoding-config-ui/package.json +1 -1
- package/plugins/paoding-config-ui/stats-core.mjs +421 -0
- package/tools/lib/cli.mjs +298 -8
- package/tools/lib/config.mjs +140 -1
- package/tools/lib/doctor.mjs +82 -32
- package/tools/lib/lifecycle.mjs +333 -0
- package/tools/lib/preset-system.mjs +254 -38
- package/tools/lib/state.mjs +24 -9
package/tools/lib/doctor.mjs
CHANGED
|
@@ -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 / 无配置的干净机器上各项依此跳过)。
|
|
@@ -12,7 +13,8 @@
|
|
|
12
13
|
* healthCheck 的 preset_drift 判定(同一份 dry-run 对比,不另立标准)。
|
|
13
14
|
*
|
|
14
15
|
* 依赖 config(loadConfig)/ yaml(loadYaml)/ alloc(autoAssignments)/
|
|
15
|
-
* state(collectState)/
|
|
16
|
+
* state(collectState)/ lifecycle(workspacePresetOrphans,孤儿目录判据与
|
|
17
|
+
* 清理侧同源)/
|
|
16
18
|
* preset-system(轨道、托管块、标记)与 health(healthCheck);被 cli 引用。
|
|
17
19
|
* 零运行时依赖(node:fs / node:path / node:url)。
|
|
18
20
|
*/
|
|
@@ -22,19 +24,21 @@ import { loadConfig } from './config.mjs'
|
|
|
22
24
|
import { loadYaml } from './yaml.mjs'
|
|
23
25
|
import { autoAssignments } from './alloc.mjs'
|
|
24
26
|
import { collectState } from './state.mjs'
|
|
25
|
-
import {
|
|
27
|
+
import { workspacePresetOrphans } from './lifecycle.mjs'
|
|
26
28
|
import {
|
|
27
29
|
MANAGED_BEGIN,
|
|
28
30
|
MANAGED_END,
|
|
29
31
|
detectAgentRolesFlavor,
|
|
30
32
|
detectHostVersionInfo,
|
|
31
33
|
detectPresetSystem,
|
|
34
|
+
findSuspectPresetRows,
|
|
32
35
|
isDeclarativeTriple,
|
|
33
36
|
isPtcWorkerHost,
|
|
34
|
-
listOrchestratorPresetIds,
|
|
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
|
|
|
@@ -59,16 +63,6 @@ function managedMarkerLines(text) {
|
|
|
59
63
|
)
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** 孤儿工作区预设目录对账:配置 workspaces 键组统一过 assignSlugs(与生成层
|
|
63
|
-
* 同源,slug 稳定),得到「配置在册」的 presetId 集合;目录有、名册无 → 孤儿。 */
|
|
64
|
-
function workspacePresetOrphans(dshHome, config) {
|
|
65
|
-
const dirs = listOrchestratorPresetIds(dshHome).filter((id) => id !== 'orchestrator')
|
|
66
|
-
if (dirs.length === 0) return { dirs, orphans: [] }
|
|
67
|
-
const wsPaths = Object.keys(config?.workspaces ?? {})
|
|
68
|
-
const inRegister = new Set([...assignSlugs(wsPaths).values()].map(presetIdOf))
|
|
69
|
-
return { dirs, orphans: dirs.filter((id) => !inRegister.has(id)) }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
66
|
/**
|
|
73
67
|
* 跑全套 doctor 检查。参数(全可缺省,干净机器上逐项降级):
|
|
74
68
|
* - dshHome / configFile:DSH 家目录与配置文件路径。
|
|
@@ -127,43 +121,99 @@ export async function runDoctor({ dshHome, configFile, profile = null, state = n
|
|
|
127
121
|
}
|
|
128
122
|
}
|
|
129
123
|
|
|
130
|
-
// ── 3.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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})`
|
|
141
155
|
const markers = managedMarkerLines(patchText)
|
|
142
156
|
const hasBegin = markers.has(MANAGED_BEGIN)
|
|
143
157
|
const hasEnd = markers.has(MANAGED_END)
|
|
158
|
+
if (hasBegin) blockLayerSeen = blockLayerSeen ?? entry.layer
|
|
144
159
|
if (hasBegin && !hasEnd) {
|
|
145
|
-
add('error', '声明行托管块',
|
|
146
|
-
}
|
|
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) {
|
|
147
189
|
let rows = []
|
|
148
190
|
try {
|
|
149
|
-
rows = readHomePatchRows(dshHome)
|
|
191
|
+
rows = readHomePatchRows(dshHome, { layer: entry.layer, profileName: entry.profileName ?? 'web' })
|
|
150
192
|
} catch {
|
|
151
193
|
rows = []
|
|
152
194
|
}
|
|
153
195
|
if (rows.length === 0) {
|
|
154
|
-
add('ok', '声明行托管块', hasBegin ? '
|
|
196
|
+
add('ok', '声明行托管块', hasBegin ? `${layerName}托管块存在但无声明行` : `${entry.layer === 'home' ? '' : `${entry.patchFile} `}无托管块(目录轨宿主无需声明行)`)
|
|
155
197
|
} else {
|
|
156
198
|
const orphans = rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
|
|
157
199
|
if (track === 'directory') {
|
|
158
|
-
add('warn', '声明行托管块',
|
|
200
|
+
add('warn', '声明行托管块', `${layerName}检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
|
|
159
201
|
} else if (orphans.length > 0) {
|
|
160
|
-
add('warn', '声明行托管块',
|
|
202
|
+
add('warn', '声明行托管块', `${layerName}孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
|
|
161
203
|
} else {
|
|
162
|
-
add('ok', '声明行托管块', `${rows.length} 条声明行均指向存在的预设目录`)
|
|
204
|
+
add('ok', '声明行托管块', `${layerName}${rows.length} 条声明行均指向存在的预设目录`)
|
|
163
205
|
}
|
|
164
206
|
}
|
|
165
207
|
}
|
|
166
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
|
+
}
|
|
167
217
|
|
|
168
218
|
// ── 4. 孤儿工作区预设目录(.agent-presets/orchestrator-*)─────────────────
|
|
169
219
|
if (!configUsable && existsSync(configFile)) {
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生命周期清理共享层 —— `dsh-paoding uninstall` 与 `doctor --fix` 共用的落盘
|
|
3
|
+
* 清理原语。两者清理的东西同源(cordis.patch.yml 托管块、.agent-presets 下的
|
|
4
|
+
* orchestrator* 预设目录、.generator-version 标记),判别口径也与 doctor 只读
|
|
5
|
+
* 体检同源(孤儿目录 = workspacePresetOrphans,孤儿声明行 = presetId 指向的
|
|
6
|
+
* 目录已不存在),本模块把「怎么删」收敛在一处,两个命令只决定「删哪些」。
|
|
7
|
+
*
|
|
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
|
|
13
|
+
* --purge 才动,删除前列在计划里。
|
|
14
|
+
*
|
|
15
|
+
* 依赖 preset-system(托管块手术与轨道原语)/ workspaces(slug 名册)/
|
|
16
|
+
* config(loadConfig);被 doctor / cli 引用。绝不触碰插件包自身目录——包
|
|
17
|
+
* 本体的卸载通道只有 dsh plugin remove(bin 入口注释同款口径)。
|
|
18
|
+
*/
|
|
19
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { loadConfig } from './config.mjs'
|
|
22
|
+
import { assignSlugs, presetIdOf } from './workspaces.mjs'
|
|
23
|
+
import {
|
|
24
|
+
MANAGED_BEGIN,
|
|
25
|
+
MANAGED_END,
|
|
26
|
+
listOrchestratorPresetIds,
|
|
27
|
+
presetRowId,
|
|
28
|
+
readHomePatchRows,
|
|
29
|
+
stripHomePatchRows,
|
|
30
|
+
} from './preset-system.mjs'
|
|
31
|
+
|
|
32
|
+
/** cordis.patch.yml 写前备份的后缀(只保一份,重复清理覆盖上一份)。 */
|
|
33
|
+
export const PATCH_BACKUP_SUFFIX = '.bak-1'
|
|
34
|
+
|
|
35
|
+
/** 生成器标记文件名:位置固定在 .agent-presets/orchestrator/ 里(api-core
|
|
36
|
+
* ensurePresetInstalled 与 doctor 版本标记项同址),随基础预设目录同生同灭。 */
|
|
37
|
+
export const GENERATOR_MARKER_NAME = '.generator-version'
|
|
38
|
+
|
|
39
|
+
/** 生成器标记文件的完整路径(位置以 preset-system / api-core 的代码为准)。 */
|
|
40
|
+
export function generatorMarkerPath(dshHome) {
|
|
41
|
+
return path.join(dshHome, '.agent-presets', 'orchestrator', GENERATOR_MARKER_NAME)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 写前备份:把 file 原样拷成 `${file}${PATCH_BACKUP_SUFFIX}`(已存在则覆盖,
|
|
46
|
+
* 只保最近一份)。file 不存在返回 null(没得备份,也不造空备份);拷贝失败
|
|
47
|
+
* 上抛——备份失败宁可中止清理,不做无备份的破坏性写。
|
|
48
|
+
*/
|
|
49
|
+
export function backupBeforeWrite(file) {
|
|
50
|
+
if (!existsSync(file)) return null
|
|
51
|
+
const backupPath = `${file}${PATCH_BACKUP_SUFFIX}`
|
|
52
|
+
copyFileSync(file, backupPath)
|
|
53
|
+
return backupPath
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* cordis.patch.yml 里庖丁托管块的在场状态:{ present, corrupt }。present 按
|
|
58
|
+
* 起始标记行判断(与 doctor 声明行托管块项同一读法);有起始没结束 = 损坏,
|
|
59
|
+
* 此时任何托管写盘都会被 preset-system 拒绝,清理方只能降级成「需手工」。
|
|
60
|
+
* 文件缺失 / 读不了 → 全 false(无可清理)。
|
|
61
|
+
*/
|
|
62
|
+
export function managedBlockState(dshHome) {
|
|
63
|
+
let text = null
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(path.join(dshHome, 'cordis.patch.yml'), 'utf8')
|
|
66
|
+
} catch {
|
|
67
|
+
return { present: false, corrupt: false }
|
|
68
|
+
}
|
|
69
|
+
const marks = new Set(String(text).split(/\r?\n/).map((l) => l.trim()))
|
|
70
|
+
const present = marks.has(MANAGED_BEGIN)
|
|
71
|
+
return { present, corrupt: present && !marks.has(MANAGED_END) }
|
|
72
|
+
}
|
|
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
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 配置文件对账读法(doctor 配置文件项同一判据):缺失返回 { config: null,
|
|
108
|
+
* exists: false, usable: false };存在但解析失败 → { exists: true, usable:
|
|
109
|
+
* false }(此时宁可当孤儿全量处理也不敢对账的目录一律不动,见
|
|
110
|
+
* workspacePresetOrphans 的调用方约束);存在且可解析 → config 原样。
|
|
111
|
+
*/
|
|
112
|
+
export function loadConfigForReconcile(configFile, yamlMod) {
|
|
113
|
+
if (!existsSync(configFile)) return { config: null, exists: false, usable: false }
|
|
114
|
+
try {
|
|
115
|
+
return { config: loadConfig(configFile, yamlMod), exists: true, usable: true }
|
|
116
|
+
} catch {
|
|
117
|
+
return { config: null, exists: true, usable: false }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 孤儿工作区预设目录对账(doctor「工作区预设目录」项同源判据,从 doctor 移植
|
|
123
|
+
* 到这里供清理侧复用):配置 workspaces 键组统一过 assignSlugs(与生成层同源,
|
|
124
|
+
* slug 稳定)得到「配置在册」的 presetId 集合;目录有、名册无 → 孤儿。基础
|
|
125
|
+
* orchestrator 永不算孤儿(启动自愈会重装它)。注意调用方约束:configFile
|
|
126
|
+
* 存在但不可解析时不要调用(doctor 该项直接降级 skip,清理侧同样必须跳过,
|
|
127
|
+
* 详见 loadConfigForReconcile)。
|
|
128
|
+
*/
|
|
129
|
+
export function workspacePresetOrphans(dshHome, config) {
|
|
130
|
+
const dirs = listOrchestratorPresetIds(dshHome).filter((id) => id !== 'orchestrator')
|
|
131
|
+
if (dirs.length === 0) return { dirs, orphans: [] }
|
|
132
|
+
const wsPaths = Object.keys(config?.workspaces ?? {})
|
|
133
|
+
const inRegister = new Set([...assignSlugs(wsPaths).values()].map(presetIdOf))
|
|
134
|
+
return { dirs, orphans: dirs.filter((id) => !inRegister.has(id)) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 孤儿声明行(doctor「声明行托管块」项同一判据):托管块里 presetId 指向的
|
|
139
|
+
* .agent-presets 目录已不存在的行。含本机历史遗留的旧 insert(如指向早已
|
|
140
|
+
* 不存在的 paoding-config-ui 的声明行)——判据只认「目录没了」,不认行名。
|
|
141
|
+
* presetId 解析不出的行不在此列(doctor 不点名它,清理侧也不动它,交给用户)。
|
|
142
|
+
* 托管块损坏时返回空数组(损坏块只能整块手工处理,行级手术做不了)。
|
|
143
|
+
* 双层扫描:home 与各 profile 层的孤儿行并集(rowId 去重——同一 preset 的行
|
|
144
|
+
* 理论上只在一层,两层都在场时清理侧会逐层各撤一遍)。
|
|
145
|
+
*/
|
|
146
|
+
export function orphanManagedRows(dshHome) {
|
|
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
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 清理执行器(uninstall 与 doctor --fix 共用,别在外面另拼删盘逻辑):
|
|
163
|
+
* - stripBlock: true → 整块摘除托管块(uninstall;含起止标记与块前空行分隔)
|
|
164
|
+
* - pruneRowIds: 撤指定 rowId 的声明行段(doctor --fix 的孤儿行清理)
|
|
165
|
+
* - removeDirIds: 删指定 orchestrator* 预设目录(rmSync 逐个,单目录失败不
|
|
166
|
+
* 拖垮其余);声明行轨上被删目录的声明行同步撤下(目录与行同生同灭,与
|
|
167
|
+
* generateAndInstall 的孤儿回收口径一致)
|
|
168
|
+
* - 删完目录后 .generator-version 若仍在(理论上随基础目录已走,防御性兜底)
|
|
169
|
+
* 一并删除并在结果里注明
|
|
170
|
+
* 备份纪律见模块头:对每个 patch 文件(home / 各 profile 层)的首次写前备份
|
|
171
|
+
* .bak-1,之后不再覆盖;backupPath 兼容旧字段(首个备份),backupPaths 列全。
|
|
172
|
+
* 托管块手术双层循环(managedBlockStateAll:compat 壳把块搬进 profile 层后,
|
|
173
|
+
* 只清 home 层的旧卸载会空转),损坏块不碰:preset-system 会拒绝写盘,降级
|
|
174
|
+
* 给调用方报需手工。返回结构化结果(字段见实现),全程不 throw——单步失败
|
|
175
|
+
* 折进对应字段,由调用方决定退出码与文案。
|
|
176
|
+
*/
|
|
177
|
+
export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowIds = [], removeDirIds = [] } = {}) {
|
|
178
|
+
const result = {
|
|
179
|
+
backupPath: null, // 旧字段:首个写前备份(向后兼容 cli 打印)
|
|
180
|
+
backupPaths: [], // 本次实际落下的全部备份(每层 patch 至多一份)
|
|
181
|
+
blockPresent: false,
|
|
182
|
+
blockCorrupt: false,
|
|
183
|
+
blockRemoved: false,
|
|
184
|
+
prunedRowIds: [],
|
|
185
|
+
markerRemoved: false,
|
|
186
|
+
removedDirs: [],
|
|
187
|
+
dirFailures: [],
|
|
188
|
+
}
|
|
189
|
+
|
|
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()
|
|
194
|
+
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
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)
|
|
210
|
+
if (stripBlock) {
|
|
211
|
+
backupOnce(entry.patchFile)
|
|
212
|
+
if (stripHomePatchRows(dshHome, null, opts)) result.blockRemoved = true
|
|
213
|
+
} else if (pruneRowIds.length > 0) {
|
|
214
|
+
const present = pruneRowIds.filter((id) => rowsOnDisk.includes(id))
|
|
215
|
+
if (present.length > 0) {
|
|
216
|
+
backupOnce(entry.patchFile)
|
|
217
|
+
// stripHomePatchRows 返回 true = 指定段确已撤下;块里本来就没有的 id
|
|
218
|
+
// 不算撤(present 已过滤),不虚报。同一 rowId 两层都在场时各撤一遍,
|
|
219
|
+
// 结果里只记一次(Set 去重)。
|
|
220
|
+
if (stripHomePatchRows(dshHome, present, opts)) for (const id of present) prunedSeen.add(id)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
result.prunedRowIds = [...prunedSeen]
|
|
225
|
+
|
|
226
|
+
// 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序;行逐层清)
|
|
227
|
+
if (removeDirIds.length > 0) {
|
|
228
|
+
for (const id of removeDirIds) {
|
|
229
|
+
const dir = path.join(dshHome, '.agent-presets', id)
|
|
230
|
+
try {
|
|
231
|
+
rmSync(dir, { recursive: true, force: true })
|
|
232
|
+
result.removedDirs.push(id)
|
|
233
|
+
} catch (err) {
|
|
234
|
+
result.dirFailures.push({ id, error: err?.message ?? String(err) })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const removedIds = new Set(result.removedDirs)
|
|
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
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 3) 版本标记兜底:正常随基础 orchestrator 目录一起删;防御目录删除整体失败
|
|
251
|
+
//(dirFailures)或外部摆了非常规布局时补一手,保证「卸载后无标记残留」。
|
|
252
|
+
const markerPath = generatorMarkerPath(dshHome)
|
|
253
|
+
if (existsSync(markerPath)) {
|
|
254
|
+
try {
|
|
255
|
+
rmSync(markerPath, { force: true })
|
|
256
|
+
result.markerRemoved = true
|
|
257
|
+
} catch { /* 删不掉就留着:dirFailures / 保留提示里已能解释现场 */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 卸载计划(只读侦察,不落盘):给确认提示、非 TTY 防呆打印与「无事可做」
|
|
265
|
+
* 判定共用。不加载配置文件内容(卸载不依赖配置语义),只看存在性。
|
|
266
|
+
* blockPresent / blockCorrupt / blockRowCount 聚合双层(compat 壳的 profile 层
|
|
267
|
+
* 托管块也算在场);patchFile 保留 home 层路径的旧字段口径,patchFiles 列出
|
|
268
|
+
* 各层实际在场托管块的文件(供调用方点名 profile 层残留)。
|
|
269
|
+
*/
|
|
270
|
+
export function planUninstall({ dshHome, configFile } = {}) {
|
|
271
|
+
const layers = managedBlockStateAll(dshHome)
|
|
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
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
dshHome,
|
|
279
|
+
configFile,
|
|
280
|
+
patchFile: path.join(dshHome, 'cordis.patch.yml'),
|
|
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,
|
|
285
|
+
presetDirs: listOrchestratorPresetIds(dshHome),
|
|
286
|
+
markerPath,
|
|
287
|
+
markerExists: existsSync(markerPath),
|
|
288
|
+
configExists: existsSync(configFile),
|
|
289
|
+
configBackups: listConfigBackupFiles(configFile),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 配置文件的备份兄弟文件(`dsh-paoding.config.yml.bak*`,用户或历史工具手工
|
|
295
|
+
* 留的):--purge 时连这些一起删;默认保留时提示里点名数量。
|
|
296
|
+
*/
|
|
297
|
+
export function listConfigBackupFiles(configFile) {
|
|
298
|
+
const dir = path.dirname(configFile)
|
|
299
|
+
const base = path.basename(configFile)
|
|
300
|
+
let names = []
|
|
301
|
+
try {
|
|
302
|
+
names = readdirSync(dir)
|
|
303
|
+
} catch {
|
|
304
|
+
return []
|
|
305
|
+
}
|
|
306
|
+
return names
|
|
307
|
+
.filter((name) => name.startsWith(`${base}.bak`))
|
|
308
|
+
.sort()
|
|
309
|
+
.map((name) => path.join(dir, name))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** --purge 的配置删除:配置文件 + 全部 .bak* 备份逐个删,返回实际删掉的路径。 */
|
|
313
|
+
export function removeConfigAndBackups(configFile) {
|
|
314
|
+
const removed = []
|
|
315
|
+
for (const file of [configFile, ...listConfigBackupFiles(configFile)]) {
|
|
316
|
+
try {
|
|
317
|
+
rmSync(file, { force: true })
|
|
318
|
+
removed.push(file)
|
|
319
|
+
} catch { /* 删不掉就留着,由调用方在收尾提示里交代 */ }
|
|
320
|
+
}
|
|
321
|
+
return removed
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** .agent-presets 下与庖丁无关的残留目录名(如用户自制 preset),卸载后提示「未动」。 */
|
|
325
|
+
export function foreignPresetDirsRemaining(dshHome) {
|
|
326
|
+
let names = []
|
|
327
|
+
try {
|
|
328
|
+
names = readdirSync(path.join(dshHome, '.agent-presets'))
|
|
329
|
+
} catch {
|
|
330
|
+
return []
|
|
331
|
+
}
|
|
332
|
+
return names.filter((name) => !/^orchestrator(-[a-z0-9-]+)?$/.test(name)).sort()
|
|
333
|
+
}
|