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
package/tools/lib/skills.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 技能发现与 SKILL.md 元数据解析:detectSkills
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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)
|
|
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
|
|
55
|
-
* detectSkills;
|
|
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 (
|
|
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()
|
package/tools/lib/state.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 公共状态管线:collectState(patch 分层扫描 / MCP 工具名解析 / 插件与技能检测 /
|
|
3
|
-
* smart defaults,含可选 runtimeFacts
|
|
4
|
-
* yaml 校验 + dst 目录落盘 + saveConfig
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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).
|
|
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,
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
|
|
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(
|
|
401
|
+
const next = presetText.replace(/^name:.*$/m, `name: ${yamlScalar(displayName)}`)
|
|
310
402
|
if (next === presetText) {
|
|
311
|
-
warn(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|