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.
@@ -1,42 +1,108 @@
1
1
  /**
2
- * 面板内一键升级 —— 直接跑 pnpm 把本包装到当前 DSH profile(创作台面板用)。
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
- * 可行的升级命令是直接 pnpm:`pnpm --dir <profileDir> add dsh-maoying@latest`。
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
- * 请在开发目录 git pull);真路径不匹配 <dshHome>/profiles/<profile>/
12
- * node_modules/dsh-maoying 结构 → 拒绝(unsupported-layout)。dshHome /
13
- * profile 取 env.DSH_HOME / DSH_PROFILE(~ 前缀手动展开),缺失时回退从
14
- * 真路径的 profiles 祖先推导,推导不出同样拒绝。
15
- * 2) pnpm 解析——按候选路径逐个 existsSync + accessSync(X_OK)(PATH 各目录
16
- * 在前,homebrew / pnpm 官方安装位在后),一个都不可执行 → 拒绝
17
- * (pnpm-missing)。绝不用 shell:true,无注入面。
18
- * 3) 超时 180 秒——超时 kill 子进程收敛为 timeout,不挂死面板请求。
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
- * 硬约束:装完后运行中的进程仍是旧代码,必须重启 DeepSeek 才生效——restart-
23
- * Required 恒 true,UI 文案必须明说,不得暗示装完即生效。依赖全部可注入
24
- * (spawnImpl / realpathImpl / existsImpl / readImpl …),对齐 lib/version.mjs
25
- * 的 fetchImpl 模式,方便测试。
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 { accessSync, constants as fsConstants, existsSync, realpathSync } from 'node:fs'
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 { invalidateVersionCache } from './version.mjs'
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
- /** 失败信息里保留的子进程输出尾部字节数(stdout / stderr 各一份)。 */
39
- const OUTPUT_TAIL_BYTES = 2048
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
- * PATH 里 pnpm 之后的候选兜底(macOS 常见安装位;GUI 进程 PATH 常缺 pnpm,
50
- * 宿主进程又不经过 shell,PATH 找不到时按固定路径逐个试)。
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 extraPnpmCandidates(env) {
53
- const home = typeof env.HOME === 'string' && env.HOME.trim() !== '' ? env.HOME.trim() : os.homedir()
54
- return [
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
- if (!existsImpl(candidate)) continue
70
- accessImpl(candidate, fsConstants.X_OK)
71
- return candidate
72
- } catch {
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
- return null
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
- * 读安装目录 package.json 的 version;读不到(缺失 / 解析失败 / 字段异常)
103
- * 返回 null——from 缺失不拦安装(pnpm 退出码才是成败信号),to 缺失照常
104
- * ok:true(version 显示为 null,前端兜底显示)。
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
- async function readPkgVersion(pkgJsonPath, readImpl) {
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
- const raw = await readImpl(pkgJsonPath)
109
- const pkg = JSON.parse(raw)
110
- return typeof pkg?.version === 'string' && pkg.version !== '' ? pkg.version : null
111
- } catch {
112
- return null
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
- spawnImpl: opts.spawnImpl ?? nodeSpawn,
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
- accessImpl: opts.accessImpl ?? ((p, mode) => accessSync(p, mode)),
126
- readImpl:
127
- opts.readImpl ??
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 { spawnImpl, pkgDirImpl, realpathImpl, existsImpl, accessImpl, readImpl, env, logger, timeoutMs } =
142
- normalizeDeps(opts)
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) 解析 pnpm ────────────────────────────────────────────────────────
173
- const pnpmPath = resolvePnpm(env, { existsImpl, accessImpl })
174
- if (!pnpmPath) {
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: 'pnpm-missing',
178
- message: '未找到 pnpm 可执行文件(PATH 与常见安装路径都没有),请先安装 pnpm 后重试',
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
- const pkgJsonPath = path.join(realDir, 'package.json')
183
- const from = await readPkgVersion(pkgJsonPath, readImpl)
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
- // ── c) 执行安装(绝不用 shell:true,无注入面)────────────────────────────
186
- const exitInfo = await new Promise((resolve) => {
187
- let child
188
- try {
189
- child = spawnImpl(pnpmPath, ['--dir', profileDir, 'add', 'dsh-maoying@latest'], {
190
- stdio: ['ignore', 'pipe', 'pipe'],
191
- })
192
- } catch (err) {
193
- resolve({ failed: true, kind: 'spawn-error', message: `pnpm 启动失败: ${err?.message ?? err}` })
194
- return
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
- let out = Buffer.alloc(0)
197
- let errBuf = Buffer.alloc(0)
198
- const keepTail = (buf, chunk) => Buffer.concat([buf, Buffer.from(chunk)]).subarray(-OUTPUT_TAIL_BYTES)
199
- child.stdout?.on?.('data', (chunk) => { out = keepTail(out, chunk) })
200
- child.stderr?.on?.('data', (chunk) => { errBuf = keepTail(errBuf, chunk) })
201
- // 定时器只在进程仍存活时触发;exit 先到则清掉,不留挂起句柄。
202
- const timer = setTimeout(() => {
203
- try { child.kill() } catch { /* 已退出:忽略 */ }
204
- resolve({
205
- failed: true,
206
- kind: 'timeout',
207
- message: `pnpm 安装超时(${Math.round(timeoutMs / 1000)} 秒)已中止,请检查网络后重试`,
208
- })
209
- }, timeoutMs)
210
- const onExit = (code, signal) => {
211
- clearTimeout(timer)
212
- if (code === 0) {
213
- resolve({ failed: false })
214
- return
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
- // ── d) 成功后重读新版本 + 版本缓存失效 ───────────────────────────────────
235
- const to = await readPkgVersion(pkgJsonPath, readImpl)
236
- // 装的是磁盘上的新包,进程内 update-check 的 1h 缓存已是旧结论,立刻失效,
237
- // 下次打开面板(重启后)按磁盘新版本重算 updateAvailable。
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 完成 ${from ?? '?'} → ${to ?? '?'}(重启 DeepSeek 后生效)`)
240
- return { ok: true, kind: 'installed', from, to, restartRequired: true }
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(连点按钮 / 双面板)复用同一次安装
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-maoying",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",