dsh-paoding 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 生命周期清理共享层 —— `dsh-paoding uninstall` 与 `doctor --fix` 共用的落盘
3
+ * 清理原语。两者清理的东西同源(cordis.patch.yml 托管块、.agent-presets 下的
4
+ * orchestrator* 预设目录、.generator-version 标记),判别口径也与 doctor 只读
5
+ * 体检同源(孤儿目录 = workspacePresetOrphans,孤儿声明行 = presetId 指向的
6
+ * 目录已不存在),本模块把「怎么删」收敛在一处,两个命令只决定「删哪些」。
7
+ *
8
+ * 备份纪律:一次清理调用对 cordis.patch.yml 至多备份一份 .bak-1(首次写盘前
9
+ * 拷贝,已有同名备份覆盖——只保「本次改动前」的原貌),同一次调用里的后续写
10
+ * 不再覆盖这份首备。配置文件 dsh-paoding.config.yml 是用户数据,只有 uninstall
11
+ * --purge 才动,删除前列在计划里。
12
+ *
13
+ * 依赖 preset-system(托管块手术与轨道原语)/ workspaces(slug 名册)/
14
+ * config(loadConfig);被 doctor / cli 引用。绝不触碰插件包自身目录——包
15
+ * 本体的卸载通道只有 dsh plugin remove(bin 入口注释同款口径)。
16
+ */
17
+ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import { loadConfig } from './config.mjs'
20
+ import { assignSlugs, presetIdOf } from './workspaces.mjs'
21
+ import {
22
+ MANAGED_BEGIN,
23
+ MANAGED_END,
24
+ listOrchestratorPresetIds,
25
+ presetRowId,
26
+ readHomePatchRows,
27
+ stripHomePatchRows,
28
+ } from './preset-system.mjs'
29
+
30
+ /** cordis.patch.yml 写前备份的后缀(只保一份,重复清理覆盖上一份)。 */
31
+ export const PATCH_BACKUP_SUFFIX = '.bak-1'
32
+
33
+ /** 生成器标记文件名:位置固定在 .agent-presets/orchestrator/ 里(api-core
34
+ * ensurePresetInstalled 与 doctor 版本标记项同址),随基础预设目录同生同灭。 */
35
+ export const GENERATOR_MARKER_NAME = '.generator-version'
36
+
37
+ /** 生成器标记文件的完整路径(位置以 preset-system / api-core 的代码为准)。 */
38
+ export function generatorMarkerPath(dshHome) {
39
+ return path.join(dshHome, '.agent-presets', 'orchestrator', GENERATOR_MARKER_NAME)
40
+ }
41
+
42
+ /**
43
+ * 写前备份:把 file 原样拷成 `${file}${PATCH_BACKUP_SUFFIX}`(已存在则覆盖,
44
+ * 只保最近一份)。file 不存在返回 null(没得备份,也不造空备份);拷贝失败
45
+ * 上抛——备份失败宁可中止清理,不做无备份的破坏性写。
46
+ */
47
+ export function backupBeforeWrite(file) {
48
+ if (!existsSync(file)) return null
49
+ const backupPath = `${file}${PATCH_BACKUP_SUFFIX}`
50
+ copyFileSync(file, backupPath)
51
+ return backupPath
52
+ }
53
+
54
+ /**
55
+ * cordis.patch.yml 里庖丁托管块的在场状态:{ present, corrupt }。present 按
56
+ * 起始标记行判断(与 doctor 声明行托管块项同一读法);有起始没结束 = 损坏,
57
+ * 此时任何托管写盘都会被 preset-system 拒绝,清理方只能降级成「需手工」。
58
+ * 文件缺失 / 读不了 → 全 false(无可清理)。
59
+ */
60
+ export function managedBlockState(dshHome) {
61
+ let text = null
62
+ try {
63
+ text = readFileSync(path.join(dshHome, 'cordis.patch.yml'), 'utf8')
64
+ } catch {
65
+ return { present: false, corrupt: false }
66
+ }
67
+ const marks = new Set(String(text).split(/\r?\n/).map((l) => l.trim()))
68
+ const present = marks.has(MANAGED_BEGIN)
69
+ return { present, corrupt: present && !marks.has(MANAGED_END) }
70
+ }
71
+
72
+ /**
73
+ * 配置文件对账读法(doctor 配置文件项同一判据):缺失返回 { config: null,
74
+ * exists: false, usable: false };存在但解析失败 → { exists: true, usable:
75
+ * false }(此时宁可当孤儿全量处理也不敢对账的目录一律不动,见
76
+ * workspacePresetOrphans 的调用方约束);存在且可解析 → config 原样。
77
+ */
78
+ export function loadConfigForReconcile(configFile, yamlMod) {
79
+ if (!existsSync(configFile)) return { config: null, exists: false, usable: false }
80
+ try {
81
+ return { config: loadConfig(configFile, yamlMod), exists: true, usable: true }
82
+ } catch {
83
+ return { config: null, exists: true, usable: false }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 孤儿工作区预设目录对账(doctor「工作区预设目录」项同源判据,从 doctor 移植
89
+ * 到这里供清理侧复用):配置 workspaces 键组统一过 assignSlugs(与生成层同源,
90
+ * slug 稳定)得到「配置在册」的 presetId 集合;目录有、名册无 → 孤儿。基础
91
+ * orchestrator 永不算孤儿(启动自愈会重装它)。注意调用方约束:configFile
92
+ * 存在但不可解析时不要调用(doctor 该项直接降级 skip,清理侧同样必须跳过,
93
+ * 详见 loadConfigForReconcile)。
94
+ */
95
+ export function workspacePresetOrphans(dshHome, config) {
96
+ const dirs = listOrchestratorPresetIds(dshHome).filter((id) => id !== 'orchestrator')
97
+ if (dirs.length === 0) return { dirs, orphans: [] }
98
+ const wsPaths = Object.keys(config?.workspaces ?? {})
99
+ const inRegister = new Set([...assignSlugs(wsPaths).values()].map(presetIdOf))
100
+ return { dirs, orphans: dirs.filter((id) => !inRegister.has(id)) }
101
+ }
102
+
103
+ /**
104
+ * 孤儿声明行(doctor「声明行托管块」项同一判据):托管块里 presetId 指向的
105
+ * .agent-presets 目录已不存在的行。含本机历史遗留的旧 insert(如指向早已
106
+ * 不存在的 paoding-config-ui 的声明行)——判据只认「目录没了」,不认行名。
107
+ * presetId 解析不出的行不在此列(doctor 不点名它,清理侧也不动它,交给用户)。
108
+ * 托管块损坏时返回空数组(损坏块只能整块手工处理,行级手术做不了)。
109
+ */
110
+ export function orphanManagedRows(dshHome) {
111
+ const rows = readHomePatchRows(dshHome)
112
+ return rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
113
+ }
114
+
115
+ /**
116
+ * 清理执行器(uninstall 与 doctor --fix 共用,别在外面另拼删盘逻辑):
117
+ * - stripBlock: true → 整块摘除托管块(uninstall;含起止标记与块前空行分隔)
118
+ * - pruneRowIds: 撤指定 rowId 的声明行段(doctor --fix 的孤儿行清理)
119
+ * - removeDirIds: 删指定 orchestrator* 预设目录(rmSync 逐个,单目录失败不
120
+ * 拖垮其余);声明行轨上被删目录的声明行同步撤下(目录与行同生同灭,与
121
+ * generateAndInstall 的孤儿回收口径一致)
122
+ * - 删完目录后 .generator-version 若仍在(理论上随基础目录已走,防御性兜底)
123
+ * 一并删除并在结果里注明
124
+ * 备份纪律见模块头:对 cordis.patch.yml 的首次写前备份 .bak-1,之后不再覆盖。
125
+ * 返回结构化结果(字段见实现),全程不 throw——单步失败折进对应字段,由调用
126
+ * 方决定退出码与文案。
127
+ */
128
+ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowIds = [], removeDirIds = [] } = {}) {
129
+ const result = {
130
+ backupPath: null,
131
+ blockPresent: false,
132
+ blockCorrupt: false,
133
+ blockRemoved: false,
134
+ prunedRowIds: [],
135
+ markerRemoved: false,
136
+ removedDirs: [],
137
+ dirFailures: [],
138
+ }
139
+
140
+ const block = managedBlockState(dshHome)
141
+ result.blockPresent = block.present
142
+ result.blockCorrupt = block.corrupt
143
+
144
+ let backupDone = false
145
+ const backupOnce = () => {
146
+ if (backupDone) return
147
+ backupDone = true
148
+ result.backupPath = backupBeforeWrite(path.join(dshHome, 'cordis.patch.yml'))
149
+ }
150
+
151
+ // 1) 托管块手术(损坏块不碰:preset-system 会拒绝写盘,降级给调用方报需手工)
152
+ if (block.present && !block.corrupt) {
153
+ const rowsOnDisk = readHomePatchRows(dshHome).map((r) => r.rowId)
154
+ if (stripBlock) {
155
+ backupOnce()
156
+ result.blockRemoved = stripHomePatchRows(dshHome, null)
157
+ } else if (pruneRowIds.length > 0) {
158
+ const present = pruneRowIds.filter((id) => rowsOnDisk.includes(id))
159
+ if (present.length > 0) {
160
+ backupOnce()
161
+ // stripHomePatchRows 返回 true = 指定段确已撤下;块里本来就没有的 id
162
+ // 不算撤(present 已过滤),不虚报。
163
+ if (stripHomePatchRows(dshHome, present)) result.prunedRowIds = present
164
+ }
165
+ }
166
+ }
167
+
168
+ // 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序)
169
+ if (removeDirIds.length > 0) {
170
+ for (const id of removeDirIds) {
171
+ const dir = path.join(dshHome, '.agent-presets', id)
172
+ try {
173
+ rmSync(dir, { recursive: true, force: true })
174
+ result.removedDirs.push(id)
175
+ } catch (err) {
176
+ result.dirFailures.push({ id, error: err?.message ?? String(err) })
177
+ }
178
+ }
179
+ 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)
186
+ }
187
+ }
188
+
189
+ // 3) 版本标记兜底:正常随基础 orchestrator 目录一起删;防御目录删除整体失败
190
+ //(dirFailures)或外部摆了非常规布局时补一手,保证「卸载后无标记残留」。
191
+ const markerPath = generatorMarkerPath(dshHome)
192
+ if (existsSync(markerPath)) {
193
+ try {
194
+ rmSync(markerPath, { force: true })
195
+ result.markerRemoved = true
196
+ } catch { /* 删不掉就留着:dirFailures / 保留提示里已能解释现场 */ }
197
+ }
198
+
199
+ return result
200
+ }
201
+
202
+ /**
203
+ * 卸载计划(只读侦察,不落盘):给确认提示、非 TTY 防呆打印与「无事可做」
204
+ * 判定共用。不加载配置文件内容(卸载不依赖配置语义),只看存在性。
205
+ */
206
+ export function planUninstall({ dshHome, configFile } = {}) {
207
+ const block = managedBlockState(dshHome)
208
+ const markerPath = generatorMarkerPath(dshHome)
209
+ return {
210
+ dshHome,
211
+ configFile,
212
+ patchFile: path.join(dshHome, 'cordis.patch.yml'),
213
+ blockPresent: block.present,
214
+ blockCorrupt: block.corrupt,
215
+ blockRowCount: readHomePatchRows(dshHome).length,
216
+ presetDirs: listOrchestratorPresetIds(dshHome),
217
+ markerPath,
218
+ markerExists: existsSync(markerPath),
219
+ configExists: existsSync(configFile),
220
+ configBackups: listConfigBackupFiles(configFile),
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 配置文件的备份兄弟文件(`dsh-paoding.config.yml.bak*`,用户或历史工具手工
226
+ * 留的):--purge 时连这些一起删;默认保留时提示里点名数量。
227
+ */
228
+ export function listConfigBackupFiles(configFile) {
229
+ const dir = path.dirname(configFile)
230
+ const base = path.basename(configFile)
231
+ let names = []
232
+ try {
233
+ names = readdirSync(dir)
234
+ } catch {
235
+ return []
236
+ }
237
+ return names
238
+ .filter((name) => name.startsWith(`${base}.bak`))
239
+ .sort()
240
+ .map((name) => path.join(dir, name))
241
+ }
242
+
243
+ /** --purge 的配置删除:配置文件 + 全部 .bak* 备份逐个删,返回实际删掉的路径。 */
244
+ export function removeConfigAndBackups(configFile) {
245
+ const removed = []
246
+ for (const file of [configFile, ...listConfigBackupFiles(configFile)]) {
247
+ try {
248
+ rmSync(file, { force: true })
249
+ removed.push(file)
250
+ } catch { /* 删不掉就留着,由调用方在收尾提示里交代 */ }
251
+ }
252
+ return removed
253
+ }
254
+
255
+ /** .agent-presets 下与庖丁无关的残留目录名(如用户自制 preset),卸载后提示「未动」。 */
256
+ export function foreignPresetDirsRemaining(dshHome) {
257
+ let names = []
258
+ try {
259
+ names = readdirSync(path.join(dshHome, '.agent-presets'))
260
+ } catch {
261
+ return []
262
+ }
263
+ return names.filter((name) => !/^orchestrator(-[a-z0-9-]+)?$/.test(name)).sort()
264
+ }