dsh-maoying 0.1.13 → 0.1.15

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.
@@ -709,8 +709,12 @@ const FORESHADOW_STATUSES = ['planted', 'progressing', 'resolved', 'abandoned']
709
709
  /** project.yml 的共创模式枚举(五期 §9.1)。 */
710
710
  const PROJECT_MODES = ['ai-draft', 'co-write', 'author-draft']
711
711
 
712
- /** 章节 frontmatter 的 volume 规范形态:v + 两位卷号。 */
713
- function canonicalVolume(vol) {
712
+ /**
713
+ * 章节 frontmatter 的 volume 规范形态:v + 两位卷号。面板从章纲建章
714
+ * (panel-core 的 createChapterFromOutline)与本文件 check / collectChapters
715
+ * 共用同一规范形态。
716
+ */
717
+ export function canonicalVolume(vol) {
714
718
  return `v${String(vol).padStart(2, '0')}`
715
719
  }
716
720
 
@@ -11,13 +11,20 @@
11
11
  * (五期起含 outlines 章纲清单)
12
12
  * readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
13
13
  * outline 字段)
14
+ * readOutlineFile(novelDir, file) 章纲全文(章纲弹层用;0.1.15 起按单
15
+ * 写作的未建章章纲可看全文)
16
+ * projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
17
+ * project_hash / 保存旧版检测共用基线)
14
18
  *
15
- * 写入面(全部「读原文 → 行级字符串手术 → 写后校验替换计数 === 1」;
16
- * 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小)
19
+ * 写入面(行级原地替换三件套:读原文 → 手术 → 写后校验替换计数 === 1;
20
+ * 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小。
21
+ * createChapterFromOutline 是唯一例外——它从章纲落一个全新章节文件,不动
22
+ * 任何既有文件的字节)
17
23
  * mutateChapterStatus 章节 frontmatter 的 status 行
18
24
  * mutateForeshadowStatus foreshadows.yml 对应 id 条目块内的 status 行
19
25
  * mutatePhase project.yml 的 phase 行
20
26
  * rebuildIndex 复用 lib/novel-parse 的 buildIndexYaml 重建
27
+ * createChapterFromOutline 从章纲建章(chapters/ 落 pending 空章,0.1.15)
21
28
  *
22
29
  * 错误约定:本模块抛 PanelError(带 statusCode),路由层按 statusCode 回
23
30
  * JSON(403 拒绝 / 404 找不到 / 409 拒写 / 422 入参不合法);未带状态码的
@@ -28,10 +35,11 @@
28
35
  * 绝不让它炸掉整个快照。
29
36
  */
30
37
 
31
- import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
38
+ import { createHash } from 'node:crypto'
39
+ import { mkdirSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
32
40
  import path from 'node:path'
33
41
 
34
- import { buildIndexYaml, chapterSortKey, collectChapters, countCjk, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
42
+ import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
35
43
  import { loadYamlLib, resolveDshHome } from './yaml-loader.mjs'
36
44
 
37
45
  /** 章节状态机(与 lib/novel-parse 的表同源:只取键)。 */
@@ -397,6 +405,27 @@ function readProject(novelDir, yamlLib, warnings) {
397
405
  }
398
406
  }
399
407
 
408
+ /** sha256 hex(小写):文本 → 指纹。 */
409
+ function sha256Hex(text) {
410
+ return createHash('sha256').update(text, 'utf8').digest('hex')
411
+ }
412
+
413
+ /**
414
+ * project.yml 原文指纹(sha256 hex):面板「人工改动保护」的基线。快照带出
415
+ * project_hash,保存时回传 base_hash 比对——表单载入后文件被外部(作者手改
416
+ * / agent 推进 phase)动过就能查觉,拒绝静默覆盖。读不到/读失败返回 null
417
+ * (与「无 project.yml」同口径,不炸快照)。
418
+ */
419
+ export function projectFileHash(novelDir) {
420
+ let text = null
421
+ try {
422
+ text = readFileSync(path.join(path.resolve(novelDir), 'project.yml'), 'utf8')
423
+ } catch {
424
+ return null
425
+ }
426
+ return sha256Hex(text)
427
+ }
428
+
400
429
  /**
401
430
  * 全量快照(docs/creator-panel.md §3,字段名冻结)。novelDir 为工作区的
402
431
  * novel 目录;project.yml 不在 = 未初始化(found: false,其余字段缺省)。
@@ -409,10 +438,13 @@ export function parseStateSnapshot(novelDir, options = {}) {
409
438
  const warnings = []
410
439
 
411
440
  if (!existsSync(path.join(dir, 'project.yml'))) {
412
- return { ok: true, root, novelDir: dir, found: false, warnings }
441
+ return { ok: true, root, novelDir: dir, found: false, project_hash: null, warnings }
413
442
  }
414
443
 
415
444
  const project = readProject(dir, yamlLib, warnings)
445
+ // 人工改动保护基线:快照顶层带 project.yml 原文指纹,客户端保存时原样回传
446
+ // (project-save 的 base_hash);读不到为 null(不影响快照其余字段)。
447
+ const projectHash = projectFileHash(dir)
416
448
 
417
449
  const chaptersRaw = (() => {
418
450
  try {
@@ -451,6 +483,15 @@ export function parseStateSnapshot(novelDir, options = {}) {
451
483
  markers: strArray(meta.markers),
452
484
  }))
453
485
 
486
+ // 关系端点归一:契约规定 relations.yml 的 a/b 是角色 slug(characters/ 目录名),
487
+ // 而关系图客户端按角色 name(卡 frontmatter name,缺省回落 slug)找节点——不出
488
+ // 快照侧归一,带中文名的角色卡必然端点对不上、一条边都画不出。对得上角色卡的
489
+ // 端点改写成 name;对不上的(已死亡/未建档角色,或历史条目直接写了 name)原样
490
+ // 保留。novel-parse 侧(protagonistCards、relations 校验)仍按 slug 契约,不动。
491
+ const nameBySlug = Object.fromEntries(
492
+ characters.map((c) => [String(c.file ?? '').replace(/\.md$/i, ''), c.name])
493
+ )
494
+
454
495
  const relations = collectArrayYml(dir, 'relations.yml', yamlLib, warnings).map((r) => ({
455
496
  a: strOrNull(r.a),
456
497
  b: strOrNull(r.b),
@@ -459,7 +500,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
459
500
  intensity: intOrNull(r.intensity),
460
501
  started: strOrNull(r.started),
461
502
  ended: strOrNull(r.ended),
462
- }))
503
+ })).map((r) => ({ ...r, a: nameBySlug[r.a] ?? r.a, b: nameBySlug[r.b] ?? r.b }))
463
504
 
464
505
  const foreshadows = collectArrayYml(dir, 'foreshadows.yml', yamlLib, warnings).map((f) => ({
465
506
  id: strOrNull(f.id),
@@ -531,6 +572,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
531
572
  root,
532
573
  novelDir: dir,
533
574
  found: true,
575
+ project_hash: projectHash,
534
576
  project,
535
577
  chapters,
536
578
  characters,
@@ -580,6 +622,74 @@ export function readChapter(novelDir, file, options = {}) {
580
622
  return { ok: true, file: name, frontmatter: meta, body, outline }
581
623
  }
582
624
 
625
+ /**
626
+ * 章纲文件名解析(readOutlineFile / createChapterFromOutline 共用的入口
627
+ * 校验):file 过 safeFileName 后必须 .md 结尾且 stem 合乎 vXX-cXXX 规范
628
+ * (与 collectOutlines 的收录口径一致——不合规范的面板本就不列,路由侧
629
+ * 一律 422 拒掉,不给穿越/探测留口子)。
630
+ */
631
+ function outlineStemOf(file) {
632
+ const name = safeFileName(file, 'file')
633
+ if (!/\.md$/i.test(name)) fail(422, `非章纲文件名(只认 .md):${name}`)
634
+ const stem = name.replace(/\.md$/i, '')
635
+ if (chapterSortKey(stem) === null) fail(422, `非章纲文件名(stem 不合 vXX-cXXX 规范):${name}`)
636
+ return stem
637
+ }
638
+
639
+ /**
640
+ * 章纲全文(0.1.15 起面板「章纲弹层」用):按单写作兼容未建章章纲后,
641
+ * 孤儿章纲也要能看全文。章纲纯 markdown 无 frontmatter,body 即原文;
642
+ * words 为全文 CJK 计数(口径与快照 outlines 一致)。章纲不存在 404。
643
+ */
644
+ export function readOutlineFile(novelDir, file) {
645
+ const stem = outlineStemOf(file)
646
+ const outline = readOutline(novelDir, stem)
647
+ if (outline === null) fail(404, `章纲不存在:outlines/${stem}.md`)
648
+ return { ok: true, file: outline.file, words: outline.words, body: outline.body }
649
+ }
650
+
651
+ /**
652
+ * 章纲标题提取(createChapterFromOutline 用):取章纲正文首条 `#` 标题行
653
+ * 文本,去结尾「章纲」二字(大纲师标题惯用后缀)后 trim;空则「(无标题)」
654
+ * (与 collectChapters 的缺省标题同形)。
655
+ */
656
+ function outlineTitleOf(body) {
657
+ const hit = /^#[ \t]+(.+?)[ \t]*$/m.exec(String(body ?? ''))
658
+ const title = (hit === null ? '' : hit[1]).replace(/章纲$/, '').trim()
659
+ return title !== '' ? title : '(无标题)'
660
+ }
661
+
662
+ /**
663
+ * 从章纲建章(0.1.15,面板「按单写作」的孤儿章纲一键建章):在 chapters/
664
+ * 下落一个 status=pending 的空章节——frontmatter 按 templates/novel/README.md
665
+ * 的章节模板(volume/num/title/status/summary + 五维五键,值待写留空),
666
+ * 正文留空。title 取章纲首条标题行(outlineTitleOf)。目标章节已存在 409
667
+ * (先到先建,绝不覆盖既有正文);章纲不存在 404。写前确保 chapters/ 目录
668
+ * 在(手工项目 / 测试夹具可能还没建目录)。建章后走既有 pending 流转。
669
+ */
670
+ export function createChapterFromOutline(novelDir, file) {
671
+ const stem = outlineStemOf(file)
672
+ const key = chapterSortKey(stem)
673
+ const target = path.join(path.resolve(novelDir), 'chapters', `${stem}.md`)
674
+ if (existsSync(target)) fail(409, `章节已存在:chapters/${stem}.md——无需重复建章`)
675
+ const outline = readOutline(novelDir, stem)
676
+ if (outline === null) fail(404, `章纲不存在:outlines/${stem}.md`)
677
+ const title = outlineTitleOf(outline.body)
678
+ const lines = [
679
+ '---',
680
+ `volume: ${canonicalVolume(key.vol)}`,
681
+ `num: ${key.num}`,
682
+ `title: ${yamlScalar(title)}`,
683
+ 'status: pending',
684
+ `summary: ${yamlScalar('')}`,
685
+ ...FIVE_DIM_KEYS.map((k) => `${k}: ${yamlScalar('')}`),
686
+ '---',
687
+ ]
688
+ mkdirSync(path.dirname(target), { recursive: true })
689
+ writeAtomic(target, `${lines.join('\n')}\n`)
690
+ return { ok: true, file: `${stem}.md`, title }
691
+ }
692
+
583
693
  // ── 写操作:行级原地替换 ────────────────────────────────────────────────────
584
694
 
585
695
  /**
@@ -679,12 +789,19 @@ export function mutatePhase(novelDir, phase) {
679
789
  const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
680
790
 
681
791
  /**
682
- * 小说设置:project.yml 平键批量改写(创作台「小说设置」tab)。逐键做
792
+ * 书稿元信息保存:project.yml 平键批量改写(创作台「概览」的「编辑」态)。
793
+ * 逐键做
683
794
  * 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
684
795
  * 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
685
796
  * title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
797
+ *
798
+ * 人工改动保护:options.baseHash 是面板表单载入时的 project.yml 原文指纹
799
+ * (快照 project_hash)。与写入前现读原文不一致 → 表单基线已过期(作者或
800
+ * agent 在载入后改过文件),409 拒写——静默覆盖会丢人工改动。未传 baseHash
801
+ * 跳过检查(旧调用方兼容)。返回值带写后新指纹 project_hash,客户端 reload
802
+ * 前即可拿到下一轮基线。
686
803
  */
687
- export function mutateProject(novelDir, values) {
804
+ export function mutateProject(novelDir, values, options = {}) {
688
805
  if (values === null || typeof values !== 'object' || Array.isArray(values)) {
689
806
  fail(422, 'values 必须是键值对象')
690
807
  }
@@ -701,6 +818,12 @@ export function mutateProject(novelDir, values) {
701
818
 
702
819
  const target = path.join(novelDir, 'project.yml')
703
820
  const text = readTarget(target, 'project.yml')
821
+ // 旧版检测在读原文之后、落笔之前:指纹不一致就地 409,一个字节都不动。
822
+ // 客户端指纹归一到小写再比(指纹本身 hex,大小写宽容不构成安全口子)。
823
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
824
+ if (baseHash !== '' && baseHash !== sha256Hex(text)) {
825
+ fail(409, 'project.yml 已被外部修改,请刷新面板后基于最新内容重改再存')
826
+ }
704
827
  const lines = text.split(/\r?\n/)
705
828
  const pending = new Map(Object.entries(next))
706
829
  for (let i = 0; i < lines.length; i++) {
@@ -724,8 +847,10 @@ export function mutateProject(novelDir, values) {
724
847
  lines.splice(insertAt, 0, ...missing)
725
848
  }
726
849
  }
727
- writeAtomic(target, joinLines(lines, text))
728
- return { ok: true }
850
+ const nextText = joinLines(lines, text)
851
+ writeAtomic(target, nextText)
852
+ // 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必再等一次快照。
853
+ return { ok: true, project_hash: sha256Hex(nextText) }
729
854
  }
730
855
 
731
856
  /** 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.13",
3
+ "version": "0.1.15",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",