dsh-maoying 0.1.12 → 0.1.14
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 +169 -0
- package/README.md +4 -2
- package/docs/creator-panel.md +13 -6
- package/index.mjs +35 -2
- package/lib/base.css +25 -0
- package/lib/client.js +503 -175
- package/lib/panel-core.mjs +56 -5
- package/lib/self-update.mjs +263 -0
- package/lib/version.mjs +11 -0
- package/package.json +2 -1
package/lib/panel-core.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* (五期起含 outlines 章纲清单)
|
|
12
12
|
* readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
|
|
13
13
|
* outline 字段)
|
|
14
|
+
* projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
|
|
15
|
+
* project_hash / 保存旧版检测共用基线)
|
|
14
16
|
*
|
|
15
17
|
* 写入面(全部「读原文 → 行级字符串手术 → 写后校验替换计数 === 1」;
|
|
16
18
|
* 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小)
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
* 绝不让它炸掉整个快照。
|
|
29
31
|
*/
|
|
30
32
|
|
|
33
|
+
import { createHash } from 'node:crypto'
|
|
31
34
|
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
32
35
|
import path from 'node:path'
|
|
33
36
|
|
|
@@ -397,6 +400,27 @@ function readProject(novelDir, yamlLib, warnings) {
|
|
|
397
400
|
}
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
/** sha256 hex(小写):文本 → 指纹。 */
|
|
404
|
+
function sha256Hex(text) {
|
|
405
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* project.yml 原文指纹(sha256 hex):面板「人工改动保护」的基线。快照带出
|
|
410
|
+
* project_hash,保存时回传 base_hash 比对——表单载入后文件被外部(作者手改
|
|
411
|
+
* / agent 推进 phase)动过就能查觉,拒绝静默覆盖。读不到/读失败返回 null
|
|
412
|
+
* (与「无 project.yml」同口径,不炸快照)。
|
|
413
|
+
*/
|
|
414
|
+
export function projectFileHash(novelDir) {
|
|
415
|
+
let text = null
|
|
416
|
+
try {
|
|
417
|
+
text = readFileSync(path.join(path.resolve(novelDir), 'project.yml'), 'utf8')
|
|
418
|
+
} catch {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
return sha256Hex(text)
|
|
422
|
+
}
|
|
423
|
+
|
|
400
424
|
/**
|
|
401
425
|
* 全量快照(docs/creator-panel.md §3,字段名冻结)。novelDir 为工作区的
|
|
402
426
|
* novel 目录;project.yml 不在 = 未初始化(found: false,其余字段缺省)。
|
|
@@ -409,10 +433,13 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
409
433
|
const warnings = []
|
|
410
434
|
|
|
411
435
|
if (!existsSync(path.join(dir, 'project.yml'))) {
|
|
412
|
-
return { ok: true, root, novelDir: dir, found: false, warnings }
|
|
436
|
+
return { ok: true, root, novelDir: dir, found: false, project_hash: null, warnings }
|
|
413
437
|
}
|
|
414
438
|
|
|
415
439
|
const project = readProject(dir, yamlLib, warnings)
|
|
440
|
+
// 人工改动保护基线:快照顶层带 project.yml 原文指纹,客户端保存时原样回传
|
|
441
|
+
// (project-save 的 base_hash);读不到为 null(不影响快照其余字段)。
|
|
442
|
+
const projectHash = projectFileHash(dir)
|
|
416
443
|
|
|
417
444
|
const chaptersRaw = (() => {
|
|
418
445
|
try {
|
|
@@ -451,6 +478,15 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
451
478
|
markers: strArray(meta.markers),
|
|
452
479
|
}))
|
|
453
480
|
|
|
481
|
+
// 关系端点归一:契约规定 relations.yml 的 a/b 是角色 slug(characters/ 目录名),
|
|
482
|
+
// 而关系图客户端按角色 name(卡 frontmatter name,缺省回落 slug)找节点——不出
|
|
483
|
+
// 快照侧归一,带中文名的角色卡必然端点对不上、一条边都画不出。对得上角色卡的
|
|
484
|
+
// 端点改写成 name;对不上的(已死亡/未建档角色,或历史条目直接写了 name)原样
|
|
485
|
+
// 保留。novel-parse 侧(protagonistCards、relations 校验)仍按 slug 契约,不动。
|
|
486
|
+
const nameBySlug = Object.fromEntries(
|
|
487
|
+
characters.map((c) => [String(c.file ?? '').replace(/\.md$/i, ''), c.name])
|
|
488
|
+
)
|
|
489
|
+
|
|
454
490
|
const relations = collectArrayYml(dir, 'relations.yml', yamlLib, warnings).map((r) => ({
|
|
455
491
|
a: strOrNull(r.a),
|
|
456
492
|
b: strOrNull(r.b),
|
|
@@ -459,7 +495,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
459
495
|
intensity: intOrNull(r.intensity),
|
|
460
496
|
started: strOrNull(r.started),
|
|
461
497
|
ended: strOrNull(r.ended),
|
|
462
|
-
}))
|
|
498
|
+
})).map((r) => ({ ...r, a: nameBySlug[r.a] ?? r.a, b: nameBySlug[r.b] ?? r.b }))
|
|
463
499
|
|
|
464
500
|
const foreshadows = collectArrayYml(dir, 'foreshadows.yml', yamlLib, warnings).map((f) => ({
|
|
465
501
|
id: strOrNull(f.id),
|
|
@@ -531,6 +567,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
531
567
|
root,
|
|
532
568
|
novelDir: dir,
|
|
533
569
|
found: true,
|
|
570
|
+
project_hash: projectHash,
|
|
534
571
|
project,
|
|
535
572
|
chapters,
|
|
536
573
|
characters,
|
|
@@ -683,8 +720,14 @@ const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
|
|
|
683
720
|
* 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
|
|
684
721
|
* 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
|
|
685
722
|
* title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
|
|
723
|
+
*
|
|
724
|
+
* 人工改动保护:options.baseHash 是面板表单载入时的 project.yml 原文指纹
|
|
725
|
+
* (快照 project_hash)。与写入前现读原文不一致 → 表单基线已过期(作者或
|
|
726
|
+
* agent 在载入后改过文件),409 拒写——静默覆盖会丢人工改动。未传 baseHash
|
|
727
|
+
* 跳过检查(旧调用方兼容)。返回值带写后新指纹 project_hash,客户端 reload
|
|
728
|
+
* 前即可拿到下一轮基线。
|
|
686
729
|
*/
|
|
687
|
-
export function mutateProject(novelDir, values) {
|
|
730
|
+
export function mutateProject(novelDir, values, options = {}) {
|
|
688
731
|
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
|
689
732
|
fail(422, 'values 必须是键值对象')
|
|
690
733
|
}
|
|
@@ -701,6 +744,12 @@ export function mutateProject(novelDir, values) {
|
|
|
701
744
|
|
|
702
745
|
const target = path.join(novelDir, 'project.yml')
|
|
703
746
|
const text = readTarget(target, 'project.yml')
|
|
747
|
+
// 旧版检测在读原文之后、落笔之前:指纹不一致就地 409,一个字节都不动。
|
|
748
|
+
// 客户端指纹归一到小写再比(指纹本身 hex,大小写宽容不构成安全口子)。
|
|
749
|
+
const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
|
|
750
|
+
if (baseHash !== '' && baseHash !== sha256Hex(text)) {
|
|
751
|
+
fail(409, 'project.yml 已被外部修改,请刷新面板后基于最新内容重改再存')
|
|
752
|
+
}
|
|
704
753
|
const lines = text.split(/\r?\n/)
|
|
705
754
|
const pending = new Map(Object.entries(next))
|
|
706
755
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -724,8 +773,10 @@ export function mutateProject(novelDir, values) {
|
|
|
724
773
|
lines.splice(insertAt, 0, ...missing)
|
|
725
774
|
}
|
|
726
775
|
}
|
|
727
|
-
|
|
728
|
-
|
|
776
|
+
const nextText = joinLines(lines, text)
|
|
777
|
+
writeAtomic(target, nextText)
|
|
778
|
+
// 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必再等一次快照。
|
|
779
|
+
return { ok: true, project_hash: sha256Hex(nextText) }
|
|
729
780
|
}
|
|
730
781
|
|
|
731
782
|
/** YAML 平标量:安全字符裸写,含特殊字符(冒号空格/井号/引号/首尾空格等)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板内一键升级 —— 直接跑 pnpm 把本包装到当前 DSH profile(创作台面板用)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:desktop 档的真实安装形态是 <dshHome>/profiles/<profile>/node_modules/
|
|
5
|
+
* dsh-maoying(真实目录,非 symlink),宿主 dsh CLI 拒绝管理 desktop 档,唯一
|
|
6
|
+
* 可行的升级命令是直接 pnpm:`pnpm --dir <profileDir> add dsh-maoying@latest`。
|
|
7
|
+
*
|
|
8
|
+
* 安全边界(缺一不可,全不满足绝不执行安装):
|
|
9
|
+
* 1) 安装形态判定——插件包自身目录先 realpathSync 取真路径,真路径 ≠ 常规
|
|
10
|
+
* 路径即 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,不挂死面板请求。
|
|
19
|
+
*
|
|
20
|
+
* 所有失败路径都不抛错,一律收敛为 { 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 模式,方便测试。
|
|
26
|
+
*/
|
|
27
|
+
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'
|
|
30
|
+
import os from 'node:os'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
|
|
34
|
+
import { invalidateVersionCache } from './version.mjs'
|
|
35
|
+
|
|
36
|
+
/** 安装超时:pnpm 冷缓存 + 国内网络最坏留足 3 分钟。 */
|
|
37
|
+
export const SELF_UPDATE_TIMEOUT_MS = 180 * 1000
|
|
38
|
+
/** 失败信息里保留的子进程输出尾部字节数(stdout / stderr 各一份)。 */
|
|
39
|
+
const OUTPUT_TAIL_BYTES = 2048
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 插件包自身目录(package.json 所在目录)。抽成函数便于测试注入假安装位。
|
|
43
|
+
*/
|
|
44
|
+
function defaultPkgDir() {
|
|
45
|
+
return path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* PATH 里 pnpm 之后的候选兜底(macOS 常见安装位;GUI 进程 PATH 常缺 pnpm,
|
|
50
|
+
* 宿主进程又不经过 shell,PATH 找不到时按固定路径逐个试)。
|
|
51
|
+
*/
|
|
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) {
|
|
68
|
+
try {
|
|
69
|
+
if (!existsImpl(candidate)) continue
|
|
70
|
+
accessImpl(candidate, fsConstants.X_OK)
|
|
71
|
+
return candidate
|
|
72
|
+
} catch {
|
|
73
|
+
/* 存在但不可执行(或探测异常):换下一个候选 */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从真路径的 profiles 祖先推导 { home, profile }:真路径必须形如
|
|
81
|
+
* <dshHome>/profiles/<profile>/node_modules/dsh-maoying,其余形态返回 null。
|
|
82
|
+
*/
|
|
83
|
+
function deriveLayoutFromPath(realDir) {
|
|
84
|
+
const pkgName = path.basename(realDir)
|
|
85
|
+
const nmDir = path.dirname(realDir)
|
|
86
|
+
if (pkgName !== 'dsh-maoying' || path.basename(nmDir) !== 'node_modules') return null
|
|
87
|
+
const profile = path.basename(path.dirname(nmDir))
|
|
88
|
+
const profilesDir = path.dirname(path.dirname(nmDir))
|
|
89
|
+
if (path.basename(profilesDir) !== 'profiles') return null
|
|
90
|
+
return { home: path.dirname(profilesDir), profile }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** env 里的 dsh home(~ 前缀手动展开,环境变量不经过 shell 展开)。 */
|
|
94
|
+
function dshHomeFromEnv(env) {
|
|
95
|
+
const raw = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
|
96
|
+
if (raw === '') return null
|
|
97
|
+
if (raw === '~' || raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(1))
|
|
98
|
+
return raw
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 读安装目录 package.json 的 version;读不到(缺失 / 解析失败 / 字段异常)
|
|
103
|
+
* 返回 null——from 缺失不拦安装(pnpm 退出码才是成败信号),to 缺失照常
|
|
104
|
+
* ok:true(version 显示为 null,前端兜底显示)。
|
|
105
|
+
*/
|
|
106
|
+
async function readPkgVersion(pkgJsonPath, readImpl) {
|
|
107
|
+
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
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 组装最小注入面:缺省走真实 Node 实现,测试逐项替换。 */
|
|
117
|
+
function normalizeDeps(opts) {
|
|
118
|
+
const env = opts.env ?? process.env
|
|
119
|
+
return {
|
|
120
|
+
env,
|
|
121
|
+
spawnImpl: opts.spawnImpl ?? nodeSpawn,
|
|
122
|
+
pkgDirImpl: opts.pkgDirImpl ?? defaultPkgDir,
|
|
123
|
+
realpathImpl: opts.realpathImpl ?? ((p) => realpathSync(p)),
|
|
124
|
+
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')),
|
|
129
|
+
logger: opts.logger ?? console,
|
|
130
|
+
timeoutMs:
|
|
131
|
+
typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : SELF_UPDATE_TIMEOUT_MS,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* selfUpdate 主体(永不 reject;concurrency 去重与兜底 try/catch 在外层)。
|
|
137
|
+
* @returns {Promise<{ok: boolean, kind: string, from?: string|null, to?: string|null,
|
|
138
|
+
* restartRequired?: boolean, message?: string}>}
|
|
139
|
+
*/
|
|
140
|
+
async function runSelfUpdate(opts = {}) {
|
|
141
|
+
const { spawnImpl, pkgDirImpl, realpathImpl, existsImpl, accessImpl, readImpl, env, logger, timeoutMs } =
|
|
142
|
+
normalizeDeps(opts)
|
|
143
|
+
|
|
144
|
+
// ── a) 安装形态判定 ──────────────────────────────────────────────────────
|
|
145
|
+
const pkgDir = pkgDirImpl()
|
|
146
|
+
let realDir
|
|
147
|
+
try {
|
|
148
|
+
realDir = path.resolve(realpathImpl(pkgDir))
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { ok: false, kind: 'unsupported-layout', message: `安装目录读取失败: ${err?.message ?? err}` }
|
|
151
|
+
}
|
|
152
|
+
if (realDir !== path.resolve(pkgDir)) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
kind: 'dev-link',
|
|
156
|
+
message: '当前安装指向开发目录(symlink/link 安装),请到开发目录 git pull 更新,这里不代装',
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const derived = deriveLayoutFromPath(realDir)
|
|
160
|
+
const home = dshHomeFromEnv(env) ?? derived?.home ?? null
|
|
161
|
+
const profileRaw = typeof env.DSH_PROFILE === 'string' ? env.DSH_PROFILE.trim() : ''
|
|
162
|
+
const profile = profileRaw !== '' ? profileRaw : derived?.profile ?? null
|
|
163
|
+
const profileDir = home && profile ? path.join(home, 'profiles', profile) : null
|
|
164
|
+
if (!derived || !profileDir || realDir !== path.resolve(path.join(profileDir, 'node_modules', 'dsh-maoying'))) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
kind: 'unsupported-layout',
|
|
168
|
+
message: '当前安装形态不受面板内升级支持(不是 <dshHome>/profiles/<profile>/node_modules/dsh-maoying 目录安装),请用命令行升级',
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── b) 解析 pnpm ────────────────────────────────────────────────────────
|
|
173
|
+
const pnpmPath = resolvePnpm(env, { existsImpl, accessImpl })
|
|
174
|
+
if (!pnpmPath) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
kind: 'pnpm-missing',
|
|
178
|
+
message: '未找到 pnpm 可执行文件(PATH 与常见安装路径都没有),请先安装 pnpm 后重试',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pkgJsonPath = path.join(realDir, 'package.json')
|
|
183
|
+
const from = await readPkgVersion(pkgJsonPath, readImpl)
|
|
184
|
+
|
|
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
|
|
195
|
+
}
|
|
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
|
+
})
|
|
222
|
+
}
|
|
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
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── d) 成功后重读新版本 + 版本缓存失效 ───────────────────────────────────
|
|
235
|
+
const to = await readPkgVersion(pkgJsonPath, readImpl)
|
|
236
|
+
// 装的是磁盘上的新包,进程内 update-check 的 1h 缓存已是旧结论,立刻失效,
|
|
237
|
+
// 下次打开面板(重启后)按磁盘新版本重算 updateAvailable。
|
|
238
|
+
invalidateVersionCache()
|
|
239
|
+
logger?.info?.(`maoying 创作台: self-update 完成 ${from ?? '?'} → ${to ?? '?'}(重启 DeepSeek 后生效)`)
|
|
240
|
+
return { ok: true, kind: 'installed', from, to, restartRequired: true }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// in-flight 去重:并发的 selfUpdate(连点按钮 / 双面板)复用同一次安装
|
|
244
|
+
// promise,pnpm add 绝不并发跑两份;promise 落定即清空。resetSelfUpdateInflight
|
|
245
|
+
// 仅供测试隔离模块级状态。
|
|
246
|
+
let inflight = null
|
|
247
|
+
|
|
248
|
+
/** 测试专用:清空模块级 in-flight 状态(生产勿用)。 */
|
|
249
|
+
export function resetSelfUpdateInflight() {
|
|
250
|
+
inflight = null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 面板一键升级入口(并发去重)。
|
|
255
|
+
* @returns {Promise<{ok: boolean, kind: string, ...}>} 永不 reject
|
|
256
|
+
*/
|
|
257
|
+
export async function selfUpdate(opts = {}) {
|
|
258
|
+
if (inflight) return inflight
|
|
259
|
+
inflight = runSelfUpdate(opts).finally(() => {
|
|
260
|
+
inflight = null
|
|
261
|
+
})
|
|
262
|
+
return inflight
|
|
263
|
+
}
|
package/lib/version.mjs
CHANGED
|
@@ -310,6 +310,17 @@ async function detectLatest({ fetchImpl, timeoutMs, nowMs }) {
|
|
|
310
310
|
// 注意并发窗口内各调用共享第一次调用注入的 now 语义(checkedAt 取先到者)。
|
|
311
311
|
let inflight = null
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* 版本缓存失效(面板一键升级专用,lib/self-update.mjs 调用):pnpm 把磁盘
|
|
315
|
+
* 上的包装到新版本后,进程内这份缓存(含 1h TTL 与 7 天 lastGood 窗口里的
|
|
316
|
+
* 旧结论)整体作废——下次 getVersionInfo 按磁盘新版本重算 updateAvailable。
|
|
317
|
+
* 只重置缓存与 in-flight,不改任何检测行为。
|
|
318
|
+
*/
|
|
319
|
+
export function invalidateVersionCache() {
|
|
320
|
+
cache = { at: 0, info: null }
|
|
321
|
+
inflight = null
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
/**
|
|
314
325
|
* 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
|
|
315
326
|
* 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-maoying",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"docs/",
|
|
37
37
|
"icon.svg",
|
|
38
38
|
"README.md",
|
|
39
|
+
"README.en.md",
|
|
39
40
|
"LICENSE",
|
|
40
41
|
"cordis.patch.yml"
|
|
41
42
|
],
|