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.
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/creator-panel.md +19 -9
- package/docs/design.md +2 -2
- package/index.mjs +56 -2
- package/lib/base.css +45 -2
- package/lib/client.js +338 -48
- package/lib/novel-parse.mjs +6 -2
- package/lib/panel-core.mjs +135 -10
- package/lib/self-update.mjs +263 -0
- package/lib/version.mjs +11 -0
- package/package.json +1 -1
package/lib/novel-parse.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
713
|
-
|
|
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
|
|
package/lib/panel-core.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
728
|
-
|
|
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)内则原样复用
|