dsh-paoding 0.2.2 → 0.3.1
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 +27 -32
- package/README_en.md +27 -32
- package/bin/dsh-paoding.mjs +73 -11
- package/docs/architecture.md +41 -34
- package/docs/architecture_en.md +65 -42
- package/docs/configuration.md +159 -65
- package/docs/configuration_en.md +161 -66
- package/docs/images/1.png +0 -0
- package/docs/installation.md +100 -237
- package/docs/installation_en.md +112 -252
- package/docs/orchestration.md +34 -38
- package/docs/orchestration_en.md +34 -38
- package/package.json +8 -5
- package/plugins/paoding-config-ui/api-core.mjs +215 -18
- package/plugins/paoding-config-ui/index.mjs +414 -154
- package/plugins/paoding-config-ui/lib/base.css +1083 -42
- package/plugins/paoding-config-ui/lib/client.js +2266 -252
- package/plugins/paoding-config-ui/package.json +2 -2
- package/plugins/paoding-config-ui/runtime-inventory.mjs +27 -26
- package/presets/orchestrator/agent.cordis.yml +19 -9
- package/presets/orchestrator/preset.yml +3 -2
- package/presets/orchestrator/restrict.mjs +2 -1
- package/tools/install.mjs +9 -4
- package/tools/lib/alloc.mjs +60 -29
- package/tools/lib/cli.mjs +127 -278
- package/tools/lib/compose.mjs +261 -95
- package/tools/lib/config.mjs +266 -86
- package/tools/lib/host.mjs +55 -24
- package/tools/lib/skills.mjs +72 -45
- package/tools/lib/state.mjs +172 -40
- package/tools/lib/upgrade.mjs +170 -0
- package/tools/lib/util.mjs +7 -0
- package/tools/lib/version.mjs +233 -0
- package/tools/lib/workspaces.mjs +74 -0
- package/tools/lib/yaml.mjs +92 -15
- package/docs/images/2.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6.png +0 -0
- package/install.sh +0 -31
- package/tools/lib/wizard.mjs +0 -516
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
* dsh-paoding 配置后端核心 —— DSH Web 插件 (plugins/paoding-config-ui)
|
|
3
3
|
* 的路由层适配:复用 tools/install.mjs 的检测与生成管线。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 提供:检测状态收集(缓存)、状态序列化(含 workspaceMeta 工作区元数据)、
|
|
6
|
+
* 生成/安装(支持按工作区落独立 preset)、首装/升级后的 preset 自动重生成
|
|
7
|
+
* (ensurePresetInstalled,等价 install --auto 语义)。零运行时依赖。
|
|
6
8
|
*/
|
|
7
9
|
import os from 'node:os'
|
|
8
10
|
import path from 'node:path'
|
|
9
|
-
import {
|
|
11
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { readFile } from 'node:fs/promises'
|
|
13
|
+
import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
|
|
14
|
+
import { autoAssignments } from '../../tools/lib/alloc.mjs'
|
|
10
15
|
|
|
11
16
|
export function resolveDshHome() {
|
|
12
17
|
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
@@ -20,26 +25,38 @@ export function resolveConfigFile() {
|
|
|
20
25
|
|
|
21
26
|
let stateCache = null
|
|
22
27
|
let stateError = null
|
|
28
|
+
// 采集 stateCache 时的配置文件 mtime(null = 采集时配置不存在 / 不可读):
|
|
29
|
+
// 供 isStateCacheStale 做外部改动对账——外部(CLI / 手编)改过配置后,面板
|
|
30
|
+
// 不再拿着陈旧缓存 apply 覆盖丢失外部修改。
|
|
31
|
+
let stateCacheMtimeMs = null
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return stateCache
|
|
33
|
+
/** 配置文件当前 mtime;读不到(不存在 / 无权限)返回 null。 */
|
|
34
|
+
function configMtimeMs() {
|
|
35
|
+
try {
|
|
36
|
+
return statSync(resolveConfigFile()).mtimeMs
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
31
39
|
}
|
|
32
|
-
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 缓存是否已落后于磁盘配置(无缓存视为失效)。命中缓存时 stat 一次配置文件
|
|
44
|
+
* 对账 mtime:变了说明外部改过,调用方应重刷(带运行时事实的重刷在路由层)。
|
|
45
|
+
*/
|
|
46
|
+
export function isStateCacheStale() {
|
|
47
|
+
if (stateCache === null) return true
|
|
48
|
+
return configMtimeMs() !== stateCacheMtimeMs
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getState() {
|
|
52
|
+
// 纯读缓存:命中且配置文件 mtime 未变直接返回;缺失 / 已失效再重刷(不带
|
|
53
|
+
// 运行时事实——是否带事实由路由层 helper 决定,见 index.mjs)。
|
|
54
|
+
if (!isStateCacheStale()) return stateCache
|
|
33
55
|
await refreshState()
|
|
34
56
|
if (stateError) throw stateError
|
|
35
57
|
return stateCache
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
/** 是否有可用的状态缓存(供路由层决定是否值得带运行时事实重刷)。 */
|
|
39
|
-
export function hasStateCache() {
|
|
40
|
-
return stateCache !== null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
60
|
export async function refreshState(runtimeFacts = null) {
|
|
44
61
|
stateError = null
|
|
45
62
|
try {
|
|
@@ -52,8 +69,12 @@ export async function refreshState(runtimeFacts = null) {
|
|
|
52
69
|
})
|
|
53
70
|
} catch (err) {
|
|
54
71
|
stateCache = null
|
|
72
|
+
stateCacheMtimeMs = null
|
|
55
73
|
stateError = err
|
|
74
|
+
return
|
|
56
75
|
}
|
|
76
|
+
// 记录采集时刻的配置文件 mtime,作为后续外部改动对账的基准
|
|
77
|
+
stateCacheMtimeMs = configMtimeMs()
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
/** Serialize the state into a JSON-safe view for the UI. */
|
|
@@ -67,6 +88,19 @@ export function serializeState(s) {
|
|
|
67
88
|
const personas = {}
|
|
68
89
|
for (const [role, p] of s.personaBlocks) personas[role] = p.text
|
|
69
90
|
|
|
91
|
+
// 已落盘 preset 目录名透传(orchestrator + orchestrator-<slug>,供工作区
|
|
92
|
+
// 元数据对账;字段缺失视为无 —— 老版本 collectState 产出的状态同样可用)
|
|
93
|
+
const installedPresets = Array.isArray(s.installedPresets) ? s.installedPresets : []
|
|
94
|
+
// 工作区元数据:现有配置里每个工作区路径的 slug / presetId / preset 是否已
|
|
95
|
+
// 生成。整组路径过一次 assignSlugs(与生成层同源),保证 slug 与落盘目录名
|
|
96
|
+
// 一致;existing 本身随上方透传,这里只补派生视图。
|
|
97
|
+
const workspaceMeta = {}
|
|
98
|
+
const wsSlugs = assignSlugs(Object.keys((s.existing && s.existing.workspaces) || {}))
|
|
99
|
+
for (const [wsPath, slug] of wsSlugs) {
|
|
100
|
+
const presetId = presetIdOf(slug)
|
|
101
|
+
workspaceMeta[wsPath] = { slug, presetId, installed: installedPresets.includes(presetId) }
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
return {
|
|
71
105
|
dshHome: s.dshHome,
|
|
72
106
|
configFile: s.configFile,
|
|
@@ -82,6 +116,8 @@ export function serializeState(s) {
|
|
|
82
116
|
restrictBase: [...s.restrictBase],
|
|
83
117
|
existing: s.existing,
|
|
84
118
|
suggested: s.suggested,
|
|
119
|
+
installedPresets,
|
|
120
|
+
workspaceMeta,
|
|
85
121
|
// 主 persona 尾部追加的默认常量(供 UI「人设追加」默认展示 / 恢复默认)
|
|
86
122
|
mainPersonaExtraDefault: s.mainPersonaExtraDefault ?? null,
|
|
87
123
|
// 运行时识别结果透传(字段缺失视为未启用运行时检测,与旧版 UI 行为一致)
|
|
@@ -91,10 +127,171 @@ export function serializeState(s) {
|
|
|
91
127
|
}
|
|
92
128
|
}
|
|
93
129
|
|
|
94
|
-
/** Compose + (optionally) install from a UI-supplied assignments object.
|
|
95
|
-
|
|
130
|
+
/** Compose + (optionally) install from a UI-supplied assignments object.
|
|
131
|
+
* workspacePath 非 null 时为该工作区生成独立 preset(见 generateAndInstall)。 */
|
|
132
|
+
export function installAssignments(state, assignments, { dryRun = false, saveConfigOnWrite = false, workspacePath = null } = {}) {
|
|
96
133
|
if (!assignments || typeof assignments !== 'object') {
|
|
97
134
|
throw new Error('missing assignments object')
|
|
98
135
|
}
|
|
99
|
-
return generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite })
|
|
136
|
+
return generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite, workspacePath })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 首装 / 升级后 preset 自动重生成 ─────────────────────────────────────────
|
|
140
|
+
//
|
|
141
|
+
// 唯一官方安装通道是插件通道(dsh plugin add dsh-paoding),包不再携带 CLI:
|
|
142
|
+
// preset 的落盘改由插件启动时自愈——目录缺失(首装)或由旧版生成器产出(升级
|
|
143
|
+
// 换包)时,按 install --auto 语义重生成一次。对账基准是 preset 目录里的
|
|
144
|
+
// .generator-version 标记文件(内容 = 生成器版本):插件包版本变 → 标记不符 →
|
|
145
|
+
// 下次启动自动重生成,一举解决「升级后 preset 还是旧版产物」的漂移问题。
|
|
146
|
+
|
|
147
|
+
// 生成器版本(标记对账基准):插件包根 package.json 读一次即缓存。读失败兜底
|
|
148
|
+
// '0.0.0'——最坏后果只是每次启动多重生成一次 preset,不会装坏任何东西。
|
|
149
|
+
let cachedGeneratorVersion = null
|
|
150
|
+
|
|
151
|
+
/** 插件包版本(package.json version;读失败兜底 '0.0.0')。 */
|
|
152
|
+
export function pluginVersion() {
|
|
153
|
+
if (cachedGeneratorVersion !== null) return cachedGeneratorVersion
|
|
154
|
+
try {
|
|
155
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
156
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '') cachedGeneratorVersion = pkg.version
|
|
157
|
+
} catch { /* 读不到 / 解析失败:走兜底值 */ }
|
|
158
|
+
return cachedGeneratorVersion ?? '0.0.0'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// dispose 中止时的返回理由(记 reason 而非 error:不算失败,调用方不打日志)。
|
|
162
|
+
const PRESET_ENSURE_ABORTED = '插件已卸载(dispose),本次 preset 生成中止'
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 首装 / 升级后自动重生成 orchestrator preset(等价 install --auto 语义)。
|
|
166
|
+
*
|
|
167
|
+
* 判定:preset 目录缺失,或 .generator-version 标记缺失 / 版本与 generatorVersion
|
|
168
|
+
* 不符 → 执行一次自动安装:局部 collectState()(不带运行时事实的纯文件扫描,
|
|
169
|
+
* 首装触发点在插件启动、宿主服务未必就绪,且与 CLI --auto 语义对齐;不走
|
|
170
|
+
* refreshState()——那会拿无事实的扫描覆盖路由层的共享缓存)后按既有配置合成
|
|
171
|
+
* assignments(无配置写基础模板),经 generateAndInstall 落盘,最后写标记文件。
|
|
172
|
+
* shouldAbort 在各 await 点后检查:插件 dispose 后在途的生成不再继续写盘。
|
|
173
|
+
* 可脱离 ctx 独立调用(单测友好),任何异常自捕、绝不抛出。
|
|
174
|
+
*
|
|
175
|
+
* @param {{dshHome?: string, generatorVersion?: string, shouldAbort?: () => boolean}} opts
|
|
176
|
+
* 缺省取 resolveDshHome() 与 pluginVersion();shouldAbort 返回 true 时中止
|
|
177
|
+
* (按 reason 返回,不算 error)。
|
|
178
|
+
* @returns {Promise<{installed: boolean, reason?: string, error?: string}>}
|
|
179
|
+
* installed=true 表示本次实际重生成;false 时带 reason(无需重生成 / 已中止)
|
|
180
|
+
* 或 error(生成失败,调用方只记日志)。
|
|
181
|
+
*/
|
|
182
|
+
export async function ensurePresetInstalled({ dshHome = resolveDshHome(), generatorVersion = pluginVersion(), shouldAbort = null } = {}) {
|
|
183
|
+
try {
|
|
184
|
+
const presetDir = path.join(dshHome, '.agent-presets', 'orchestrator')
|
|
185
|
+
const markerFile = path.join(presetDir, '.generator-version')
|
|
186
|
+
let marker = null
|
|
187
|
+
try {
|
|
188
|
+
marker = (await readFile(markerFile, 'utf8')).trim()
|
|
189
|
+
} catch { /* 目录或标记缺失:按未生成处理 */ }
|
|
190
|
+
if (marker === generatorVersion) {
|
|
191
|
+
return { installed: false, reason: 'preset 已由当前版本生成器产出,无需重生成' }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
|
|
195
|
+
const state = await collectState({
|
|
196
|
+
dshHome,
|
|
197
|
+
configFile: resolveConfigFile(),
|
|
198
|
+
cwd: process.env.DSH_PAODING_CWD || process.cwd(),
|
|
199
|
+
runtimeFacts: null,
|
|
200
|
+
})
|
|
201
|
+
if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
|
|
202
|
+
const assignments = autoAssignments({
|
|
203
|
+
existing: state.existing,
|
|
204
|
+
suggested: state.suggested,
|
|
205
|
+
staticBase: state.staticBase,
|
|
206
|
+
})
|
|
207
|
+
const result = generateAndInstall(state, assignments, {
|
|
208
|
+
dryRun: false,
|
|
209
|
+
// 与 CLI --auto 同款:fresh 把基础模板落进配置文件(显式锚定「未开启
|
|
210
|
+
// host 工具」);有配置时绝不覆写用户配置。
|
|
211
|
+
saveConfigOnWrite: !state.existing,
|
|
212
|
+
})
|
|
213
|
+
if (!result.wrote) throw new Error('preset 生成未落盘')
|
|
214
|
+
|
|
215
|
+
// 标记最后写:generateAndInstall 会整目录重建(rmSync),先写必被删。
|
|
216
|
+
if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
|
|
217
|
+
mkdirSync(presetDir, { recursive: true })
|
|
218
|
+
writeFileSync(markerFile, `${generatorVersion}\n`, 'utf8')
|
|
219
|
+
return {
|
|
220
|
+
installed: true,
|
|
221
|
+
reason: state.existing
|
|
222
|
+
? 'preset 缺失或由旧版生成器产出,已按现有配置重新生成'
|
|
223
|
+
: 'preset 缺失或由旧版生成器产出,已写入基础模板',
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// 绝不抛:首装自愈是锦上添花,失败只带回 error 交调用方记日志,不拖垮插件启动。
|
|
227
|
+
return { installed: false, error: String(err?.message ?? err) }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 工作区候选发现(「添加工作区」组合框的服务端候选扫描)──────────────────
|
|
232
|
+
|
|
233
|
+
// home 直属目录黑名单:系统 / 媒体目录不算项目。正常情况它们也不带项目标记,
|
|
234
|
+
// 这份名单是双保险——万一哪天 Desktop 里翻出个带 .git 的东西也拦得住。
|
|
235
|
+
const HOME_DIR_SKIP = new Set([
|
|
236
|
+
'Library', 'Applications', 'Desktop', 'Downloads', 'Movies', 'Music',
|
|
237
|
+
'Pictures', 'Public', 'Documents',
|
|
238
|
+
])
|
|
239
|
+
|
|
240
|
+
// 单个目录命中的项目标记:.git / .codegraph 目录、package.json 文件,
|
|
241
|
+
// 返回标记名(进候选的 marker 字段,客户端可据此展示);一个都没有返回 null。
|
|
242
|
+
function projectMarkerOf(dirPath) {
|
|
243
|
+
const markers = [
|
|
244
|
+
['.git', 'isDirectory'],
|
|
245
|
+
['.codegraph', 'isDirectory'],
|
|
246
|
+
['package.json', 'isFile'],
|
|
247
|
+
]
|
|
248
|
+
for (const [name, check] of markers) {
|
|
249
|
+
try {
|
|
250
|
+
if (statSync(path.join(dirPath, name))[check]()) return name
|
|
251
|
+
} catch { /* 无此标记(或不可读):换下一个接着看 */ }
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 扫描「添加工作区」的候选目录。扫描面刻意收窄(有界,不深递归):
|
|
258
|
+
* 已知路径的父目录一层 + home 一层,各自只看直属子目录。只收带项目标记的
|
|
259
|
+
* 真实目录;已在注册表 / 配置里的路径不再建议;单个父目录读不了(权限、
|
|
260
|
+
* 不存在等)就地跳过,绝不让整个发现失败。返回 { path, basename, marker }[],
|
|
261
|
+
* 按 basename 排序,最多 50 条(截断不算错误)。
|
|
262
|
+
*/
|
|
263
|
+
export function discoverWorkspaceCandidates({ registryPaths = [], configPaths = [], home = os.homedir() } = {}) {
|
|
264
|
+
// 已知路径集合:候选与它们撞车就没有建议价值了
|
|
265
|
+
const known = new Set()
|
|
266
|
+
for (const p of [...registryPaths, ...configPaths]) {
|
|
267
|
+
if (typeof p === 'string' && p !== '') known.add(path.resolve(p))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 待扫父目录:home 兜底 + 已知路径的父目录(Set 天然去重)
|
|
271
|
+
const parents = new Set([home])
|
|
272
|
+
for (const p of known) parents.add(path.dirname(p))
|
|
273
|
+
|
|
274
|
+
const seen = new Set()
|
|
275
|
+
const items = []
|
|
276
|
+
for (const parent of parents) {
|
|
277
|
+
let entries
|
|
278
|
+
try {
|
|
279
|
+
entries = readdirSync(parent, { withFileTypes: true })
|
|
280
|
+
} catch { /* 读不了这层就跳过这层:发现是锦上添花,不值得为一个目录报错 */ continue }
|
|
281
|
+
for (const ent of entries) {
|
|
282
|
+
// 只收真实目录;symlink 不跟进,扫描面才守得住「有界」
|
|
283
|
+
if (!ent.isDirectory()) continue
|
|
284
|
+
if (parent === home && HOME_DIR_SKIP.has(ent.name)) continue
|
|
285
|
+
const dirPath = path.join(parent, ent.name)
|
|
286
|
+
const resolved = path.resolve(dirPath)
|
|
287
|
+
if (known.has(resolved) || seen.has(resolved)) continue
|
|
288
|
+
const marker = projectMarkerOf(dirPath)
|
|
289
|
+
if (!marker) continue // 裸目录:没有项目依据,不进候选
|
|
290
|
+
seen.add(resolved)
|
|
291
|
+
items.push({ path: dirPath, basename: ent.name, marker })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
items.sort((a, b) => a.basename.localeCompare(b.basename))
|
|
296
|
+
return items.slice(0, 50)
|
|
100
297
|
}
|