dsh-paoding 0.2.2 → 0.3.0

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.
@@ -1,8 +1,12 @@
1
1
  /**
2
- * 技能发现与 SKILL.md 元数据解析:detectSkills 扫描四类根目录($DSH_HOME/
3
- * $DSH_AGENTS_HOME/项目 .dsh|.agents skills),findSkillMeta 定位并读取一个
4
- * SKILL.md、解析 frontmatter description(skillDescriptionOf /
5
- * stripSkillDescription)。依赖 node:fs / node:os / node:path;被 state 引用。
2
+ * 技能发现与 SKILL.md 元数据解析:detectSkills / findSkillMeta 共用同一组根
3
+ * 目录——用户级两根($DSH_HOME/skills、$DSH_AGENTS_HOME|~/.agents/skills)
4
+ * 恒扫;项目级两根(<cwd>/.dsh/skills、<cwd>/.agents/skills)仅在 cwd 显式
5
+ * 传入具体工作区目录时追加,cwd 为 null/undefined/空串即「全局语义」,只扫
6
+ * 用户级(技能扫描不跟随 GUI 进程启动目录,项目根按工作区按需传入)。
7
+ * findSkillMeta 定位并读取一个 SKILL.md、解析 frontmatter description
8
+ * (skillDescriptionOf / stripSkillDescription)。依赖 node:fs / node:os /
9
+ * node:path;被 state 引用。
6
10
  */
7
11
  import { readFileSync, readdirSync } from 'node:fs'
8
12
  import { homedir } from 'node:os'
@@ -13,18 +17,21 @@ import path from 'node:path'
13
17
  /**
14
18
  * Discover installed skills from the same roots dsh-skill-filesystem scans:
15
19
  * user roots ($DSH_HOME/skills, $DSH_AGENTS_HOME/skills or ~/.agents/skills)
16
- * plus project roots (<cwd>/.dsh/skills, <cwd>/.agents/skills). A skill is a
20
+ * always, plus the project roots (<cwd>/.dsh/skills, <cwd>/.agents/skills)
21
+ * only when `cwd` is a non-empty string — null / undefined / '' means the
22
+ * global view (user roots only; scanning never follows the GUI process start
23
+ * directory, project roots are per-workspace opt-in). A skill is a
17
24
  * directory or a .md file; its name is the basename. Roots that do not exist
18
25
  * are skipped.
19
26
  */
20
- export function detectSkills(dshHome, cwd) {
27
+ export function detectSkills(dshHome, cwd = null) {
21
28
  const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
29
+ // 用户级两根恒扫;cwd 非空(显式传入工作区目录)才追加项目级两根。
22
30
  const roots = [
23
31
  path.join(dshHome, 'skills'),
24
32
  path.join(agentsHome, 'skills'),
25
- path.join(cwd, '.dsh', 'skills'),
26
- path.join(cwd, '.agents', 'skills'),
27
33
  ]
34
+ if (cwd) roots.push(path.join(cwd, '.dsh', 'skills'), path.join(cwd, '.agents', 'skills'))
28
35
  const skills = new Set()
29
36
  for (const root of roots) {
30
37
  let entries
@@ -37,7 +44,9 @@ export function detectSkills(dshHome, cwd) {
37
44
  if (entry.isDirectory()) {
38
45
  if (entry.name.startsWith('.')) continue
39
46
  skills.add(entry.name)
40
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
47
+ } else if (entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.')) {
48
+ // 文件分支同样跳过点开头名:.foo.md 不是技能(编辑器/系统临时文件常是
49
+ // 点前缀),与目录分支口径一致。
41
50
  skills.add(entry.name.slice(0, -'.md'.length))
42
51
  }
43
52
  }
@@ -51,8 +60,10 @@ export function detectSkills(dshHome, cwd) {
51
60
  * default soft mode writes a compact skill row (name + description + absolute
52
61
  * SKILL.md path) into the persona and the main agent reads the full rules on
53
62
  * demand with its own read tool (zero directory injection); the optional hard
54
- * inline mode inlines the full body instead. Uses the same four roots as
55
- * detectSkills; within each root a directory skill (<root>/<name>/SKILL.md)
63
+ * inline mode inlines the full body instead. Uses the same roots as
64
+ * detectSkills (user roots always; project roots only for a non-empty `cwd`,
65
+ * i.e. the target workspace); within each root a directory skill
66
+ * (<root>/<name>/SKILL.md)
56
67
  * wins over a bare <root>/<name>.md file. Strips the leading YAML
57
68
  * frontmatter when present, then trims. `description` is parsed from the
58
69
  * frontmatter (single-line, quoted, folded `>` or literal `|` block — block
@@ -62,14 +73,15 @@ export function detectSkills(dshHome, cwd) {
62
73
  * { name, description, file, body } with `file` the absolute SKILL.md path,
63
74
  * or null when not found / empty body.
64
75
  */
65
- export function findSkillMeta(name, dshHome, cwd) {
76
+ export function findSkillMeta(name, dshHome, cwd = null) {
66
77
  const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
78
+ // 与 detectSkills 同款:用户级两根恒扫;cwd 非空(目标工作区目录)才追加
79
+ // 项目级两根——全局目标只解析用户级技能,工作区目标可解析该项目根里的技能。
67
80
  const roots = [
68
81
  path.join(dshHome, 'skills'),
69
82
  path.join(agentsHome, 'skills'),
70
- path.join(cwd, '.dsh', 'skills'),
71
- path.join(cwd, '.agents', 'skills'),
72
83
  ]
84
+ if (cwd) roots.push(path.join(cwd, '.dsh', 'skills'), path.join(cwd, '.agents', 'skills'))
73
85
  const fmRe = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
74
86
  for (const root of roots) {
75
87
  for (const candidate of [path.join(root, name, 'SKILL.md'), path.join(root, `${name}.md`)]) {
@@ -79,6 +91,9 @@ export function findSkillMeta(name, dshHome, cwd) {
79
91
  } catch {
80
92
  continue
81
93
  }
94
+ // 先剥 UTF-8 BOM(Windows 编辑器常见):否则 ^--- 锚点失配,frontmatter
95
+ // 整体解析失效(description 丢失、正文残留 frontmatter)。
96
+ if (text.startsWith('\uFEFF')) text = text.slice(1)
82
97
  const body = text.replace(fmRe, '').trim()
83
98
  if (body === '') return null
84
99
  const fm = text.match(fmRe)
@@ -94,47 +109,59 @@ export function findSkillMeta(name, dshHome, cwd) {
94
109
  * scalars (marker on the key line or on its own next line): block content is
95
110
  * the following indented lines up to the first key line at or above the
96
111
  * description key's indent (or the frontmatter end), whitespace-collapsed.
112
+ * 零缩进键优先:嵌套映射里的同名键(子对象的 description)不再抢在顶层
113
+ * frontmatter 键之前命中;没有零缩进键时回退到首个任意缩进的同名键(原行为)。
97
114
  * Returns '' when the key is absent or empty.
98
115
  */
99
116
  export function skillDescriptionOf(frontmatter) {
100
117
  const lines = frontmatter.split(/\r?\n/)
118
+ // 第一遍:只认零缩进 description 键(顶层 frontmatter 键)。
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const key = lines[i].match(/^description:\s*(.*)$/)
121
+ if (key) return descriptionValueAt(lines, i, key[1], 0)
122
+ }
123
+ // 第二遍回退:首个任意缩进的同名键(原行为)。
101
124
  for (let i = 0; i < lines.length; i++) {
102
125
  const key = lines[i].match(/^(\s*)description:\s*(.*)$/)
103
- if (!key) continue
104
- const indent = key[1].length
105
- let value = key[2]
106
- let contentFrom = i + 1
107
- if (value !== '') {
108
- if (!/^[>|][+-]?\d*$/.test(value)) return stripSkillDescription(value)
109
- // block marker on the key line: content starts on the next line
110
- } else if (lines[i + 1] && /^\s*[>|][+-]?\d*\s*$/.test(lines[i + 1])) {
111
- contentFrom = i + 2 // marker on its own line
112
- } else {
113
- return '' // empty value, no block scalar
114
- }
115
- let stop = lines.length
116
- let minIndent = Infinity
117
- for (let j = contentFrom; j < lines.length; j++) {
118
- const line = lines[j]
119
- if (line.trim() === '') continue
120
- const lineIndent = (line.match(/^[ \t]*/) ?? [''])[0].length
121
- if (lineIndent <= indent) {
122
- stop = j
123
- break
124
- }
125
- if (lineIndent < minIndent) minIndent = lineIndent
126
- }
127
- if (minIndent === Infinity) return ''
128
- const parts = []
129
- for (let j = contentFrom; j < stop; j++) {
130
- const line = lines[j]
131
- parts.push(line.trim() === '' ? '' : line.slice(Math.min(minIndent, line.length)))
132
- }
133
- return stripSkillDescription(parts.join(' '))
126
+ if (key) return descriptionValueAt(lines, i, key[2], key[1].length)
134
127
  }
135
128
  return ''
136
129
  }
137
130
 
131
+ /** 从 lines[i] 的 description 键解析其值(value 为键行冒号后的原文,indent 为
132
+ * 键缩进;块标量正文取自后续行)。语义与抽出的原实现逐字一致。 */
133
+ function descriptionValueAt(lines, i, rawValue, indent) {
134
+ let value = rawValue
135
+ let contentFrom = i + 1
136
+ if (value !== '') {
137
+ if (!/^[>|][+-]?\d*$/.test(value)) return stripSkillDescription(value)
138
+ // block marker on the key line: content starts on the next line
139
+ } else if (lines[i + 1] && /^\s*[>|][+-]?\d*\s*$/.test(lines[i + 1])) {
140
+ contentFrom = i + 2 // marker on its own line
141
+ } else {
142
+ return '' // empty value, no block scalar
143
+ }
144
+ let stop = lines.length
145
+ let minIndent = Infinity
146
+ for (let j = contentFrom; j < lines.length; j++) {
147
+ const line = lines[j]
148
+ if (line.trim() === '') continue
149
+ const lineIndent = (line.match(/^[ \t]*/) ?? [''])[0].length
150
+ if (lineIndent <= indent) {
151
+ stop = j
152
+ break
153
+ }
154
+ if (lineIndent < minIndent) minIndent = lineIndent
155
+ }
156
+ if (minIndent === Infinity) return ''
157
+ const parts = []
158
+ for (let j = contentFrom; j < stop; j++) {
159
+ const line = lines[j]
160
+ parts.push(line.trim() === '' ? '' : line.slice(Math.min(minIndent, line.length)))
161
+ }
162
+ return stripSkillDescription(parts.join(' '))
163
+ }
164
+
138
165
  /** Collapse whitespace, strip one pair of surrounding quotes, cap at 200 chars. */
139
166
  export function stripSkillDescription(raw) {
140
167
  let value = raw.trim()
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * 公共状态管线:collectState(patch 分层扫描 / MCP 工具名解析 / 插件与技能检测 /
3
- * smart defaults,含可选 runtimeFacts 合并)与 generateAndInstall(compose +
4
- * yaml 校验 + dst 目录落盘 + saveConfig)。install.mjs 的公共导出
5
- * (collectState / generateAndInstall)即来自本模块。
6
- * 依赖 util / yaml / config / host / skills / spans / compose / alloc 与
7
- * node:fs / node:path;被 cli 与 plugins/paoding-config-ui 引用。
3
+ * smart defaults,含可选 runtimeFacts 合并 / 已落盘 preset 目录扫描)与
4
+ * generateAndInstall(compose + yaml 校验 + dst 目录落盘 + saveConfig,支持按
5
+ * 工作区落到 orchestrator-<slug> 独立 preset 目录并把条目并进配置 workspaces 段;
6
+ * 配置读取失败 / 状态不可信时拒绝破坏性落盘,成功后回收孤儿工作区 preset)。
7
+ * install.mjs 的公共导出(collectState / generateAndInstall)即来自本模块。
8
+ * 依赖 util / yaml / config / host / skills / spans / compose / alloc / workspaces
9
+ * 与 node:fs / node:path;被 cli 与 plugins/paoding-config-ui 引用。
8
10
  */
9
11
  import {
10
12
  chmodSync,
11
13
  copyFileSync,
12
14
  existsSync,
13
15
  mkdirSync,
16
+ readdirSync,
14
17
  readFileSync,
15
18
  rmSync,
16
19
  statSync,
@@ -19,7 +22,15 @@ import {
19
22
  import path from 'node:path'
20
23
  import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, SRC_DIR, warn } from './util.mjs'
21
24
  import { flattenEntries, loadYaml, parsePatchFile } from './yaml.mjs'
22
- import { loadConfig, normalizeMainAgentName, saveConfig, yamlScalar } from './config.mjs'
25
+ import {
26
+ loadConfig,
27
+ normalizeRoleName,
28
+ normalizeTargetFields,
29
+ saveConfig,
30
+ stripNonTargetKeys,
31
+ yamlScalar,
32
+ } from './config.mjs'
33
+ import { assignSlugs, presetIdOf } from './workspaces.mjs'
23
34
  import {
24
35
  extractMcp,
25
36
  isEntryEnabled,
@@ -69,21 +80,12 @@ export async function collectState({ dshHome, configFile, profile = null, patche
69
80
  } catch (err) {
70
81
  throw new Error(`cannot locate role blocks in ${path.join(SRC_DIR, 'agent.cordis.yml')}: ${err.message}`)
71
82
  }
72
- let restrictBase
73
- try {
74
- restrictBase = extractMainAgentAllow(restrictSrc)
75
- } catch (err) {
76
- throw new Error(err.message)
77
- }
83
+ // 这层包装无增值(错误文案原样透传),让原始错误直接上抛。
84
+ const restrictBase = extractMainAgentAllow(restrictSrc)
78
85
 
79
86
  // Load the existing config (null when absent); its profile wins unless an
80
- // explicit profile was given.
81
- let existing = null
82
- try {
83
- existing = loadConfig(configFile, yamlMod)
84
- } catch (err) {
85
- throw new Error(err.message)
86
- }
87
+ // explicit profile was given. 读失败(存在但解析不了等)原样上抛,不做静默降级。
88
+ const existing = loadConfig(configFile, yamlMod)
87
89
  const effectiveProfile = profile ?? existing?.profile ?? 'web'
88
90
 
89
91
  // Scan the patch layers (home, profile, extra --patch files) in order.
@@ -145,7 +147,11 @@ export async function collectState({ dshHome, configFile, profile = null, patche
145
147
  for (const tool of plugin.tools) inventory.add(tool)
146
148
  }
147
149
 
148
- const skills = detectSkills(dshHome, cwd)
150
+ // 技能检测走全局语义(cwd 传 null,只扫用户级两根):state.skills 是全局
151
+ // 视角的用户级技能,不再跟随进程启动目录(GUI 场景下启动目录没有「当前
152
+ // 项目」含义);工作区视角的项目级技能由插件路由按工作区目录另行检测。
153
+ // cwd 入参保留不动(patch 扫描外的其余用途与透传照旧)。
154
+ const skills = detectSkills(dshHome, null)
149
155
  const staticBase = {}
150
156
  for (const role of ROLES) staticBase[role] = blocks.get(role).names
151
157
  const suggested = smartDefaults(mcpReports, inventory, staticBase)
@@ -205,6 +211,19 @@ export async function collectState({ dshHome, configFile, profile = null, patche
205
211
  }
206
212
  }
207
213
 
214
+ // 已落盘 preset 目录扫描(编排类):.agent-presets 下的 orchestrator 基础
215
+ // preset 与 orchestrator-<slug> 工作区专属 preset。扫描失败(目录尚不存在、
216
+ // 无读权限等)一律降级为空列表,绝不影响检测结果本体;generateAndInstall
217
+ // 成功落盘后以它为对账清单做孤儿工作区 preset 回收。
218
+ let installedPresets = []
219
+ try {
220
+ installedPresets = readdirSync(path.join(dshHome, '.agent-presets'))
221
+ .filter((name) => /^orchestrator(-[a-z0-9-]+)?$/.test(name))
222
+ .sort()
223
+ } catch {
224
+ installedPresets = []
225
+ }
226
+
208
227
  return {
209
228
  dshHome,
210
229
  cwd,
@@ -226,6 +245,9 @@ export async function collectState({ dshHome, configFile, profile = null, patche
226
245
  skills,
227
246
  staticBase,
228
247
  suggested,
248
+ // 已落盘的编排类 preset 目录名列表(含基础 orchestrator 与各工作区专属),
249
+ // 供 UI 的 workspaceMeta 对账「该工作区 preset 是否已生成」。
250
+ installedPresets,
229
251
  // 主 persona 尾部追加的默认常量(配置键 main_agent_persona_extra 缺省值):
230
252
  // 供 UI state(serializeState → /api/paoding/state)展示与「恢复默认」用。
231
253
  mainPersonaExtraDefault: DEFAULT_MAIN_AGENT_PERSONA_EXTRA,
@@ -240,30 +262,73 @@ export async function collectState({ dshHome, configFile, profile = null, patche
240
262
  /**
241
263
  * Compose, validate and (unless dry-run) install the generated preset from an
242
264
  * assignments object. Shared by the CLI and the visual config UI.
243
- * Returns { text, roleResults, staleNote, wrote }.
265
+ * Returns { text, roleResults, staleNote, wrote, presetId }.
266
+ *
267
+ * 破坏性落盘护栏:配置读取失败直接抛错中止(不静默降级 prev=null 继续装);
268
+ * 配置缺失(prev=null)而目标 preset 目录已存在时同样拒绝重建。落盘成功后对
269
+ * state.installedPresets 做孤儿工作区 preset 回收(不被 workspaces 引用的
270
+ * orchestrator-<slug> 目录 rmSync + warn;基础 preset 永不清)。
271
+ *
272
+ * workspacePath(可选):传工作区绝对路径时生成落到 orchestrator-<slug> 独立
273
+ * preset 目录(slug 由磁盘配置现有 workspaces 键 ∪ 本路径统一分配,同名目录
274
+ * 冲突自动加 hash 后缀),preset 显示名按 main_agent_display_name >
275
+ * (工作区预设派生名)「SRC 名·目录名」取值,
276
+ * 存盘只把该条目并进配置 workspaces 段(顶层字段维持磁盘现状);不传(null)
277
+ * 走全局路径,落点与存盘行为和旧版完全一致。
244
278
  */
245
- export function generateAndInstall(state, assignments, { dryRun = false, saveConfigOnWrite = false } = {}) {
246
- const { srcText, blocks, personaBlocks, inventory, restrictBase, yamlMod, dshHome, cwd, dstDir, dstAgentFile, configFile, scanned, effectiveProfile } = state
279
+ export function generateAndInstall(state, assignments, { dryRun = false, saveConfigOnWrite = false, workspacePath = null } = {}) {
280
+ const { srcText, blocks, personaBlocks, inventory, restrictBase, yamlMod, dshHome, configFile, scanned, effectiveProfile } = state
281
+
282
+ // assignments 剥污染键:profile / workspaces 不属于目标字段,UI/CLI 透传
283
+ // 一律丢弃,防其混进生成的 preset 或覆盖配置文件里对应的段。
284
+ assignments = stripNonTargetKeys(assignments)
285
+
286
+ // 工作区定位:路径归一(容忍尾斜杠 / 相对写法),空值一律视为全局。
287
+ const wsPath = workspacePath === null || workspacePath === undefined || workspacePath === ''
288
+ ? null
289
+ : path.normalize(path.resolve(workspacePath))
290
+ let dstDir = state.dstDir
291
+ let presetId = 'orchestrator'
292
+
293
+ // 配置现状读取(下方 slug 分配与 saveConfig 合并共用这一次,避免双读漂移):
294
+ // 读失败(存在但解析不了 / 权限错)直接抛错中止 —— 旧版静默降级 prev=null 后
295
+ // 继续 rmSync / 覆写配置,等于对着不可信状态做破坏性落盘,已收紧为硬错误。
296
+ let prev = null
297
+ try {
298
+ prev = loadConfig(configFile, yamlMod)
299
+ } catch (err) {
300
+ throw new Error(`现有配置 ${configFile} 读取失败,已中止(未做任何落盘): ${err.message}`)
301
+ }
302
+
303
+ if (wsPath !== null) {
304
+ // slug 取自磁盘配置的 workspaces 键集合 ∪ 本路径(prev 已在上方统一读取):
305
+ // 整组统一分配才能让同 basename 的冲突方都拿到稳定的 hash 后缀。
306
+ const wsPaths = Object.keys(prev?.workspaces ?? {})
307
+ if (!wsPaths.includes(wsPath)) wsPaths.push(wsPath)
308
+ presetId = presetIdOf(assignSlugs(wsPaths).get(wsPath))
309
+ dstDir = path.join(dshHome, '.agent-presets', presetId)
310
+ }
311
+ const dstAgentFile = path.join(dstDir, 'agent.cordis.yml')
247
312
 
248
313
  // Pre-read the SKILL.md metas of the skills selected for the main-agent
249
314
  // persona (soft rows via main_agent_skills + optional hard inlining via
250
- // main_agent_skills_inline; union, deduped). Names whose file is missing
315
+ // main_agent_skills_inline; union, deduped). 技能按生成目标解析:工作区
316
+ // 目标用该工作区目录(wsPath,已归一为绝对路径)追加项目级两根,可解析该
317
+ // 工作区项目根里的技能(SKILL.md 绝对路径写进 persona,供主 agent read 按
318
+ // 需读);全局目标传 null 只解析用户级。Names whose file is missing
251
319
  // simply do not enter the map — composeGenerated warns about them.
320
+ const skillCwd = wsPath
252
321
  const softNames = (assignments?.main_agent_skills ?? []).filter((n) => typeof n === 'string' && n !== '')
253
322
  const hardNames = (assignments?.main_agent_skills_inline ?? []).filter((n) => typeof n === 'string' && n !== '')
254
323
  const mainAgentSkillMetas = {}
255
324
  for (const name of [...new Set([...softNames, ...hardNames])]) {
256
- const hit = findSkillMeta(name, dshHome, cwd)
325
+ const hit = findSkillMeta(name, dshHome, skillCwd)
257
326
  if (hit) mainAgentSkillMetas[name] = hit
258
327
  }
259
328
 
260
329
  // Compose the generated config; a composition error is a hard error.
261
- let composed
262
- try {
263
- composed = composeGenerated(srcText, blocks, personaBlocks, assignments, inventory, restrictBase, mainAgentSkillMetas)
264
- } catch (err) {
265
- throw new Error(err.message)
266
- }
330
+ //(这层包装无增值,让 composeGenerated 的原始错误直接上抛。)
331
+ const composed = composeGenerated(srcText, blocks, personaBlocks, assignments, inventory, restrictBase, mainAgentSkillMetas)
267
332
  const generatedText = composed.text
268
333
  const roleResults = composed.roleResults
269
334
 
@@ -292,23 +357,50 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
292
357
  )
293
358
  }
294
359
 
360
+ // preset.yml 显示名,两级优先:main_agent_display_name(仅显示名键,persona
361
+ // 身份行不可改名)> 工作区预设派生名(wsPath 非空且未设显示名时取
362
+ // 「SRC preset.yml 的 name 值·工作区目录名」,让预设选择器能分清多个工作区);
363
+ // 全局路径未设显示名时维持 SRC 原名(行为与旧版一致)。displayNameSource
364
+ // 记住显示名来自哪个键:找不到可替换的 name: 行时 warn 按真实来源归因
365
+ // (派生名不来自任何键,单独措辞)。
366
+ let displayName = normalizeRoleName(assignments?.main_agent_display_name ?? null, 'main_agent_display_name')
367
+ let displayNameSource = displayName !== null ? 'main_agent_display_name' : null
368
+ if (displayName === null && wsPath !== null) {
369
+ try {
370
+ const srcPresetText = readFileSync(path.join(SRC_DIR, 'preset.yml'), 'utf8')
371
+ const m = /^name:[ \t]*(.+?)[ \t]*$/m.exec(srcPresetText)
372
+ if (m) displayName = `${m[1]}·${path.basename(wsPath)}`
373
+ } catch {
374
+ /* SRC preset 不可读 / 无 name 行:维持原名不改 */
375
+ }
376
+ }
377
+
295
378
  let wrote = false
296
379
  if (!dryRun) {
380
+ // 破坏性落盘护栏:配置不可信(读取失败已在上方抛错中止;此处 prev=null =
381
+ // 配置文件缺失)而目标 preset 目录已存在时,拒绝 rmSync 重建与随后的配置
382
+ // 覆写 —— 对着未知状态动刀可能毁掉用户仅存的落盘 preset。全新安装(无配置
383
+ // 且无目录)不受影响,仍照常装。
384
+ if (prev === null && existsSync(dstDir)) {
385
+ throw new Error(
386
+ `配置文件 ${configFile} 缺失但 preset 目录已存在(${dstDir}),已拒绝破坏性重建;` +
387
+ '请先恢复配置文件,或手动删除该目录后重试',
388
+ )
389
+ }
297
390
  mkdirSync(path.join(dshHome, '.agent-presets'), { recursive: true })
298
391
  rmSync(dstDir, { recursive: true, force: true })
299
392
  mkdirSync(dstDir, { recursive: true })
300
393
  for (const name of ['preset.yml', 'restrict.mjs']) {
301
394
  const src = path.join(SRC_DIR, name)
302
395
  const dst = path.join(dstDir, name)
303
- const mainAgentName = normalizeMainAgentName(assignments?.main_agent_name ?? null)
304
- if (name === 'preset.yml' && mainAgentName !== null) {
305
- // main_agent_name 非空:preset 显示名改为配置名。读 SRC 文本、把首个
306
- // (非注释的)顶层 `name:` 行值替换为安全 yaml 标量(含 ':' / '#' 等时
307
- // 单引号包裹转义),其余字节原样;chmod 与下方一致。
396
+ if (name === 'preset.yml' && displayName !== null) {
397
+ // 显示名非空(配置了 main_agent_display_name 或工作区预设派生名):读
398
+ // SRC 文本、把首个(非注释的)顶层 `name:` 行值替换为安全 yaml 标量
399
+ //(含 ':' / '#' 等时单引号包裹转义),其余字节原样;chmod 与下方一致。
308
400
  const presetText = readFileSync(src, 'utf8')
309
- const next = presetText.replace(/^name:.*$/m, `name: ${yamlScalar(mainAgentName)}`)
401
+ const next = presetText.replace(/^name:.*$/m, `name: ${yamlScalar(displayName)}`)
310
402
  if (next === presetText) {
311
- warn('preset.yml: 未找到可替换的顶层 name: 行(main_agent_name 未生效),已按原样写入')
403
+ warn(`preset.yml: 未找到可替换的顶层 name: 行(${displayNameSource ?? '工作区派生显示名'} 未生效),已按原样写入`)
312
404
  }
313
405
  writeFileSync(dst, next)
314
406
  } else {
@@ -319,10 +411,50 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
319
411
  writeFileSync(path.join(dstDir, 'agent.cordis.yml'), generatedText)
320
412
  chmodSync(path.join(dstDir, 'agent.cordis.yml'), statSync(path.join(SRC_DIR, 'agent.cordis.yml')).mode & 0o777)
321
413
  if (saveConfigOnWrite) {
322
- saveConfig(configFile, { profile: effectiveProfile, ...assignments })
414
+ // 合并写盘(prev 已在函数开头读过,读失败早已中止,此处不做二次静默降
415
+ // 级):全局应用只写顶层目标字段、workspaces 段原样保留;工作区应用只把
416
+ // 该条目并进 workspaces 段、顶层字段(含 profile)维持磁盘现状——两个对
417
+ // 象互不踩。顶层字段经 normalizeTargetFields 保留键存在性标记
418
+ // (has_main_agent_extra),老配置没有的键保存后仍缺省。
419
+ const next = wsPath === null
420
+ ? {
421
+ profile: effectiveProfile,
422
+ ...normalizeTargetFields(assignments),
423
+ workspaces: prev?.workspaces ?? {},
424
+ }
425
+ : {
426
+ profile: prev?.profile ?? effectiveProfile,
427
+ ...normalizeTargetFields(prev),
428
+ workspaces: { ...(prev?.workspaces ?? {}), [wsPath]: normalizeTargetFields(assignments) },
429
+ }
430
+ saveConfig(configFile, next)
323
431
  }
324
432
  wrote = true
325
433
  }
326
434
 
327
- return { text: generatedText, roleResults, staleNote, wrote }
435
+ // ── 孤儿工作区 preset 回收(成功落盘后对账)──
436
+ // installedPresets 里 orchestrator-<slug> 形态、且不被配置 workspaces 任一条目
437
+ // (含本次并入的 wsPath)引用的目录按孤儿回收(rmSync + warn 行)。护栏:基础
438
+ // preset(orchestrator)与本次刚重建的 dstDir 永不清;prev 不可信(null,配置
439
+ // 缺失 / 未读到)时整体跳过 —— 没有可信引用清单可对账,宁可残留也不误删;
440
+ // 删除失败只 warn 保留,绝不让回收拖垮本次安装结果。
441
+ if (wrote && prev !== null && Array.isArray(state.installedPresets)) {
442
+ const wsEntries = Object.keys(prev.workspaces ?? {})
443
+ if (wsPath !== null && !wsEntries.includes(wsPath)) wsEntries.push(wsPath)
444
+ const referenced = new Set([...assignSlugs(wsEntries).values()].map(presetIdOf))
445
+ for (const name of state.installedPresets) {
446
+ if (name === 'orchestrator' || referenced.has(name)) continue
447
+ const dir = path.join(dshHome, '.agent-presets', name)
448
+ if (dir === dstDir) continue // 本次刚写出的目标目录绝不清
449
+ if (!existsSync(dir)) continue // 扫描后已被外部删除:无事可做
450
+ try {
451
+ rmSync(dir, { recursive: true, force: true })
452
+ warn(`孤儿工作区 preset 已回收: ${dir}(配置 workspaces 已无引用)`)
453
+ } catch (err) {
454
+ warn(`孤儿工作区 preset 回收失败(已保留): ${dir}: ${err?.message ?? err}`)
455
+ }
456
+ }
457
+ }
458
+
459
+ return { text: generatedText, roleResults, staleNote, wrote, presetId }
328
460
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 面板一键升级执行器 —— spawn `dsh plugin --profile <profile> update dsh-paoding`
3
+ * 原地换版本(Web 面板「升级」按钮专用;CLI 不走这里)。
4
+ *
5
+ * 唯一官方安装通道是 DSH 插件通道:包由 pnpm 装进 profile
6
+ * ($DSH_HOME/profiles/<name>/node_modules/dsh-paoding),升级 = 让 dsh 的插件
7
+ * 转发器对该包做 pnpm update + bundle reconcile。本模块只负责「拿目标版本 →
8
+ * 探测自身 profile → 跑子进程」这一段编排;profile 名从本文件运行时真实路径
9
+ * 反推(registry 安装形态必然位于 <...>/profiles/<name>/node_modules/ 之下),
10
+ * 开发 link: 直连形态的真实路径在仓库里、匹配不到,如实报「无法就地升级」。
11
+ *
12
+ * 零依赖纯 Node(>=18):目标版本复用 version.mjs 的 fetchLatestFromRegistry
13
+ * (npm registry 优先),子进程、时钟全部可注入便于单测。升级后不做磁盘后验
14
+ * ——本进程代码不随升级变化,无从对账;成功与否以 dsh 子进程退出码为准,
15
+ * 新版本在重启 DSH 后生效(preset 由新包的启动自愈钩子自动重生成)。
16
+ *
17
+ * 失败路径全部诚实暴露(不静默):拿不到版本、开发形态、超时、退出码非 0,
18
+ * 都原样带回 error + 子进程输出尾部,调用方(面板路由)转成 500 醒目展示
19
+ * ——升级是用户主动点的高级操作,失败被吞掉比失败本身更糟。
20
+ */
21
+ import os from 'node:os'
22
+ import { spawn } from 'node:child_process'
23
+ import { realpath } from 'node:fs/promises'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { currentVersion, compareVersions, fetchLatestFromRegistry } from './version.mjs'
26
+
27
+ // 子进程输出上限:合并 stdout+stderr 后只保留最后 ~8KB(安装进度刷屏无排障
28
+ // 价值,报错结论都在尾部);累积过程中超过 1MB 就先截一版,防止异常刷爆内存。
29
+ const OUTPUT_TAIL_BYTES = 8 * 1024
30
+ const OUTPUT_HARD_CAP_BYTES = 1024 * 1024
31
+
32
+ // 模块级互斥:同一进程同时只允许一个升级在跑(pnpm 双开操作同一 profile 必炸)。
33
+ // 进行中再调立即返回失败,不排队——升级是分钟级操作,排队只会让面板转圈更久。
34
+ let upgradeInFlight = false
35
+
36
+ function tailOutput(text) {
37
+ const s = String(text ?? '')
38
+ return s.length > OUTPUT_TAIL_BYTES ? s.slice(-OUTPUT_TAIL_BYTES) : s
39
+ }
40
+
41
+ /**
42
+ * 缺省子进程执行器(可被 spawnImpl 注入替换,测试用):spawn 任意命令,合并
43
+ * 收集 stdout+stderr,timeoutMs 到点 kill 并按超时收敛。永不 reject,统一收敛
44
+ * 为 { code, output, error? }(error 仅 spawn 本身失败(如 ENOENT)时存在)。
45
+ */
46
+ function defaultSpawn(command, args, options, timeoutMs) {
47
+ return new Promise((resolve) => {
48
+ const child = spawn(command, args, options)
49
+ let output = ''
50
+ const onChunk = (chunk) => {
51
+ output += chunk.toString()
52
+ if (output.length > OUTPUT_HARD_CAP_BYTES) output = output.slice(-OUTPUT_TAIL_BYTES)
53
+ }
54
+ child.stdout?.on('data', onChunk)
55
+ child.stderr?.on('data', onChunk)
56
+ let timedOut = false
57
+ const timer = setTimeout(() => {
58
+ timedOut = true
59
+ child.kill('SIGTERM') // close 事件仍会触发,收尾统一在 close 里判定
60
+ }, timeoutMs)
61
+ child.on('error', (err) => {
62
+ clearTimeout(timer)
63
+ resolve({ code: null, output: tailOutput(output), error: String(err?.message ?? err) })
64
+ })
65
+ child.on('close', (code) => {
66
+ clearTimeout(timer)
67
+ resolve(timedOut
68
+ ? { code, output: tailOutput(output), error: `dsh plugin update 升级超时(${Math.round(timeoutMs / 1000)}s)` }
69
+ : { code, output: tailOutput(output) })
70
+ })
71
+ })
72
+ }
73
+
74
+ /**
75
+ * 从本模块运行时真实路径反推所属 profile 名。registry / git 依赖等插件通道
76
+ * 安装形态必然落在 <...>/profiles/<name>/node_modules/dsh-paoding/ 之下;开发
77
+ * link: 直连时真实路径是仓库目录,匹配不到返回 null(调用方据此拒绝就地升级
78
+ * 并指路手动命令)。realpath 必须走(macOS /tmp 等符号链接会遮住真实层级);
79
+ * 分隔符两种都收,兼容 win32 路径。
80
+ * @returns {Promise<string|null>}
81
+ */
82
+ export async function detectProfileName() {
83
+ try {
84
+ const real = await realpath(fileURLToPath(import.meta.url))
85
+ const m = /[/\\]profiles[/\\]([^/\\]+)[/\\]node_modules[/\\]/.exec(String(real))
86
+ return m ? m[1] : null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 执行一键升级。
94
+ * @param {{profile?: string, fetchImpl?: Function, spawnImpl?: Function,
95
+ * timeoutMs?: number, now?: Function|number}} opts
96
+ * - profile 可选:显式指定目标 profile(跳过路径反推,测试 / 手动强制用);
97
+ * 缺省经 detectProfileName() 从运行时真实路径反推。
98
+ * - fetchImpl / spawnImpl / now 可注入(单测用);缺省用 globalThis.fetch、
99
+ * node:child_process 的 spawn、Date.now()。
100
+ * - timeoutMs:dsh 子进程超时(含 pnpm 下载安装),默认 120s。
101
+ * - spawnImpl 契约:(command, args, options, timeoutMs) → Promise resolving
102
+ * { code, output, error? }(同 defaultSpawn 的收敛形状)。
103
+ * @returns {Promise<object>} 恒不抛错,收敛为:
104
+ * - { ok: true, upToDate: true, current } 本地已不落后 registry
105
+ * - { ok: true, version, profile, output, elapsedMs } 升级成功(退出码 0)
106
+ * - { ok: false, error, output?, elapsedMs? } 各类失败(原因见 error)
107
+ */
108
+ export async function runUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs = 120000, now } = {}) {
109
+ if (upgradeInFlight) return { ok: false, error: '升级正在进行中' }
110
+ upgradeInFlight = true
111
+ try {
112
+ return await doRunUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs, now })
113
+ } catch (err) {
114
+ // 防御性兜底:上面的编排路径本应自捕一切,这里保证互斥锁绝不因意外泄漏
115
+ //(泄漏 = 本进程永远不能再升级)。
116
+ return { ok: false, error: '升级执行异常: ' + String(err?.message ?? err) }
117
+ } finally {
118
+ upgradeInFlight = false
119
+ }
120
+ }
121
+
122
+ async function doRunUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs, now }) {
123
+ const startedAt = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
124
+ const elapsedMs = () =>
125
+ Math.max(0, (typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()) - startedAt)
126
+
127
+ // ① 版本预检:拿不到 registry latest 就不做半截升级(宁可不动现有安装)。
128
+ // registry 超时给 10s:面板静默检测用 3s 够了,这里是用户显式点了「升级」、
129
+ // 本来就要等,多花几秒换一次成功率是划算的;仍超时就如实报失败。
130
+ const { version: current } = await currentVersion()
131
+ const reg = await fetchLatestFromRegistry({ fetchImpl, timeoutMs: 10000 })
132
+ if (!reg.ok) return { ok: false, error: `无法确定最新版本:${reg.error}` }
133
+ const target = reg.latest // 形如 v0.2.3
134
+ if (compareVersions(target, current) <= 0) return { ok: true, upToDate: true, current }
135
+ const targetVersion = target.replace(/^v/, '')
136
+
137
+ // ② profile 探测:开发 link: 直连形态无法就地升级,如实拒绝并指路手动命令。
138
+ const resolved = typeof profile === 'string' && profile !== '' ? profile : await detectProfileName()
139
+ if (!resolved) {
140
+ return {
141
+ ok: false,
142
+ error: '开发 link 形态无法就地升级,请手动执行 dsh plugin add dsh-paoding@latest 切换到 registry 版本',
143
+ }
144
+ }
145
+
146
+ // ③ spawn dsh 插件转发器:update = pnpm update + bundle reconcile。cwd 用
147
+ // 系统临时目录:子进程不该在仓库或 $DSH_HOME 里跑;DSH_HOME 经环境继承,
148
+ // 由子进程的 dsh 自行解析。win32 的 dsh 是 .cmd shim,必须借 shell 拉起
149
+ // (Node 直接 spawn .cmd 会 EINVAL,见 CVE-2024-27980 加固),与
150
+ // bin/dsh-paoding.mjs 同一方案;参数保持数组形式,转义交给 Node。
151
+ const useShell = process.platform === 'win32'
152
+ const command = useShell ? 'dsh.cmd' : 'dsh'
153
+ const run = typeof spawnImpl === 'function' ? spawnImpl : defaultSpawn
154
+ const proc = await run(command, ['plugin', '--profile', resolved, 'update', 'dsh-paoding'], {
155
+ cwd: os.tmpdir(),
156
+ env: process.env,
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ shell: useShell,
159
+ }, timeoutMs)
160
+ const output = tailOutput(proc?.output)
161
+
162
+ // ④ 无磁盘后验(旧 npx 时代读 $DSH_HOME/dsh-paoding 对账的方案已随该目录
163
+ // 一并废弃):本进程代码不随升级变化,无从对账;成功与否以退出码为准,
164
+ // 新版本重启 DSH 后生效(preset 由新版启动钩子自动重生成)。
165
+ if (proc?.error) return { ok: false, error: proc.error, output, elapsedMs: elapsedMs() }
166
+ if (proc?.code !== 0) {
167
+ return { ok: false, error: `dsh plugin update 退出码 ${proc?.code}`, output, elapsedMs: elapsedMs() }
168
+ }
169
+ return { ok: true, version: targetVersion, profile: resolved, output, elapsedMs: elapsedMs() }
170
+ }