dsh-maoying 0.1.17 → 0.1.19
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.en.md +6 -6
- package/README.md +6 -6
- package/docs/creator-panel.md +23 -11
- package/docs/design.md +3 -3
- package/docs/spec.md +3 -0
- package/index.mjs +66 -10
- package/lib/base.css +122 -3
- package/lib/client.js +436 -35
- package/lib/novel-parse.mjs +12 -1
- package/lib/panel-core.mjs +162 -15
- package/lib/self-update.mjs +408 -123
- package/package.json +1 -1
- package/presets/maoying/agent.cordis.v2.yml +7 -7
- package/presets/maoying/agent.cordis.yml +7 -7
- package/skills/maoying-review-checklist/SKILL.md +8 -3
- package/templates/novel/README.md +4 -0
- package/templates/novel/style.md +35 -0
package/lib/self-update.mjs
CHANGED
|
@@ -1,42 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 面板内一键升级 ——
|
|
2
|
+
* 面板内一键升级 —— 版本预检 + pnpm 候选链(机制对齐 dsh-paoding
|
|
3
|
+
* tools/lib/upgrade.mjs,0.1.18 起)。
|
|
3
4
|
*
|
|
4
5
|
* 背景:desktop 档的真实安装形态是 <dshHome>/profiles/<profile>/node_modules/
|
|
5
6
|
* dsh-maoying(真实目录,非 symlink),宿主 dsh CLI 拒绝管理 desktop 档,唯一
|
|
6
|
-
*
|
|
7
|
+
* 可行的升级命令是在 profile 目录里直跑 `pnpm add dsh-maoying@<精确版本>`:
|
|
8
|
+
* - cwd 就是 profile 目录(pnpm 的操作对象是它),不带 --dir;
|
|
9
|
+
* - 装精确版本而非 @latest——避开 pnpm 新鲜度防护 / 镜像 packument 滞后
|
|
10
|
+
* 造成的「跑了但啥也没装」静默空转。
|
|
11
|
+
*
|
|
12
|
+
* pnpm 解析(0.1.18 重写,修复「未找到 pnpm」误报):不再 existsSync+
|
|
13
|
+
* accessSync(X_OK) 文件预筛(corepack 管理且未 `corepack enable` 的机器 PATH
|
|
14
|
+
* 里没有裸 pnpm,桌面 GUI 进程 PATH 还可能是 launchd 最小集,预筛必落空),
|
|
15
|
+
* 改为 spawn ENOENT 探测链:逐候选真 spawn,仅 err.code==='ENOENT' 才换下一
|
|
16
|
+
* 路;EACCES / 超时 / 非零退出都是真实运行结果,如实返回不换通道。候选链:
|
|
17
|
+
* 1) 裸 pnpm(npm i -g / homebrew 等常规安装);
|
|
18
|
+
* 2) 裸 corepack(corepack 随 node 发行,未 enable 也能 `corepack pnpm ...`
|
|
19
|
+
* 即装即用);
|
|
20
|
+
* 3) /opt/homebrew/bin/corepack(existsSync 预筛,不在盘上不浪费 spawn);
|
|
21
|
+
* 4) /opt/homebrew/opt/node@XX/bin/corepack(readdirSync 过滤 node 前缀目录
|
|
22
|
+
* 拼路径,
|
|
23
|
+
* 兜住 keg-only 的 node@XX 版代安装——desktop Electron 最小 PATH 实测
|
|
24
|
+
* 全靠它命中);
|
|
25
|
+
* 5) path.dirname(process.execPath)/corepack(桌面 Electron 下无用但无害,
|
|
26
|
+
* headless / web profile 的 node 同目录形态有用);
|
|
27
|
+
* 6) ~/Library/pnpm、~/.local/share/pnpm(pnpm 独立安装脚本落位)。
|
|
28
|
+
* win32 的 pnpm / corepack 都是 .cmd shim,spawn 必须 shell:true(Node 直接
|
|
29
|
+
* spawn .cmd 会 EINVAL,见 CVE-2024-27980 加固),命令补 .cmd 后缀、参数保持
|
|
30
|
+
* 数组形式;darwin/linux 恒 shell:false,无注入面。
|
|
31
|
+
*
|
|
32
|
+
* 升级前版本预检:fetchLatestFromRegistry(10s)失败再 fetchLatestFromMirror
|
|
33
|
+
* (10s),拿不到 latest 绝不做半截升级;compareVersions(target, current) <= 0
|
|
34
|
+
* 直接短路「已是最新」,不 spawn。装完失效版本检测缓存。
|
|
7
35
|
*
|
|
8
36
|
* 安全边界(缺一不可,全不满足绝不执行安装):
|
|
9
37
|
* 1) 安装形态判定——插件包自身目录先 realpathSync 取真路径,真路径 ≠ 常规
|
|
10
38
|
* 路径即 link/symlink 安装(如 web 档指向 dev repo)→ 拒绝(dev-link,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 2)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
39
|
+
* 指路 git pull);真路径不匹配 <dshHome>/profiles/<profile>/node_modules/
|
|
40
|
+
* dsh-maoying 结构 → 拒绝(unsupported-layout)。dshHome / profile 取
|
|
41
|
+
* env.DSH_HOME / DSH_PROFILE(~ 前缀手动展开),缺失时回退从真路径的
|
|
42
|
+
* profiles 祖先推导,推导不出同样拒绝。
|
|
43
|
+
* 2) profile 就绪判定——profile 目录没有 package.json(未初始化)→ 拒绝
|
|
44
|
+
* (profile-missing),不硬跑 pnpm(会在空目录另起一份 package.json)。
|
|
45
|
+
* 3) 超时 180 秒——超时 SIGTERM(5s 后升级 SIGKILL)收敛为 timeout,不挂死
|
|
46
|
+
* 面板请求。
|
|
47
|
+
*
|
|
48
|
+
* 全局 CLI 顺带更新(方案 B:desktop profile 与全局 npm 安装是两份独立拷贝,
|
|
49
|
+
* 只更 profile 会版本偏斜):profile 阶段落定(installed 装完 / up-to-date
|
|
50
|
+
* 短路)后跑独立的全局阶段,目标是两份安装收敛到同一精确版本:
|
|
51
|
+
* - detectGlobalCli 零 spawn 纯文件系统检测:PATH 各目录 + /opt/homebrew/bin
|
|
52
|
+
* + /usr/local/bin 找 `maoying`(win32 `maoying.cmd`),existsSync 预筛;
|
|
53
|
+
* 命中后 realpathSync 该 bin 两级上溯得包根(bin/maoying.mjs → 包根),
|
|
54
|
+
* 包根 package.json 的 name 必须是 dsh-maoying 才认(防怪链接);包根
|
|
55
|
+
* realpath 落在本 profile 目录内(如 profile 自带 .bin/maoying)→ 不是
|
|
56
|
+
* 全局安装,跳过该候选继续找。找不到任何候选 → { found: false }。
|
|
57
|
+
* - 找不到全局安装 → globalCli: { ok: true, skipped: 'missing' },不 spawn;
|
|
58
|
+
* - 全局 version 与精确目标同版 → skipped: 'same',不 spawn;否则 spawn
|
|
59
|
+
* `npm i -g dsh-maoying@<target>`(cwd=os.tmpdir()、独立 120s 超时,npm
|
|
60
|
+
* 候选链与 pnpm 同款 spawn ENOENT 探测:裸 npm → execPath 同目录 →
|
|
61
|
+
* /opt/homebrew/bin → /usr/local/bin → /opt/homebrew/opt/node@XX/bin)。
|
|
62
|
+
* - 全局阶段失败(npm 缺失 / 非零 / 超时)**不拖累整体结果**:profile 已装
|
|
63
|
+
* 好,重启照常生效;globalCli.ok:false + 中文 error(含手动出路
|
|
64
|
+
* `npm i -g dsh-maoying@<target>`)如实透出。dev-link / unsupported-layout /
|
|
65
|
+
* no-target / profile-missing 等失败路径维持整单拒绝,不碰全局。
|
|
19
66
|
*
|
|
20
67
|
* 所有失败路径都不抛错,一律收敛为 { ok: false, kind, message } 结果对象;
|
|
21
|
-
* 成功返回 { ok: true, kind: 'installed', from, to, restartRequired: true
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
68
|
+
* 成功返回 { ok: true, kind: 'installed', from, to, restartRequired: true,
|
|
69
|
+
* globalCli },已是最新短路返回 { ok: true, kind: 'up-to-date', from,
|
|
70
|
+
* globalCli }(globalCli 形状见上节)。硬约束:装完后运行
|
|
71
|
+
* 中的进程仍是旧代码,必须重启 DeepSeek 才生效——restartRequired 恒 true,UI
|
|
72
|
+
* 文案必须明说,不得暗示装完即生效。依赖全部可注入(spawnImpl / realpathImpl /
|
|
73
|
+
* existsImpl / readdirImpl / fetchImpl / platform …),对齐 lib/version.mjs 的
|
|
74
|
+
* fetchImpl 模式,方便测试。
|
|
26
75
|
*/
|
|
27
76
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
28
|
-
import {
|
|
29
|
-
import { readFile } from 'node:fs/promises'
|
|
77
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
|
|
30
78
|
import os from 'node:os'
|
|
31
79
|
import path from 'node:path'
|
|
32
80
|
import { fileURLToPath } from 'node:url'
|
|
33
81
|
|
|
34
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
compareVersions,
|
|
84
|
+
currentVersion,
|
|
85
|
+
fetchLatestFromMirror,
|
|
86
|
+
fetchLatestFromRegistry,
|
|
87
|
+
invalidateVersionCache,
|
|
88
|
+
} from './version.mjs'
|
|
35
89
|
|
|
36
90
|
/** 安装超时:pnpm 冷缓存 + 国内网络最坏留足 3 分钟。 */
|
|
37
91
|
export const SELF_UPDATE_TIMEOUT_MS = 180 * 1000
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
92
|
+
/** 全局 CLI 更新超时:npm i -g 单包安装,独立于 profile 的 180s,2 分钟收敛。 */
|
|
93
|
+
export const GLOBAL_CLI_TIMEOUT_MS = 120 * 1000
|
|
94
|
+
/** 版本预检单路(registry / mirror)超时:用户显式点了「升级」,等得起。 */
|
|
95
|
+
const VERSION_TIMEOUT_MS = 10 * 1000
|
|
96
|
+
/**
|
|
97
|
+
* 失败信息里保留的子进程输出尾部长度(stdout+stderr 合并后取尾):8KB——
|
|
98
|
+
* pnpm 的报错结论都在尾部,但镜像 404 / 对账冲突这类关键行可能被进度刷屏
|
|
99
|
+
* 顶得很深,2KB 不够排障。
|
|
100
|
+
*/
|
|
101
|
+
const OUTPUT_TAIL_BYTES = 8 * 1024
|
|
102
|
+
/** 输出累积硬上限:超过先截一版,防异常刷爆内存。 */
|
|
103
|
+
const OUTPUT_HARD_CAP_BYTES = 1024 * 1024
|
|
104
|
+
/** SIGTERM 后留给进程自行退出的宽限,超时升级 SIGKILL。 */
|
|
105
|
+
const TERM_GRACE_MS = 5 * 1000
|
|
40
106
|
|
|
41
107
|
/**
|
|
42
108
|
* 插件包自身目录(package.json 所在目录)。抽成函数便于测试注入假安装位。
|
|
@@ -46,34 +112,57 @@ function defaultPkgDir() {
|
|
|
46
112
|
}
|
|
47
113
|
|
|
48
114
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
115
|
+
* 缺省子进程执行器(可被 spawnImpl 注入替换,测试用):spawn 任意命令,合并
|
|
116
|
+
* 收集 stdout+stderr,永不 reject,统一收敛为
|
|
117
|
+
* { code, output, error?, errorCode? }
|
|
118
|
+
* error 仅子进程异常(spawn 失败 / 超时)时存在;errorCode 为结构化失败码:
|
|
119
|
+
* spawn 失败透传 err.code(如 'ENOENT',候选链据此换下一路),超时收敛为
|
|
120
|
+
* 'ETIMEDOUT'。timeoutLabel 只用于超时文案标明哪条通道超时。超时先 SIGTERM,
|
|
121
|
+
* TERM_GRACE_MS 后仍不退则 SIGKILL 兜底(pnpm 尊重 SIGTERM;SIGKILL 保证
|
|
122
|
+
* close 事件必然到达,调用方绝不悬挂)。
|
|
51
123
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'/opt/homebrew/bin/pnpm',
|
|
56
|
-
'/usr/local/bin/pnpm',
|
|
57
|
-
path.join(home, '.local', 'share', 'pnpm', 'pnpm'),
|
|
58
|
-
path.join(home, 'Library', 'pnpm', 'pnpm'),
|
|
59
|
-
]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** 解析 pnpm 可执行文件:PATH 各目录在前、常见安装位兜底,去重后逐个可执行性检查。 */
|
|
63
|
-
function resolvePnpm(env, { existsImpl, accessImpl }) {
|
|
64
|
-
const pathEnv = typeof env.PATH === 'string' ? env.PATH : ''
|
|
65
|
-
const fromPath = pathEnv.split(path.delimiter).filter((d) => d !== '').map((d) => path.join(d, 'pnpm'))
|
|
66
|
-
const candidates = [...new Set([...fromPath, ...extraPnpmCandidates(env)])]
|
|
67
|
-
for (const candidate of candidates) {
|
|
124
|
+
export function defaultSpawn(command, args, options, timeoutMs, timeoutLabel) {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let child
|
|
68
127
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
128
|
+
child = nodeSpawn(command, args, options)
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// 参数非法等同步异常:与 error 事件同一收敛形状。
|
|
131
|
+
resolve({ code: null, output: '', error: String(err?.message ?? err), errorCode: err?.code ?? null })
|
|
132
|
+
return
|
|
74
133
|
}
|
|
75
|
-
|
|
76
|
-
|
|
134
|
+
let output = ''
|
|
135
|
+
const onChunk = (chunk) => {
|
|
136
|
+
output += chunk.toString()
|
|
137
|
+
if (output.length > OUTPUT_HARD_CAP_BYTES) output = output.slice(-OUTPUT_TAIL_BYTES)
|
|
138
|
+
}
|
|
139
|
+
child.stdout?.on('data', onChunk)
|
|
140
|
+
child.stderr?.on('data', onChunk)
|
|
141
|
+
let timedOut = false
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
timedOut = true
|
|
144
|
+
try { child.kill('SIGTERM') } catch { /* 已退出:忽略 */ }
|
|
145
|
+
}, timeoutMs)
|
|
146
|
+
const killTimer = setTimeout(() => {
|
|
147
|
+
if (!timedOut) return
|
|
148
|
+
try { child.kill('SIGKILL') } catch { /* 已退出:忽略 */ }
|
|
149
|
+
}, timeoutMs + TERM_GRACE_MS)
|
|
150
|
+
const settle = (r) => { clearTimeout(timer); clearTimeout(killTimer); resolve(r) }
|
|
151
|
+
child.on('error', (err) => {
|
|
152
|
+
settle({ code: null, output: tailOutput(output), error: String(err?.message ?? err), errorCode: err?.code ?? null })
|
|
153
|
+
})
|
|
154
|
+
child.on('close', (code) => {
|
|
155
|
+
settle(timedOut
|
|
156
|
+
? { code, output: tailOutput(output), error: `${timeoutLabel} 超时(${Math.round(timeoutMs / 1000)} 秒)已中止`, errorCode: 'ETIMEDOUT' }
|
|
157
|
+
: { code, output: tailOutput(output) })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 取尾部:超长只留最后 OUTPUT_TAIL_BYTES。 */
|
|
163
|
+
function tailOutput(text) {
|
|
164
|
+
const s = String(text ?? '')
|
|
165
|
+
return s.length > OUTPUT_TAIL_BYTES ? s.slice(-OUTPUT_TAIL_BYTES) : s
|
|
77
166
|
}
|
|
78
167
|
|
|
79
168
|
/**
|
|
@@ -98,48 +187,213 @@ function dshHomeFromEnv(env) {
|
|
|
98
187
|
return raw
|
|
99
188
|
}
|
|
100
189
|
|
|
190
|
+
/** env 里的用户 home(PATH 候选拼接用;缺失回退 os.homedir())。 */
|
|
191
|
+
function homeFromEnv(env) {
|
|
192
|
+
const raw = typeof env.HOME === 'string' && env.HOME.trim() !== '' ? env.HOME.trim() : ''
|
|
193
|
+
return raw !== '' ? raw : os.homedir()
|
|
194
|
+
}
|
|
195
|
+
|
|
101
196
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
197
|
+
* 组装 pnpm 候选链(详见文件头注):裸 pnpm → 裸 corepack(+['pnpm'] 前缀)
|
|
198
|
+
* → 各绝对路径落位(existsSync 预筛,不存在不浪费 spawn;去重保序)。
|
|
199
|
+
* win32 的 pnpm / corepack 是 .cmd shim,命令补 .cmd 后缀(配合 shell:true);
|
|
200
|
+
* darwin/linux 补空串。/opt/homebrew 系仅 darwin+homebrew 存在,existsSync
|
|
201
|
+
* 自然过滤,无需按平台分支。
|
|
105
202
|
*/
|
|
106
|
-
|
|
203
|
+
function buildCandidates({ platform, env, execPath, existsImpl, readdirImpl }) {
|
|
204
|
+
const suffix = platform === 'win32' ? '.cmd' : ''
|
|
205
|
+
const candidates = [
|
|
206
|
+
{ command: `pnpm${suffix}`, argsPrefix: [], label: 'pnpm' },
|
|
207
|
+
{ command: `corepack${suffix}`, argsPrefix: ['pnpm'], label: 'corepack pnpm' },
|
|
208
|
+
]
|
|
209
|
+
const absolute = ['/opt/homebrew/bin/corepack']
|
|
107
210
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
211
|
+
// /opt/homebrew/opt/<node 版本目录>/bin/corepack:目录不存在(非 ARM
|
|
212
|
+
// homebrew mac)readdir 抛错即跳过;命中多个 node 版本时按名排序全收。
|
|
213
|
+
const nodeDirs = readdirImpl('/opt/homebrew/opt')
|
|
214
|
+
.filter((name) => name.startsWith('node'))
|
|
215
|
+
.sort()
|
|
216
|
+
for (const dir of nodeDirs) absolute.push(path.join('/opt/homebrew/opt', dir, 'bin', `corepack${suffix}`))
|
|
217
|
+
} catch { /* 无 /opt/homebrew/opt:跳过这组候选 */ }
|
|
218
|
+
absolute.push(path.join(path.dirname(execPath), `corepack${suffix}`))
|
|
219
|
+
const home = homeFromEnv(env)
|
|
220
|
+
absolute.push(path.join(home, 'Library', 'pnpm', `pnpm${suffix}`))
|
|
221
|
+
absolute.push(path.join(home, '.local', 'share', 'pnpm', `pnpm${suffix}`))
|
|
222
|
+
for (const p of [...new Set(absolute)]) {
|
|
223
|
+
if (existsImpl(p)) candidates.push({ command: p, argsPrefix: [], label: p })
|
|
224
|
+
}
|
|
225
|
+
return candidates
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 全局 maoying CLI 检测(零 spawn,纯文件系统;机制见文件头注「全局 CLI 顺带
|
|
230
|
+
* 更新」节)。bin 候选位 = env.PATH 各目录 + /opt/homebrew/bin + /usr/local/bin
|
|
231
|
+
* (去重保序),找 `maoying`(win32 `maoying.cmd`),existsSync 预筛;命中后
|
|
232
|
+
* realpathSync 两级上溯得包根,包根 package.json 的 name 必须是 dsh-maoying
|
|
233
|
+
* 才认;包根 realpath 落在 profileDir 内(或就是它)→ 是 profile 自己的
|
|
234
|
+
* .bin/maoying,跳过该候选继续找。全落空 → { found: false }。
|
|
235
|
+
* 任何一步异常(断链 symlink / 包根不可读 / JSON 坏)都只跳过该候选,不抛错。
|
|
236
|
+
*/
|
|
237
|
+
function detectGlobalCli({ env, platform, profileDir, existsImpl, realpathImpl, readImpl }) {
|
|
238
|
+
const binName = `maoying${platform === 'win32' ? '.cmd' : ''}`
|
|
239
|
+
const dirs = String(typeof env.PATH === 'string' ? env.PATH : '')
|
|
240
|
+
.split(path.delimiter)
|
|
241
|
+
.map((d) => d.trim())
|
|
242
|
+
.filter((d) => d !== '')
|
|
243
|
+
dirs.push('/opt/homebrew/bin', '/usr/local/bin')
|
|
244
|
+
// profile 目录 realpath 只算一次(目录此刻必然存在——package.json 就绪闸已过;
|
|
245
|
+
// 读不到真路径就退回原路径,比对退化为保守不误杀全局候选)。
|
|
246
|
+
let realProfileDir = null
|
|
247
|
+
try { realProfileDir = path.resolve(realpathImpl(profileDir)) } catch { realProfileDir = path.resolve(profileDir) }
|
|
248
|
+
for (const dir of [...new Set(dirs)]) {
|
|
249
|
+
const bin = path.join(dir, binName)
|
|
250
|
+
if (!existsImpl(bin)) continue
|
|
251
|
+
let cli
|
|
252
|
+
try { cli = path.resolve(realpathImpl(bin)) } catch { continue }
|
|
253
|
+
const root = path.dirname(path.dirname(cli))
|
|
254
|
+
let manifest
|
|
255
|
+
try { manifest = JSON.parse(readImpl(path.join(root, 'package.json'))) } catch { continue }
|
|
256
|
+
if (manifest?.name !== 'dsh-maoying') continue
|
|
257
|
+
let realRoot
|
|
258
|
+
try { realRoot = path.resolve(realpathImpl(root)) } catch { realRoot = path.resolve(root) }
|
|
259
|
+
if (realRoot === realProfileDir || realRoot.startsWith(realProfileDir + path.sep)) continue
|
|
260
|
+
return {
|
|
261
|
+
found: true,
|
|
262
|
+
bin,
|
|
263
|
+
root,
|
|
264
|
+
version: typeof manifest.version === 'string' ? manifest.version : '',
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { found: false }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 组装 npm 候选链(全局 CLI 更新用,与 pnpm 链同款 spawn ENOENT 探测语义):
|
|
272
|
+
* 裸 npm → execPath 同目录(node 与 npm 同落位的常规发行形态)→
|
|
273
|
+
* /opt/homebrew/bin → /usr/local/bin → /opt/homebrew/opt/node@XX/bin(readdirSync
|
|
274
|
+
* 过滤 node 前缀目录,兜 keg-only node@XX)。绝对路径候选 existsSync 预筛;
|
|
275
|
+
* 去重保序。win32 的 npm 是 .cmd shim,命令补后缀(配合 shell:true)。
|
|
276
|
+
*/
|
|
277
|
+
function buildNpmCandidates({ platform, execPath, existsImpl, readdirImpl }) {
|
|
278
|
+
const suffix = platform === 'win32' ? '.cmd' : ''
|
|
279
|
+
const candidates = [{ command: `npm${suffix}`, label: 'npm' }]
|
|
280
|
+
const absolute = [
|
|
281
|
+
path.join(path.dirname(execPath), `npm${suffix}`),
|
|
282
|
+
'/opt/homebrew/bin/npm',
|
|
283
|
+
'/usr/local/bin/npm',
|
|
284
|
+
]
|
|
285
|
+
try {
|
|
286
|
+
const nodeDirs = readdirImpl('/opt/homebrew/opt')
|
|
287
|
+
.filter((name) => name.startsWith('node'))
|
|
288
|
+
.sort()
|
|
289
|
+
for (const dir of nodeDirs) absolute.push(path.join('/opt/homebrew/opt', dir, 'bin', `npm${suffix}`))
|
|
290
|
+
} catch { /* 无 /opt/homebrew/opt:跳过这组候选 */ }
|
|
291
|
+
for (const p of [...new Set(absolute)]) {
|
|
292
|
+
if (existsImpl(p)) candidates.push({ command: p, label: p })
|
|
293
|
+
}
|
|
294
|
+
return candidates
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 全局 CLI 阶段(profile 阶段落定后跑;失败绝不拖累整体结果,形状见文件头注)。
|
|
299
|
+
* 返回 { ok: true, skipped: 'missing' } / { ok: true, skipped: 'same', version } /
|
|
300
|
+
* { ok: true, from, to: targetVersion } / { ok: false, error, output? }。
|
|
301
|
+
* spawn 走 npm 候选链(仅 ENOENT 换下一路),cwd=os.tmpdir()(npm 全局装与
|
|
302
|
+
* cwd 无关,不该在 profile / 仓库里跑),独立 globalTimeoutMs 超时。
|
|
303
|
+
*/
|
|
304
|
+
async function updateGlobalCliStage({ targetVersion, profileDir }, deps) {
|
|
305
|
+
const { env, platform, execPath, spawnImpl, existsImpl, readdirImpl, realpathImpl, readImpl, logger, globalTimeoutMs } = deps
|
|
306
|
+
const g = detectGlobalCli({ env, platform, profileDir, existsImpl, realpathImpl, readImpl })
|
|
307
|
+
if (!g.found) return { ok: true, skipped: 'missing' }
|
|
308
|
+
// 版本比对('' = 包根 package.json 缺 version,当旧版处理装到精确目标)。
|
|
309
|
+
if (g.version !== '' && compareVersions(g.version, targetVersion) === 0) {
|
|
310
|
+
logger?.info?.(`maoying 创作台: 全局 maoying CLI 已是 ${g.version},跳过更新`)
|
|
311
|
+
return { ok: true, skipped: 'same', version: g.version }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const spawnOptions = {
|
|
315
|
+
cwd: os.tmpdir(),
|
|
316
|
+
env,
|
|
317
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
318
|
+
shell: platform === 'win32',
|
|
319
|
+
}
|
|
320
|
+
const candidates = buildNpmCandidates({ platform, execPath, existsImpl, readdirImpl })
|
|
321
|
+
const installArgs = ['i', '-g', `dsh-maoying@${targetVersion}`]
|
|
322
|
+
const manual = `npm i -g dsh-maoying@${targetVersion}`
|
|
323
|
+
|
|
324
|
+
let proc = null
|
|
325
|
+
for (const cand of candidates) {
|
|
326
|
+
proc = await spawnImpl(cand.command, installArgs, spawnOptions, globalTimeoutMs, 'npm i -g')
|
|
327
|
+
if (proc?.errorCode !== 'ENOENT') break
|
|
328
|
+
}
|
|
329
|
+
const output = tailOutput(proc?.output)
|
|
330
|
+
|
|
331
|
+
// 以下失败路径一律 globalCli.ok:false + 中文 error 含手动出路;整体结果由
|
|
332
|
+
// 调用方维持 ok:true(profile 已装好,全局失败不追责整单)。
|
|
333
|
+
if (proc?.errorCode === 'ENOENT') {
|
|
334
|
+
const tried = candidates.map((c) => c.label).join(' / ')
|
|
335
|
+
const error = `未找到可用的 npm(已试 ${tried}),全局 CLI 未更新;可手动执行:${manual}`
|
|
336
|
+
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(npm-missing): ${error}`)
|
|
337
|
+
return { ok: false, error }
|
|
338
|
+
}
|
|
339
|
+
if (proc?.errorCode === 'ETIMEDOUT') {
|
|
340
|
+
logger?.warn?.('maoying 创作台: 全局 CLI 更新失败(timeout): ' + String(proc?.error ?? ''))
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: `npm i -g 超时(${Math.round(globalTimeoutMs / 1000)} 秒)已中止,全局 CLI 未更新;可手动执行:${manual}`,
|
|
344
|
+
output,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (proc?.error) {
|
|
348
|
+
const error = `npm 启动失败: ${proc.error},全局 CLI 未更新;可手动执行:${manual}`
|
|
349
|
+
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(spawn): ${proc.error}`)
|
|
350
|
+
return { ok: false, error, output }
|
|
113
351
|
}
|
|
352
|
+
if (proc?.code !== 0) {
|
|
353
|
+
const error = `npm i -g 失败(exit ${proc?.code}),全局 CLI 未更新;可手动执行:${manual}`
|
|
354
|
+
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(exit ${proc?.code}): ${output}`)
|
|
355
|
+
return { ok: false, error, output }
|
|
356
|
+
}
|
|
357
|
+
logger?.info?.(`maoying 创作台: 全局 maoying CLI ${g.version} → ${targetVersion}`)
|
|
358
|
+
return { ok: true, from: g.version, to: targetVersion }
|
|
114
359
|
}
|
|
115
360
|
|
|
116
361
|
/** 组装最小注入面:缺省走真实 Node 实现,测试逐项替换。 */
|
|
117
|
-
function normalizeDeps(opts) {
|
|
118
|
-
const env = opts.env ?? process.env
|
|
362
|
+
function normalizeDeps(opts = {}) {
|
|
119
363
|
return {
|
|
120
|
-
env,
|
|
121
|
-
|
|
364
|
+
env: opts.env ?? process.env,
|
|
365
|
+
platform: opts.platform ?? process.platform,
|
|
366
|
+
execPath: opts.execPath ?? process.execPath,
|
|
367
|
+
spawnImpl: opts.spawnImpl ?? defaultSpawn,
|
|
122
368
|
pkgDirImpl: opts.pkgDirImpl ?? defaultPkgDir,
|
|
123
369
|
realpathImpl: opts.realpathImpl ?? ((p) => realpathSync(p)),
|
|
124
370
|
existsImpl: opts.existsImpl ?? ((p) => existsSync(p)),
|
|
125
|
-
|
|
126
|
-
readImpl:
|
|
127
|
-
|
|
128
|
-
((p) => readFile(p, 'utf8')),
|
|
371
|
+
readdirImpl: opts.readdirImpl ?? ((p) => readdirSync(p)),
|
|
372
|
+
readImpl: opts.readImpl ?? ((p) => readFileSync(p, 'utf8')),
|
|
373
|
+
fetchImpl: opts.fetchImpl ?? null,
|
|
129
374
|
logger: opts.logger ?? console,
|
|
130
375
|
timeoutMs:
|
|
131
376
|
typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : SELF_UPDATE_TIMEOUT_MS,
|
|
377
|
+
globalTimeoutMs:
|
|
378
|
+
typeof opts.globalTimeoutMs === 'number' && opts.globalTimeoutMs > 0
|
|
379
|
+
? opts.globalTimeoutMs
|
|
380
|
+
: GLOBAL_CLI_TIMEOUT_MS,
|
|
132
381
|
}
|
|
133
382
|
}
|
|
134
383
|
|
|
135
384
|
/**
|
|
136
385
|
* selfUpdate 主体(永不 reject;concurrency 去重与兜底 try/catch 在外层)。
|
|
137
386
|
* @returns {Promise<{ok: boolean, kind: string, from?: string|null, to?: string|null,
|
|
138
|
-
* restartRequired?: boolean, message?: string
|
|
387
|
+
* restartRequired?: boolean, message?: string,
|
|
388
|
+
* globalCli?: {ok: true, skipped: 'missing'} | {ok: true, skipped: 'same', version: string} |
|
|
389
|
+
* {ok: true, from: string, to: string} | {ok: false, error: string, output?: string}}>}
|
|
390
|
+
* globalCli 仅两个成功路径携带,形状见文件头注「全局 CLI 顺带更新」节。
|
|
139
391
|
*/
|
|
140
392
|
async function runSelfUpdate(opts = {}) {
|
|
141
|
-
const {
|
|
142
|
-
|
|
393
|
+
const {
|
|
394
|
+
env, platform, execPath, spawnImpl, pkgDirImpl, realpathImpl, existsImpl, readdirImpl, readImpl,
|
|
395
|
+
fetchImpl, logger, timeoutMs, globalTimeoutMs,
|
|
396
|
+
} = normalizeDeps(opts)
|
|
143
397
|
|
|
144
398
|
// ── a) 安装形态判定 ──────────────────────────────────────────────────────
|
|
145
399
|
const pkgDir = pkgDirImpl()
|
|
@@ -169,75 +423,106 @@ async function runSelfUpdate(opts = {}) {
|
|
|
169
423
|
}
|
|
170
424
|
}
|
|
171
425
|
|
|
172
|
-
// ── b)
|
|
173
|
-
|
|
174
|
-
|
|
426
|
+
// ── b) 版本预检:拿不到 latest 绝不做半截升级(宁可不动现有安装)─────────
|
|
427
|
+
// 官方源失败再试 npmmirror 镜像(点「升级」的用户网络本来就在出状况,
|
|
428
|
+
// 多一路快路实打实救回一次升级);仍拿不到如实报 no-target。
|
|
429
|
+
const { version: current } = await currentVersion()
|
|
430
|
+
const reg = await fetchLatestFromRegistry({ fetchImpl, timeoutMs: VERSION_TIMEOUT_MS })
|
|
431
|
+
const mirror = reg.ok ? null : await fetchLatestFromMirror({ fetchImpl, timeoutMs: VERSION_TIMEOUT_MS })
|
|
432
|
+
const manifest = reg.ok ? reg : mirror
|
|
433
|
+
if (!manifest.ok) {
|
|
175
434
|
return {
|
|
176
435
|
ok: false,
|
|
177
|
-
kind: '
|
|
178
|
-
message:
|
|
436
|
+
kind: 'no-target',
|
|
437
|
+
message: `无法确定最新版本:官方源 ${reg.error};镜像 ${manifest.error}`,
|
|
179
438
|
}
|
|
180
439
|
}
|
|
440
|
+
const target = manifest.latest // 形如 v0.2.3
|
|
441
|
+
const targetVersion = target.replace(/^v/, '')
|
|
442
|
+
// 全局 CLI 阶段的依赖包(两个成功路径各跑一次;失败路径整单拒绝、碰都不碰)。
|
|
443
|
+
const globalDeps = { env, platform, execPath, spawnImpl, existsImpl, readdirImpl, realpathImpl, readImpl, logger, globalTimeoutMs }
|
|
444
|
+
if (compareVersions(target, current) <= 0) {
|
|
445
|
+
logger?.info?.(`maoying 创作台: self-update 短路:本地 ${current} 已不落后线上 ${target}`)
|
|
446
|
+
// 方案 B 语义 = 两份同版:profile 已是 target 时全局 CLI 若仍落后,照样更到
|
|
447
|
+
// 同一精确版本(全局没装 / 已同版时阶段内自行跳过,不 spawn)。
|
|
448
|
+
const globalCli = await updateGlobalCliStage({ targetVersion, profileDir }, globalDeps)
|
|
449
|
+
return { ok: true, kind: 'up-to-date', from: current, globalCli }
|
|
450
|
+
}
|
|
181
451
|
|
|
182
|
-
|
|
183
|
-
|
|
452
|
+
// ── c) profile 就绪判定:目录没有 package.json 说明未初始化,诚实失败,
|
|
453
|
+
// 不硬跑(pnpm 会在空目录另起一份 package.json,装到错误的位置)。
|
|
454
|
+
if (!existsImpl(path.join(profileDir, 'package.json'))) {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
kind: 'profile-missing',
|
|
458
|
+
message: `profile 未初始化(${profileDir} 缺 package.json),无法就地升级,请先重启 DeepSeek 或重新安装插件`,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
184
461
|
|
|
185
|
-
// ──
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
462
|
+
// ── d) pnpm 候选链:spawn ENOENT(命令不存在)才换下一路,EACCES / 超时 /
|
|
463
|
+
// 非零退出都是真实运行结果,如实返回不换通道。cwd 就是 profile 目录
|
|
464
|
+
// (pnpm 的操作对象是它),不带 --dir;装精确版本而非 @latest(避开
|
|
465
|
+
// pnpm 新鲜度防护 / 镜像 packument 滞后的静默空转)。
|
|
466
|
+
// win32 的 pnpm/corepack 是 .cmd shim,必须借 shell 拉起(Node 直接
|
|
467
|
+
// spawn .cmd 会 EINVAL,见 CVE-2024-27980 加固),参数保持数组形式。
|
|
468
|
+
const useShell = platform === 'win32'
|
|
469
|
+
const spawnOptions = {
|
|
470
|
+
cwd: profileDir,
|
|
471
|
+
env,
|
|
472
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
473
|
+
shell: useShell,
|
|
474
|
+
}
|
|
475
|
+
const candidates = buildCandidates({ platform, env, execPath, existsImpl, readdirImpl })
|
|
476
|
+
const installArgs = ['add', `dsh-maoying@${targetVersion}`]
|
|
477
|
+
|
|
478
|
+
let proc = null
|
|
479
|
+
for (const cand of candidates) {
|
|
480
|
+
proc = await spawnImpl(cand.command, [...cand.argsPrefix, ...installArgs], spawnOptions, timeoutMs, 'pnpm add')
|
|
481
|
+
if (proc?.errorCode !== 'ENOENT') break
|
|
482
|
+
}
|
|
483
|
+
const output = tailOutput(proc?.output)
|
|
484
|
+
|
|
485
|
+
// 全候选 ENOENT:给可操作的出路,不裸抛 spawn pnpm ENOENT(本修复的病灶)。
|
|
486
|
+
if (proc?.errorCode === 'ENOENT') {
|
|
487
|
+
const tried = candidates.map((c) => c.label).join(' / ')
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
kind: 'pnpm-missing',
|
|
491
|
+
message: `未找到可用的 pnpm(已试 ${tried})。可先在终端执行 corepack enable 并重启 DSH 后重试;或手动升级:cd ${profileDir} && corepack pnpm add dsh-maoying@${targetVersion}`,
|
|
195
492
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
const tailText = `${out.toString('utf8')}\n${errBuf.toString('utf8')}`.trim()
|
|
217
|
-
resolve({
|
|
218
|
-
failed: true,
|
|
219
|
-
kind: 'pnpm-failed',
|
|
220
|
-
message: `pnpm add 失败(${signal != null ? `signal ${signal}` : `exit ${code}`}):${tailText !== '' ? tailText : '无输出'}`,
|
|
221
|
-
})
|
|
493
|
+
}
|
|
494
|
+
if (proc?.errorCode === 'ETIMEDOUT') {
|
|
495
|
+
// 超时:SIGTERM 已发(宽限后 SIGKILL),如实收敛不换通道。
|
|
496
|
+
logger?.warn?.('maoying 创作台: self-update 失败(timeout): ' + String(proc?.error ?? ''))
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
kind: 'timeout',
|
|
500
|
+
message: `pnpm 安装超时(${Math.round(timeoutMs / 1000)} 秒)已中止,请检查网络后重试`,
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (proc?.error) {
|
|
504
|
+
// 其他子进程异常(EACCES 等真实结果,不换通道、不猜原因)。
|
|
505
|
+
logger?.warn?.(`maoying 创作台: self-update 失败(spawn): ${proc.error}`)
|
|
506
|
+
return { ok: false, kind: 'nonzero', message: `pnpm 启动失败: ${proc.error}` }
|
|
507
|
+
}
|
|
508
|
+
if (proc?.code !== 0) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
kind: 'nonzero',
|
|
512
|
+
message: `pnpm add 失败(exit ${proc?.code}):${output !== '' ? output : '无输出'}`,
|
|
222
513
|
}
|
|
223
|
-
child.on?.('exit', onExit)
|
|
224
|
-
child.on?.('error', (err) => {
|
|
225
|
-
clearTimeout(timer)
|
|
226
|
-
resolve({ failed: true, kind: 'spawn-error', message: `pnpm 启动失败: ${err?.message ?? err}` })
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
if (exitInfo.failed) {
|
|
230
|
-
logger?.warn?.(`maoying 创作台: self-update 失败(${exitInfo.kind}): ${exitInfo.message}`)
|
|
231
|
-
return { ok: false, kind: exitInfo.kind, message: exitInfo.message }
|
|
232
514
|
}
|
|
233
515
|
|
|
234
|
-
// ──
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
516
|
+
// ── e) 成功:版本缓存立刻失效(装的是磁盘新包,进程内 1h 缓存已是旧结论)。
|
|
517
|
+
// to 取预检到的精确目标版本(退出码即成败信号,不做磁盘后验——本进程
|
|
518
|
+
// 代码不随升级变化,无从对账)。随后跑全局 CLI 阶段(方案 B:desktop
|
|
519
|
+
// profile 与全局 npm 安装是两份独立拷贝,顺带更到同一精确版本);全局
|
|
520
|
+
// 阶段失败不拖累整体——profile 已装好,重启照常生效,globalCli.ok:false
|
|
521
|
+
// 如实透出。
|
|
238
522
|
invalidateVersionCache()
|
|
239
|
-
logger?.info?.(`maoying 创作台: self-update 完成 ${
|
|
240
|
-
|
|
523
|
+
logger?.info?.(`maoying 创作台: self-update 完成 ${current} → ${targetVersion}(重启 DeepSeek 后生效)`)
|
|
524
|
+
const globalCli = await updateGlobalCliStage({ targetVersion, profileDir }, globalDeps)
|
|
525
|
+
return { ok: true, kind: 'installed', from: current, to: targetVersion, restartRequired: true, globalCli }
|
|
241
526
|
}
|
|
242
527
|
|
|
243
528
|
// in-flight 去重:并发的 selfUpdate(连点按钮 / 双面板)复用同一次安装
|