dsh-maoying 0.1.20 → 0.1.21
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 +4 -4
- package/README.md +2 -2
- package/docs/creator-panel.md +14 -8
- package/docs/design.md +8 -8
- package/docs/spec.md +2 -2
- package/index.mjs +126 -29
- package/lib/base.css +16 -4
- package/lib/client.js +458 -78
- package/lib/epub.mjs +6 -0
- package/lib/novel-parse.mjs +103 -19
- package/lib/panel-core.mjs +16 -7
- package/lib/self-update.mjs +118 -21
- package/lib/version.mjs +30 -2
- package/package.json +2 -1
- package/presets/maoying/agent.cordis.v2.yml +5 -0
- package/presets/maoying/agent.cordis.yml +9 -0
- package/presets/maoying/restrict.mjs +16 -3
- package/scripts/smoke-client.mjs +128 -0
package/lib/epub.mjs
CHANGED
|
@@ -121,9 +121,15 @@ export function buildZip(entries) {
|
|
|
121
121
|
* XML 文本转义:& 必须先转(否则把刚生成的 & 又转一遍),再 < > 与引号。
|
|
122
122
|
* 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII 名不走它,引号
|
|
123
123
|
* 转义属防御纵深(将来属性值万一改走用户输入也不破 XML)。
|
|
124
|
+
*
|
|
125
|
+
* L8:XML 1.0 非法控制字符(\x00-\x08、\x0B、\x0C、\x0E-\x1F;\t \n \r 合法
|
|
126
|
+
* 保留)先替换为 U+FFFD 再转义——混入即产非法 XHTML,阅读器可能拒载整本。
|
|
127
|
+
* 选替换不选剥离:脏字符多半是复制粘贴事故,U+FFFD 给作者与阅读器留下
|
|
128
|
+
* 「这里有脏字符」的可见痕迹,静默剥除会无声丢字。
|
|
124
129
|
*/
|
|
125
130
|
export function escapeXml(text) {
|
|
126
131
|
return String(text ?? '')
|
|
132
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '\uFFFD')
|
|
127
133
|
.replace(/&/g, '&')
|
|
128
134
|
.replace(/</g, '<')
|
|
129
135
|
.replace(/>/g, '>')
|
package/lib/novel-parse.mjs
CHANGED
|
@@ -106,15 +106,21 @@ export function parseScalarText(raw) {
|
|
|
106
106
|
* - 顶层数组(volumes.yml / foreshadows.yml 等):`- key: value` 起条目,
|
|
107
107
|
* 缩进行补键;裸 `- 标量` 收作字符串条目。
|
|
108
108
|
* 深嵌套不在支持范围(数据规范本身就禁止)。解析不了返回 null。
|
|
109
|
+
*
|
|
110
|
+
* H2:onWarn(可选回调)接收「认不出但没炸」的行告警——目前只有数组分支的
|
|
111
|
+
* 零缩进散键行一种;调用方(panel-core collectArrayYml / checkProject /
|
|
112
|
+
* buildBrief)把消息各自挂上文件名再入自家告警通道,散键不再静默蒸发。
|
|
109
113
|
*/
|
|
110
|
-
export function parseFlatYamlArray(text) {
|
|
114
|
+
export function parseFlatYamlArray(text, onWarn = null) {
|
|
111
115
|
if (text === null) return null
|
|
112
116
|
const lines = []
|
|
113
|
-
|
|
117
|
+
const lineNos = [] // 源行号随行登记:散键告警要能定位到文件行
|
|
118
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
114
119
|
const line = stripInlineComment(raw).replace(/\s+$/, '')
|
|
115
|
-
if (line.trim() === '' || line.trim().startsWith('#'))
|
|
120
|
+
if (line.trim() === '' || line.trim().startsWith('#')) return
|
|
116
121
|
lines.push(line)
|
|
117
|
-
|
|
122
|
+
lineNos.push(i + 1)
|
|
123
|
+
})
|
|
118
124
|
if (lines.length === 0) return null
|
|
119
125
|
const first = lines[0].trim()
|
|
120
126
|
if (first === '[]') return []
|
|
@@ -126,7 +132,8 @@ export function parseFlatYamlArray(text) {
|
|
|
126
132
|
if (current !== null) arr.push(current)
|
|
127
133
|
current = null
|
|
128
134
|
}
|
|
129
|
-
for (
|
|
135
|
+
for (let li = 0; li < lines.length; li++) {
|
|
136
|
+
const line = lines[li]
|
|
130
137
|
const trimmed = line.trim()
|
|
131
138
|
if (trimmed.startsWith('- ')) {
|
|
132
139
|
flush()
|
|
@@ -141,6 +148,11 @@ export function parseFlatYamlArray(text) {
|
|
|
141
148
|
} else if (trimmed === '-') {
|
|
142
149
|
flush()
|
|
143
150
|
current = {}
|
|
151
|
+
} else if (!/^[ \t]/.test(line)) {
|
|
152
|
+
// H2:数组形态里零缩进、非「-」起头的散键行不属于任何条目——旧实现会
|
|
153
|
+
// 静默并入前一条目,手改残留键能覆盖前条 status 且零告警。丢弃并告警
|
|
154
|
+
// (顶层纯键值不经过本分支——那是合法映射形态)。
|
|
155
|
+
if (onWarn !== null) onWarn(`第 ${lineNos[li]} 行零缩进散键「${trimmed}」不属于任何条目,已丢弃`)
|
|
144
156
|
} else if (current !== null) {
|
|
145
157
|
const m = /^([^:]+):\s*(.*)$/.exec(trimmed)
|
|
146
158
|
if (m !== null) current[m[1].trim()] = parseScalarText(m[2])
|
|
@@ -215,7 +227,9 @@ export function chapterSortKey(stem) {
|
|
|
215
227
|
function listMdFiles(dir) {
|
|
216
228
|
if (!existsSync(dir)) return []
|
|
217
229
|
return readdirSync(dir)
|
|
218
|
-
|
|
230
|
+
// M10:.md 大小写不敏感(/\.md$/i)——panel-core 的 collectOutlines /
|
|
231
|
+
// collectMdEntities 同口径收录 .MD,CLI 侧不得把同一批文件漏在门外
|
|
232
|
+
.filter((f) => /\.md$/i.test(f))
|
|
219
233
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
220
234
|
}
|
|
221
235
|
|
|
@@ -317,6 +331,9 @@ export function buildIndexYaml(chapters, volumesRaw) {
|
|
|
317
331
|
if (volumes.length === 0) lines.push(' []')
|
|
318
332
|
for (const volume of volumes) {
|
|
319
333
|
const id = String(volume.id ?? '')
|
|
334
|
+
// M8:重复 id 不在此处去重——index 重建是确定性纯函数、静默择一反而把数据
|
|
335
|
+
// 问题藏进产物;重复 id 由 checkProject 报 error 兜底,源头修 volumes.yml
|
|
336
|
+
// (claimed 用 Set,同批章节重复入卷只影响卷内字数之和,orphans 不受影响)。
|
|
320
337
|
const members = chapters.filter((ch) => ch.volume === id)
|
|
321
338
|
for (const ch of members) claimed.add(ch.stem)
|
|
322
339
|
lines.push(` - id: ${flowScalar(id || '(缺 id)')}`)
|
|
@@ -451,7 +468,8 @@ function collectCharacterCards(novelDir) {
|
|
|
451
468
|
if (!existsSync(dir)) return []
|
|
452
469
|
const cards = []
|
|
453
470
|
for (const file of readdirSync(dir)) {
|
|
454
|
-
|
|
471
|
+
// M10:口径与 listMdFiles 一致——.MD 大小写不敏感(面板实体卡目录收录同款文件)
|
|
472
|
+
if (!/\.md$/i.test(file)) continue
|
|
455
473
|
const text = readTextOrNull(path.join(dir, file))
|
|
456
474
|
if (text === null) continue
|
|
457
475
|
const { meta, body, raw } = parseChapterFile(text)
|
|
@@ -462,9 +480,11 @@ function collectCharacterCards(novelDir) {
|
|
|
462
480
|
}
|
|
463
481
|
|
|
464
482
|
/** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。
|
|
465
|
-
* `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。
|
|
466
|
-
|
|
467
|
-
|
|
483
|
+
* `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。
|
|
484
|
+
* onWarn(H2)透传给 parseFlatYamlArray 收散键丢弃告警,消息不含文件名,
|
|
485
|
+
* 由调用方按需挂(checkProject 入 problems、buildBrief 入 warnings)。 */
|
|
486
|
+
function readYamlArray(novelDir, name, onWarn = null) {
|
|
487
|
+
const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)), onWarn)
|
|
468
488
|
if (!Array.isArray(raw)) return []
|
|
469
489
|
return raw.filter(
|
|
470
490
|
(item) => item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0,
|
|
@@ -606,8 +626,10 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
|
|
|
606
626
|
}
|
|
607
627
|
const idx = chapters.findIndex((c) => c.stem === target.stem)
|
|
608
628
|
|
|
609
|
-
// 卷名:volumes.yml 的 title,缺了退「卷N」/卷 id
|
|
610
|
-
const volumesRaw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, 'volumes.yml')))
|
|
629
|
+
// 卷名:volumes.yml 的 title,缺了退「卷N」/卷 id(H2:散键告警挂文件名入 warnings)
|
|
630
|
+
const volumesRaw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, 'volumes.yml')), (w) =>
|
|
631
|
+
warnings.push(`volumes.yml: ${w}`),
|
|
632
|
+
)
|
|
611
633
|
const volTitles = new Map()
|
|
612
634
|
if (Array.isArray(volumesRaw)) {
|
|
613
635
|
for (const v of volumesRaw) {
|
|
@@ -661,7 +683,7 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
|
|
|
661
683
|
// 出场角色卡:有章纲按章纲选人(卡名/ slug 命中 + 「出场角色:」行点名);
|
|
662
684
|
// 章纲缺失时回退「前章出场角色 + 主角」(主角判定见 protagonistCards 注释)
|
|
663
685
|
const cards = collectCharacterCards(novelDir)
|
|
664
|
-
const relations = readYamlArray(novelDir, 'relations.yml')
|
|
686
|
+
const relations = readYamlArray(novelDir, 'relations.yml', (w) => warnings.push(`relations.yml: ${w}`))
|
|
665
687
|
let roleCards = []
|
|
666
688
|
let missingNames = []
|
|
667
689
|
if (outline !== null) {
|
|
@@ -690,7 +712,7 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
|
|
|
690
712
|
// 活跃伏笔:status ∈ planted/progressing 的条目照录
|
|
691
713
|
const hasForeshadowsFile = existsSync(path.join(novelDir, 'foreshadows.yml'))
|
|
692
714
|
if (!hasForeshadowsFile) warnings.push('未找到 foreshadows.yml,活跃伏笔一节按空处理')
|
|
693
|
-
const activeForeshadows = readYamlArray(novelDir, 'foreshadows.yml').filter(
|
|
715
|
+
const activeForeshadows = readYamlArray(novelDir, 'foreshadows.yml', (w) => warnings.push(`foreshadows.yml: ${w}`)).filter(
|
|
694
716
|
(f) => f.status === 'planted' || f.status === 'progressing',
|
|
695
717
|
)
|
|
696
718
|
const fsParts = activeForeshadows.map((f) => renderForeshadowBrief(f))
|
|
@@ -724,6 +746,15 @@ const CHAPTER_WRITERS = ['author', 'ai']
|
|
|
724
746
|
/** 伏笔状态机枚举(docs/spec.md)。 */
|
|
725
747
|
const FORESHADOW_STATUSES = ['planted', 'progressing', 'resolved', 'abandoned']
|
|
726
748
|
|
|
749
|
+
/** 伏笔 source 枚举(templates/novel/foreshadows.yml 声明;缺省归 plan 不报)。 */
|
|
750
|
+
const FORESHADOW_SOURCES = ['plan', 'backfill']
|
|
751
|
+
|
|
752
|
+
/** clue-board.yml 的 kind 枚举(templates/novel/clue-board.yml 声明)。 */
|
|
753
|
+
const CLUE_KINDS = ['clue', 'red-herring', 'deduction', 'question']
|
|
754
|
+
|
|
755
|
+
/** memories.yml 的 category 枚举(templates/novel/memories.yml 声明)。 */
|
|
756
|
+
const MEMORY_CATEGORIES = ['character', 'location', 'item', 'event', 'promise', 'appearance', 'arc', 'other']
|
|
757
|
+
|
|
727
758
|
/** project.yml 的共创模式枚举(五期 §9.1)。 */
|
|
728
759
|
const PROJECT_MODES = ['ai-draft', 'co-write', 'author-draft']
|
|
729
760
|
|
|
@@ -890,6 +921,16 @@ export function checkProject(novelDir) {
|
|
|
890
921
|
const problems = []
|
|
891
922
|
const push = (level, file, message) => problems.push({ level, file, message })
|
|
892
923
|
|
|
924
|
+
// H2:顶层数组账本 yml 的极简解析告警(零缩进散键被丢弃等)并入 problems——
|
|
925
|
+
// 与面板快照的 warnings 通道对齐,散键不能再静默蒸发。
|
|
926
|
+
const readAccountYml = (name) => readYamlArray(novelDir, name, (w) => push('warn', name, w))
|
|
927
|
+
// M9:账本章节引用统一校验——空值是「未填」不算悬空,指向未写章节报 error
|
|
928
|
+
// (口径与 foreshadows planted_chapter 的既有引用校验一致)。
|
|
929
|
+
const refCheck = (file, label, field, ref) => {
|
|
930
|
+
if ((typeof ref !== 'string' || ref.trim() === '') && typeof ref !== 'number') return
|
|
931
|
+
if (!chapterRefExists(ref, stems, nums)) push('error', file, `${label}的 ${field} 引用章节不存在:${ref}`)
|
|
932
|
+
}
|
|
933
|
+
|
|
893
934
|
// —— chapters/:frontmatter 完整性、枚举、章号卷号一致 ——
|
|
894
935
|
const chaptersDir = path.join(novelDir, 'chapters')
|
|
895
936
|
const chapterFiles = listMdFiles(chaptersDir)
|
|
@@ -971,13 +1012,18 @@ export function checkProject(novelDir) {
|
|
|
971
1012
|
}
|
|
972
1013
|
|
|
973
1014
|
// —— foreshadows.yml:状态枚举、id 去重、章节引用、状态-字段自洽 ——
|
|
974
|
-
const foreshadows =
|
|
1015
|
+
const foreshadows = readAccountYml('foreshadows.yml')
|
|
975
1016
|
const seenIds = new Map()
|
|
976
1017
|
foreshadows.forEach((f, i) => {
|
|
977
1018
|
const label = typeof f.id === 'string' && f.id !== '' ? f.id : `第 ${i + 1} 条`
|
|
978
1019
|
if (typeof f.status === 'string' && f.status !== '' && !FORESHADOW_STATUSES.includes(f.status)) {
|
|
979
1020
|
push('error', 'foreshadows.yml', `伏笔 ${label} status 越枚举:${f.status}(合法值 ${FORESHADOW_STATUSES.join('|')})`)
|
|
980
1021
|
}
|
|
1022
|
+
// M9:source 枚举(模板声明 plan|backfill)——缺省不报,面板快照侧同样把
|
|
1023
|
+
// 非 backfill 归一成 plan,两侧口径一致
|
|
1024
|
+
if (typeof f.source === 'string' && f.source !== '' && !FORESHADOW_SOURCES.includes(f.source)) {
|
|
1025
|
+
push('error', 'foreshadows.yml', `伏笔 ${label} source 越枚举:${f.source}(合法值 ${FORESHADOW_SOURCES.join('|')})`)
|
|
1026
|
+
}
|
|
981
1027
|
if (typeof f.id === 'string' && f.id !== '') {
|
|
982
1028
|
if (seenIds.has(f.id)) push('error', 'foreshadows.yml', `伏笔 id 重复:${f.id}(第 ${seenIds.get(f.id) + 1} 条与第 ${i + 1} 条)`)
|
|
983
1029
|
else seenIds.set(f.id, i)
|
|
@@ -1039,10 +1085,26 @@ export function checkProject(novelDir) {
|
|
|
1039
1085
|
}
|
|
1040
1086
|
})
|
|
1041
1087
|
|
|
1088
|
+
// —— volumes.yml:id 去重(M8:重复 id 会让同批章节被 filter 重复计入多个
|
|
1089
|
+
// 卷,卷字数之和 ≠ total;写法对齐 foreshadows id 去重)——
|
|
1090
|
+
const volumes = readAccountYml('volumes.yml')
|
|
1091
|
+
const seenVolIds = new Map()
|
|
1092
|
+
volumes.forEach((v, i) => {
|
|
1093
|
+
if (typeof v.id === 'string' && v.id !== '') {
|
|
1094
|
+
if (seenVolIds.has(v.id)) {
|
|
1095
|
+
push('error', 'volumes.yml', `分卷 id 重复:${v.id}(第 ${seenVolIds.get(v.id) + 1} 条与第 ${i + 1} 条)——同 id 卷会把章节重复计入`)
|
|
1096
|
+
} else {
|
|
1097
|
+
seenVolIds.set(v.id, i)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1042
1102
|
// —— timeline.yml:年份可解析、不倒挂(warn:故事内纪年本就允许花写)——
|
|
1043
|
-
const timeline =
|
|
1103
|
+
const timeline = readAccountYml('timeline.yml')
|
|
1044
1104
|
let prevYear = null
|
|
1045
1105
|
timeline.forEach((t, i) => {
|
|
1106
|
+
// M9:chapter 指向已写正文;放在年份解析之前,免得花写年份把引用校验跳过
|
|
1107
|
+
refCheck('timeline.yml', `第 ${i + 1} 条时间线`, 'chapter', t.chapter)
|
|
1046
1108
|
const parsed = parseTimelineYear(t.year)
|
|
1047
1109
|
if (parsed === null) {
|
|
1048
1110
|
push('warn', 'timeline.yml', `第 ${i + 1} 条 year 无法解析为数字:${t.year}`)
|
|
@@ -1054,14 +1116,34 @@ export function checkProject(novelDir) {
|
|
|
1054
1116
|
prevYear = parsed
|
|
1055
1117
|
})
|
|
1056
1118
|
|
|
1119
|
+
// —— memories.yml:category 枚举 + source_chapter 引用(M9 补对称校验,
|
|
1120
|
+
// 枚举以 templates/novel/memories.yml 声明为准)——
|
|
1121
|
+
readAccountYml('memories.yml').forEach((m, i) => {
|
|
1122
|
+
if (typeof m.category === 'string' && m.category !== '' && !MEMORY_CATEGORIES.includes(m.category)) {
|
|
1123
|
+
push('error', 'memories.yml', `第 ${i + 1} 条记忆 category 越枚举:${m.category}(合法值 ${MEMORY_CATEGORIES.join('|')})`)
|
|
1124
|
+
}
|
|
1125
|
+
refCheck('memories.yml', `第 ${i + 1} 条记忆`, 'source_chapter', m.source_chapter)
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
// —— clue-board.yml:kind 枚举 + related_chapter 引用(M9 补对称校验,
|
|
1129
|
+
// 枚举以 templates/novel/clue-board.yml 声明为准)——
|
|
1130
|
+
readAccountYml('clue-board.yml').forEach((c, i) => {
|
|
1131
|
+
if (typeof c.kind === 'string' && c.kind !== '' && !CLUE_KINDS.includes(c.kind)) {
|
|
1132
|
+
push('error', 'clue-board.yml', `第 ${i + 1} 条线索 kind 越枚举:${c.kind}(合法值 ${CLUE_KINDS.join('|')})`)
|
|
1133
|
+
}
|
|
1134
|
+
refCheck('clue-board.yml', `第 ${i + 1} 条线索`, 'related_chapter', c.related_chapter)
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1057
1137
|
// —— relations.yml:a/b 角色有卡(warn:可能是已死亡/未建档角色,故不升 error)——
|
|
1058
1138
|
const cards = collectCharacterCards(novelDir)
|
|
1059
1139
|
const knownRoles = new Set(cards.map((c) => c.slug))
|
|
1060
1140
|
for (const card of cards) {
|
|
1061
1141
|
if (typeof card.meta.name === 'string' && card.meta.name !== '') knownRoles.add(card.meta.name)
|
|
1062
1142
|
}
|
|
1063
|
-
const relations =
|
|
1143
|
+
const relations = readAccountYml('relations.yml')
|
|
1064
1144
|
relations.forEach((r, i) => {
|
|
1145
|
+
// M9:started/ended 指向已写正文(ended 空串是「未结束」,refCheck 视作未填跳过)
|
|
1146
|
+
for (const k of ['started', 'ended']) refCheck('relations.yml', `第 ${i + 1} 条关系`, k, r[k])
|
|
1065
1147
|
for (const side of ['a', 'b']) {
|
|
1066
1148
|
const value = typeof r[side] === 'string' ? r[side].trim() : ''
|
|
1067
1149
|
if (value !== '' && !knownRoles.has(value)) {
|
|
@@ -1119,12 +1201,14 @@ export function checkProject(novelDir) {
|
|
|
1119
1201
|
if (!hasWorldLore) {
|
|
1120
1202
|
push('error', 'world/', 'phase 已过 setting 但 world/ 与 science/ 均无条目(setting → outline 判据:世界观核心条目须落盘)')
|
|
1121
1203
|
}
|
|
1122
|
-
|
|
1204
|
+
// 复用上方已读的 volumes / relations(M8/H2 起 readAccountYml 带告警采集,
|
|
1205
|
+
// 这里重复读会导致同一份文件告警计两次)
|
|
1206
|
+
if (volumes.length === 0) {
|
|
1123
1207
|
push('error', 'volumes.yml', 'phase 已过 setting 但 volumes.yml 为空或缺失(setting → outline 判据:分卷规划须在案)')
|
|
1124
1208
|
}
|
|
1125
1209
|
// 关系网判据(六期补):两张及以上角色卡时 relations.yml 不得为空——
|
|
1126
1210
|
// 空关系网意味着设定阶段漏登关系,关系图与大纲师的戏剧权重都无从谈起。
|
|
1127
|
-
if (cards.length >= 2 &&
|
|
1211
|
+
if (cards.length >= 2 && relations.length === 0) {
|
|
1128
1212
|
push('error', 'relations.yml', 'phase 已过 setting 但 relations.yml 为空(setting → outline 判据:两张及以上角色卡时关系网须登记)')
|
|
1129
1213
|
}
|
|
1130
1214
|
}
|
package/lib/panel-core.mjs
CHANGED
|
@@ -304,7 +304,9 @@ function collectOutlines(novelDir, warnings) {
|
|
|
304
304
|
const dir = path.join(novelDir, 'outlines')
|
|
305
305
|
let names = []
|
|
306
306
|
try {
|
|
307
|
-
|
|
307
|
+
// M10:.md 口径与 novel-parse listMdFiles 一致(/\.md$/i 大小写不敏感),
|
|
308
|
+
// 同一个 .MD 文件不许面板收录、CLI 排除
|
|
309
|
+
names = readdirSync(dir).filter((n) => /\.md$/i.test(n))
|
|
308
310
|
} catch (err) {
|
|
309
311
|
if (err?.code !== 'ENOENT') warnings.push(`outlines: 遍历失败(${err?.code ?? err?.message ?? err})`)
|
|
310
312
|
return []
|
|
@@ -336,7 +338,8 @@ function collectMdEntities(novelDir, sub, yamlLib, warnings) {
|
|
|
336
338
|
const dir = path.join(novelDir, sub)
|
|
337
339
|
let names = []
|
|
338
340
|
try {
|
|
339
|
-
|
|
341
|
+
// M10:与 collectOutlines / novel-parse listMdFiles 同款 /\.md$/i 口径
|
|
342
|
+
names = readdirSync(dir).filter((n) => /\.md$/i.test(n)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
340
343
|
} catch {
|
|
341
344
|
return []
|
|
342
345
|
}
|
|
@@ -372,11 +375,16 @@ function collectArrayYml(novelDir, fileName, yamlLib, warnings) {
|
|
|
372
375
|
if (yamlLib) {
|
|
373
376
|
try {
|
|
374
377
|
data = yamlLib.parse(text)
|
|
375
|
-
} catch {
|
|
376
|
-
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// H1:真解析失败不再静默——先记文件名+一句话错误再降级极简解析,畸形
|
|
380
|
+
// 文件(如未闭合 flow 序列)在面板至少留下「坏过」的痕迹,条目残缺可归因
|
|
381
|
+
warnings.push(`${fileName}: yaml 解析失败(${err?.message ?? err}),已降级极简解析`)
|
|
377
382
|
}
|
|
378
383
|
}
|
|
379
|
-
if (data === null || data === undefined)
|
|
384
|
+
if (data === null || data === undefined) {
|
|
385
|
+
// H2:极简解析丢弃的零缩进散键行(novel-parse 侧 onWarn)挂上文件名并入 warnings
|
|
386
|
+
data = parseFlatYamlArray(text, (w) => warnings.push(`${fileName}: ${w}`))
|
|
387
|
+
}
|
|
380
388
|
if (!Array.isArray(data)) {
|
|
381
389
|
warnings.push(`${fileName}: 顶层不是数组,已按空表处理`)
|
|
382
390
|
return []
|
|
@@ -402,8 +410,9 @@ function readProject(novelDir, yamlLib, warnings) {
|
|
|
402
410
|
try {
|
|
403
411
|
const parsed = yamlLib.parse(text)
|
|
404
412
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed
|
|
405
|
-
} catch {
|
|
406
|
-
|
|
413
|
+
} catch (err) {
|
|
414
|
+
// H1:与 collectArrayYml 同理——真解析失败记 warning 再降级,不静默吞
|
|
415
|
+
warnings.push(`project.yml: yaml 解析失败(${err?.message ?? err}),已降级极简解析`)
|
|
407
416
|
}
|
|
408
417
|
}
|
|
409
418
|
if (data === null) {
|
package/lib/self-update.mjs
CHANGED
|
@@ -30,8 +30,16 @@
|
|
|
30
30
|
* 数组形式;darwin/linux 恒 shell:false,无注入面。
|
|
31
31
|
*
|
|
32
32
|
* 升级前版本预检:fetchLatestFromRegistry(10s)失败再 fetchLatestFromMirror
|
|
33
|
-
* (10s),拿不到 latest 绝不做半截升级;
|
|
34
|
-
*
|
|
33
|
+
* (10s),拿不到 latest 绝不做半截升级;latest 剥 v 后强制 semver 形状校验
|
|
34
|
+
* (X.Y.Z[-预发布],M1)——被控源返回的串携带 shell 元字符时绝不进 spawn 参数
|
|
35
|
+
* (win32 shell:true 下即命令注入),不匹配按 no-target 拒绝。
|
|
36
|
+
* compareVersions(target, current) <= 0 直接短路「已是最新」,不 spawn。装完
|
|
37
|
+
* 失效版本检测缓存。
|
|
38
|
+
*
|
|
39
|
+
* 装后对账(M2):退出码 0 只代表 pnpm/npm 自认装完,半装/坏包要重启才暴露
|
|
40
|
+
* ——装完回读安装位 package.json 的 version 与精确目标对账,读不到或不符一律
|
|
41
|
+
* 收敛 verify-failed(新 kind,走既有 ok:false 分支,client 透传兼容),
|
|
42
|
+
* message 给手动重装出路,绝不静默当成功。
|
|
35
43
|
*
|
|
36
44
|
* 安全边界(缺一不可,全不满足绝不执行安装):
|
|
37
45
|
* 1) 安装形态判定——插件包自身目录先 realpathSync 取真路径,真路径 ≠ 常规
|
|
@@ -55,10 +63,12 @@
|
|
|
55
63
|
* realpath 落在本 profile 目录内(如 profile 自带 .bin/maoying)→ 不是
|
|
56
64
|
* 全局安装,跳过该候选继续找。找不到任何候选 → { found: false }。
|
|
57
65
|
* - 找不到全局安装 → globalCli: { ok: true, skipped: 'missing' },不 spawn;
|
|
58
|
-
* - 全局 version
|
|
59
|
-
* `npm i -g dsh-maoying@<target
|
|
60
|
-
* 候选链与 pnpm 同款 spawn ENOENT
|
|
61
|
-
* /opt/homebrew/bin → /usr/local/bin →
|
|
66
|
+
* - 全局 version 不落后精确目标(含比目标更新,L5:绝不静默降级)→
|
|
67
|
+
* skipped: 'same',不 spawn;仅落后才 spawn `npm i -g dsh-maoying@<target>`
|
|
68
|
+
* (cwd=os.tmpdir()、独立 120s 超时,npm 候选链与 pnpm 同款 spawn ENOENT
|
|
69
|
+
* 探测:裸 npm → execPath 同目录 → /opt/homebrew/bin → /usr/local/bin →
|
|
70
|
+
* /opt/homebrew/opt/node@XX/bin)。装完同样回读全局安装位对账,不符收敛
|
|
71
|
+
* globalCli.ok:false + kind:'verify-failed'(不强求回滚)。
|
|
62
72
|
* - 全局阶段失败(npm 缺失 / 非零 / 超时)**不拖累整体结果**:profile 已装
|
|
63
73
|
* 好,重启照常生效;globalCli.ok:false + 中文 error(含手动出路
|
|
64
74
|
* `npm i -g dsh-maoying@<target>`)如实透出。dev-link / unsupported-layout /
|
|
@@ -85,6 +95,7 @@ import {
|
|
|
85
95
|
fetchLatestFromMirror,
|
|
86
96
|
fetchLatestFromRegistry,
|
|
87
97
|
invalidateVersionCache,
|
|
98
|
+
isValidVersion,
|
|
88
99
|
} from './version.mjs'
|
|
89
100
|
|
|
90
101
|
/** 安装超时:pnpm 冷缓存 + 国内网络最坏留足 3 分钟。 */
|
|
@@ -119,9 +130,11 @@ function defaultPkgDir() {
|
|
|
119
130
|
* spawn 失败透传 err.code(如 'ENOENT',候选链据此换下一路),超时收敛为
|
|
120
131
|
* 'ETIMEDOUT'。timeoutLabel 只用于超时文案标明哪条通道超时。超时先 SIGTERM,
|
|
121
132
|
* TERM_GRACE_MS 后仍不退则 SIGKILL 兜底(pnpm 尊重 SIGTERM;SIGKILL 保证
|
|
122
|
-
* close 事件必然到达,调用方绝不悬挂)。
|
|
133
|
+
* close 事件必然到达,调用方绝不悬挂)。win32(shell:true)下 child 只是
|
|
134
|
+
* cmd.exe 包装壳,SIGTERM 杀不到底层进程,超时改用 taskkill /T /F 按进程树
|
|
135
|
+
* 整棵杀(L12,见 timer 内注释);platform 参数仅供测试注入,缺省进程平台。
|
|
123
136
|
*/
|
|
124
|
-
export function defaultSpawn(command, args, options, timeoutMs, timeoutLabel) {
|
|
137
|
+
export function defaultSpawn(command, args, options, timeoutMs, timeoutLabel, platform = process.platform) {
|
|
125
138
|
return new Promise((resolve) => {
|
|
126
139
|
let child
|
|
127
140
|
try {
|
|
@@ -141,7 +154,21 @@ export function defaultSpawn(command, args, options, timeoutMs, timeoutLabel) {
|
|
|
141
154
|
let timedOut = false
|
|
142
155
|
const timer = setTimeout(() => {
|
|
143
156
|
timedOut = true
|
|
144
|
-
try {
|
|
157
|
+
try {
|
|
158
|
+
if (platform === 'win32' && child.pid != null) {
|
|
159
|
+
// L12:win32(shell:true)下 child 只是 cmd.exe 包装壳,kill 只杀得
|
|
160
|
+
// 掉壳,底层 pnpm/node 变孤儿继续占用网络与文件锁。taskkill /T /F
|
|
161
|
+
// 按 PID 树杀整棵进程(/T 含子孙、/F 强制),壳被杀后 close 事件照
|
|
162
|
+
// 常到达;taskkill 自身启动失败(System32 内置,基本不可能)退回
|
|
163
|
+
// SIGKILL,下方宽限兜底不变。
|
|
164
|
+
const killer = nodeSpawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' })
|
|
165
|
+
killer.on('error', () => { try { child.kill('SIGKILL') } catch { /* 已退出:忽略 */ } })
|
|
166
|
+
} else {
|
|
167
|
+
// 非 win32:先 SIGTERM 给宽限(pnpm 尊重),TERM_GRACE_MS 后仍不退
|
|
168
|
+
// 由下方 SIGKILL 兜底。
|
|
169
|
+
child.kill('SIGTERM')
|
|
170
|
+
}
|
|
171
|
+
} catch { /* 已退出:忽略 */ }
|
|
145
172
|
}, timeoutMs)
|
|
146
173
|
const killTimer = setTimeout(() => {
|
|
147
174
|
if (!timedOut) return
|
|
@@ -229,8 +256,13 @@ function buildCandidates({ platform, env, execPath, existsImpl, readdirImpl }) {
|
|
|
229
256
|
* 全局 maoying CLI 检测(零 spawn,纯文件系统;机制见文件头注「全局 CLI 顺带
|
|
230
257
|
* 更新」节)。bin 候选位 = env.PATH 各目录 + /opt/homebrew/bin + /usr/local/bin
|
|
231
258
|
* (去重保序),找 `maoying`(win32 `maoying.cmd`),existsSync 预筛;命中后
|
|
232
|
-
*
|
|
233
|
-
*
|
|
259
|
+
* 定位包根:win32 且真路径是 .cmd/.exe shim 时(npm/pnpm 的全局 bin 是
|
|
260
|
+
* <prefix>/ 下的普通文件 shim,realpath 原样返回,两级上溯会得到 prefix 的
|
|
261
|
+
* 父目录 → 包根 name 校验必失败 → Windows 恒 skipped:'missing' 静默漏更,
|
|
262
|
+
* 见 M3),prefix 即 dirname(命令路径),包根取 <prefix>/node_modules/dsh-
|
|
263
|
+
* maoying;否则 realpathSync 该 bin 两级上溯得包根(bin/maoying.mjs → 包根)。
|
|
264
|
+
* 包根 package.json 的 name 必须是 dsh-maoying 才认(防怪链接);包根
|
|
265
|
+
* realpath 落在 profileDir 内(或就是它)→ 是 profile 自己的
|
|
234
266
|
* .bin/maoying,跳过该候选继续找。全落空 → { found: false }。
|
|
235
267
|
* 任何一步异常(断链 symlink / 包根不可读 / JSON 坏)都只跳过该候选,不抛错。
|
|
236
268
|
*/
|
|
@@ -250,7 +282,15 @@ function detectGlobalCli({ env, platform, profileDir, existsImpl, realpathImpl,
|
|
|
250
282
|
if (!existsImpl(bin)) continue
|
|
251
283
|
let cli
|
|
252
284
|
try { cli = path.resolve(realpathImpl(bin)) } catch { continue }
|
|
253
|
-
|
|
285
|
+
// M3:win32 的 .cmd/.exe shim 是 <prefix>/ 下的普通文件(realpath 原样
|
|
286
|
+
// 返回),两级上溯得到的是 prefix 的父目录,包根校验必失败;shim 形态下
|
|
287
|
+
// prefix 就是 dirname(命令路径),包根直接取 <prefix>/node_modules/dsh-
|
|
288
|
+
// maoying。darwin/linux 的 bin 是包根 bin/ 下的真入口,两级上溯维持原语义
|
|
289
|
+
//(限 win32 判 shim:posix 上碰巧叫 *.cmd 的文件不按 shim 处理)。
|
|
290
|
+
const isWinShim = platform === 'win32' && /\.(cmd|exe)$/i.test(cli)
|
|
291
|
+
const root = isWinShim
|
|
292
|
+
? path.join(path.dirname(cli), 'node_modules', 'dsh-maoying')
|
|
293
|
+
: path.dirname(path.dirname(cli))
|
|
254
294
|
let manifest
|
|
255
295
|
try { manifest = JSON.parse(readImpl(path.join(root, 'package.json'))) } catch { continue }
|
|
256
296
|
if (manifest?.name !== 'dsh-maoying') continue
|
|
@@ -297,17 +337,28 @@ function buildNpmCandidates({ platform, execPath, existsImpl, readdirImpl }) {
|
|
|
297
337
|
/**
|
|
298
338
|
* 全局 CLI 阶段(profile 阶段落定后跑;失败绝不拖累整体结果,形状见文件头注)。
|
|
299
339
|
* 返回 { ok: true, skipped: 'missing' } / { ok: true, skipped: 'same', version } /
|
|
300
|
-
* { ok: true, from, to: targetVersion } / { ok: false, error, output? }
|
|
340
|
+
* { ok: true, from, to: targetVersion } / { ok: false, error, output?, kind? }
|
|
341
|
+
* (kind 仅装后对账失败时为 'verify-failed')。
|
|
301
342
|
* spawn 走 npm 候选链(仅 ENOENT 换下一路),cwd=os.tmpdir()(npm 全局装与
|
|
302
343
|
* cwd 无关,不该在 profile / 仓库里跑),独立 globalTimeoutMs 超时。
|
|
303
344
|
*/
|
|
304
345
|
async function updateGlobalCliStage({ targetVersion, profileDir }, deps) {
|
|
305
346
|
const { env, platform, execPath, spawnImpl, existsImpl, readdirImpl, realpathImpl, readImpl, logger, globalTimeoutMs } = deps
|
|
347
|
+
// M1:targetVersion 进 spawn 参数(win32 shell:true)前再闸一次形状——上游
|
|
348
|
+
// 已验,这里是双保险,非法串绝不拼参、绝不安装。
|
|
349
|
+
if (!isValidVersion(targetVersion)) {
|
|
350
|
+
const error = `目标版本串非法(${String(targetVersion)}),已拒绝更新全局 CLI;可手动执行:npm i -g dsh-maoying@latest`
|
|
351
|
+
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(bad-target): ${error}`)
|
|
352
|
+
return { ok: false, error }
|
|
353
|
+
}
|
|
306
354
|
const g = detectGlobalCli({ env, platform, profileDir, existsImpl, realpathImpl, readImpl })
|
|
307
355
|
if (!g.found) return { ok: true, skipped: 'missing' }
|
|
308
356
|
// 版本比对('' = 包根 package.json 缺 version,当旧版处理装到精确目标)。
|
|
309
|
-
|
|
310
|
-
|
|
357
|
+
// L5:不落后(>=)即跳过——旧实现只对同版短路,全局比目标还新时会被
|
|
358
|
+
// `npm i -g dsh-maoying@<旧目标>` 静默降级;语义应为「全局仍落后才更到
|
|
359
|
+
// 同版」,绝不降级。
|
|
360
|
+
if (g.version !== '' && compareVersions(g.version, targetVersion) >= 0) {
|
|
361
|
+
logger?.info?.(`maoying 创作台: 全局 maoying CLI ${g.version} 不落后于目标 ${targetVersion},跳过更新`)
|
|
311
362
|
return { ok: true, skipped: 'same', version: g.version }
|
|
312
363
|
}
|
|
313
364
|
|
|
@@ -354,6 +405,20 @@ async function updateGlobalCliStage({ targetVersion, profileDir }, deps) {
|
|
|
354
405
|
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(exit ${proc?.code}): ${output}`)
|
|
355
406
|
return { ok: false, error, output }
|
|
356
407
|
}
|
|
408
|
+
// M2:全局装后对账(思路同 profile,不强求回滚)——npm 退出码 0 只代表
|
|
409
|
+
// npm 自认装完,磁盘半装要重启才暴露。重跑同款零 spawn 检测回读全局安装位
|
|
410
|
+
// package.json:version 与精确目标相符才算成功;重检落空(读不到)或不符
|
|
411
|
+
// 如实报 verify-failed,error 带手动重装出路。整体结果仍由调用方维持
|
|
412
|
+
// ok:true(profile 已装好,全局失败不追责整单)。
|
|
413
|
+
const recheck = detectGlobalCli({ env, platform, profileDir, existsImpl, realpathImpl, readImpl })
|
|
414
|
+
if (!recheck.found || recheck.version !== targetVersion) {
|
|
415
|
+
const detail = recheck.found
|
|
416
|
+
? `npm i -g 后磁盘 version ${recheck.version} ≠ 目标 ${targetVersion}`
|
|
417
|
+
: 'npm i -g 后未在 PATH 检测到全局 maoying 安装'
|
|
418
|
+
const error = `全局 CLI 更新后对账失败(${detail}),请手动重装:npm i -g dsh-maoying@${targetVersion}`
|
|
419
|
+
logger?.warn?.(`maoying 创作台: 全局 CLI 更新失败(verify-failed): ${detail}`)
|
|
420
|
+
return { ok: false, kind: 'verify-failed', error, output }
|
|
421
|
+
}
|
|
357
422
|
logger?.info?.(`maoying 创作台: 全局 maoying CLI ${g.version} → ${targetVersion}`)
|
|
358
423
|
return { ok: true, from: g.version, to: targetVersion }
|
|
359
424
|
}
|
|
@@ -439,6 +504,17 @@ async function runSelfUpdate(opts = {}) {
|
|
|
439
504
|
}
|
|
440
505
|
const target = manifest.latest // 形如 v0.2.3
|
|
441
506
|
const targetVersion = target.replace(/^v/, '')
|
|
507
|
+
// M1:targetVersion 使用前强制形状校验(X.Y.Z[-预发布])——registry/npmmirror
|
|
508
|
+
// 被控时返回的串只剥 v 前缀就采信,会拼进 `dsh-maoying@${targetVersion}`
|
|
509
|
+
// 与响应文案(win32 shell:true 下即命令注入)。不匹配按 no-target 拒绝,
|
|
510
|
+
// 绝不 spawn。version.mjs 源头已验,这里是使用点的双保险。
|
|
511
|
+
if (!isValidVersion(targetVersion)) {
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
kind: 'no-target',
|
|
515
|
+
message: `线上版本串非法(${target}),已拒绝升级以防注入;请稍后重试或用命令行升级`,
|
|
516
|
+
}
|
|
517
|
+
}
|
|
442
518
|
// 全局 CLI 阶段的依赖包(两个成功路径各跑一次;失败路径整单拒绝、碰都不碰)。
|
|
443
519
|
const globalDeps = { env, platform, execPath, spawnImpl, existsImpl, readdirImpl, realpathImpl, readImpl, logger, globalTimeoutMs }
|
|
444
520
|
if (compareVersions(target, current) <= 0) {
|
|
@@ -513,12 +589,33 @@ async function runSelfUpdate(opts = {}) {
|
|
|
513
589
|
}
|
|
514
590
|
}
|
|
515
591
|
|
|
516
|
-
// ── e)
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
592
|
+
// ── e) 装后对账(M2):退出码 0 只代表 pnpm 自认装完,半装/坏包要重启才
|
|
593
|
+
// 暴露。回读安装位 package.json 的 version 与精确目标对账,相符才算
|
|
594
|
+
// 成功;读不到或不符一律 verify-failed(新 kind,走既有 ok:false 分支,
|
|
595
|
+
// client 透传兼容)并给手动重装出路;与其他 profile 阶段失败路径同
|
|
596
|
+
// 收敛,整单不碰全局。
|
|
597
|
+
const installedManifest = path.join(profileDir, 'node_modules', 'dsh-maoying', 'package.json')
|
|
598
|
+
let installedVersion = null
|
|
599
|
+
try {
|
|
600
|
+
installedVersion = JSON.parse(readImpl(installedManifest))?.version ?? null
|
|
601
|
+
} catch { /* 读不到 / 解析失败:对账按失败处理 */ }
|
|
602
|
+
if (installedVersion !== targetVersion) {
|
|
603
|
+
const detail = installedVersion === null
|
|
604
|
+
? `读不到 ${installedManifest}(安装未落盘或已损坏)`
|
|
605
|
+
: `磁盘 version ${installedVersion} ≠ 目标 ${targetVersion}`
|
|
606
|
+
logger?.warn?.(`maoying 创作台: self-update 对账失败(verify-failed): ${detail}`)
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
kind: 'verify-failed',
|
|
610
|
+
message: `升级后对账失败(${detail}),请手动重装:cd ${profileDir} && pnpm add dsh-maoying@${targetVersion}`,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── f) 成功:版本缓存立刻失效(装的是磁盘新包,进程内 1h 缓存已是旧结论)。
|
|
615
|
+
// to 取预检到的精确目标版本(已与磁盘对账)。随后跑全局 CLI 阶段(方案
|
|
616
|
+
// B:desktop profile 与全局 npm 安装是两份独立拷贝,顺带更到同一精确
|
|
617
|
+
// 版本);全局阶段失败不拖累整体——profile 已装好,重启照常生效,
|
|
618
|
+
// globalCli.ok:false 如实透出。
|
|
522
619
|
invalidateVersionCache()
|
|
523
620
|
logger?.info?.(`maoying 创作台: self-update 完成 ${current} → ${targetVersion}(重启 DeepSeek 后生效)`)
|
|
524
621
|
const globalCli = await updateGlobalCliStage({ targetVersion, profileDir }, globalDeps)
|
package/lib/version.mjs
CHANGED
|
@@ -31,6 +31,17 @@ const MIRROR_LATEST = 'https://registry.npmmirror.com/dsh-maoying/latest'
|
|
|
31
31
|
// 对任何 latest 误报新版,升级提示必须整体抑制(读不到版本就不提示)。
|
|
32
32
|
const FALLBACK_VERSION = '0.0.0'
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 合法版本号形状(M1):semver 核心三段 + 可选预发布段(如 1.2.3-rc.1)。
|
|
36
|
+
* 远端源(registry / npmmirror / GitHub tag)返回的串必须先过这道闸才允许
|
|
37
|
+
* 进缓存、参与比较或拼进安装参数——对 version 只校验非空字符串的话,被控源
|
|
38
|
+
* 返回的串可携带 shell 元字符,剥了 v 前缀就采信会经 self-update 的
|
|
39
|
+
* `dsh-maoying@<version>` 拼参(win32 shell:true 下即命令注入)。
|
|
40
|
+
*/
|
|
41
|
+
export function isValidVersion(value) {
|
|
42
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.\-]+)?$/.test(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
// 内存缓存 TTL:1 小时内复用上次成功结果,过期才真的发外网请求。
|
|
35
46
|
const CACHE_TTL_MS = 60 * 60 * 1000
|
|
36
47
|
// 请求失败时的 stale-if-error 窗口:上次成功结果在 7 天内仍原样复用(lastGood)。
|
|
@@ -66,6 +77,11 @@ export async function currentVersion() {
|
|
|
66
77
|
function parseVersion(value) {
|
|
67
78
|
let s = String(value ?? '').trim()
|
|
68
79
|
if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
|
|
80
|
+
// L3:build metadata(首个 '+' 起的段)按 semver 口径不参与比较,先截掉——
|
|
81
|
+
// 否则 '1.2.3+build' 的核心段被切成 ['1','2','3+build'],'3+build' 非纯数字
|
|
82
|
+
// 归 0,整体被误判成 1.2.0。
|
|
83
|
+
const plus = s.indexOf('+')
|
|
84
|
+
if (plus !== -1) s = s.slice(0, plus)
|
|
69
85
|
const dash = s.indexOf('-')
|
|
70
86
|
const coreText = dash === -1 ? s : s.slice(0, dash)
|
|
71
87
|
const pre = dash === -1 ? '' : s.slice(dash + 1)
|
|
@@ -157,6 +173,8 @@ export async function fetchLatestRelease({ fetchImpl, timeoutMs = 3000 } = {}) {
|
|
|
157
173
|
}
|
|
158
174
|
const tag = typeof data?.tag_name === 'string' && data.tag_name !== '' ? data.tag_name : null
|
|
159
175
|
if (!tag) return { ok: false, error: 'GitHub API 响应缺少 tag_name' }
|
|
176
|
+
// M1:tag 同款形状校验(剥 v/V 前缀后验),非法 tag 不进缓存也不参与比较。
|
|
177
|
+
if (!isValidVersion(tag.replace(/^v/i, ''))) return { ok: false, error: `GitHub release tag 非法: ${tag}` }
|
|
160
178
|
const url = typeof data?.html_url === 'string' && data.html_url !== '' ? data.html_url : RELEASE_PAGE
|
|
161
179
|
return { ok: true, tag, url }
|
|
162
180
|
} catch (err) {
|
|
@@ -190,6 +208,9 @@ async function fetchLatestManifest(url, label, source, { fetchImpl, timeoutMs =
|
|
|
190
208
|
// CDN 缓存抖动可能回形状不对的正文,按失败处理走下一级检测源。
|
|
191
209
|
const ver = typeof data?.version === 'string' && data.version !== '' ? data.version : null
|
|
192
210
|
if (!ver) return { ok: false, error: `${label} 响应缺少 version` }
|
|
211
|
+
// M1:形状非法(非 X.Y.Z[-预发布],可能携带 shell 元字符)的 version 不进
|
|
212
|
+
// 缓存也不参与比较/安装,按该路失败处理走下一级检测源。
|
|
213
|
+
if (!isValidVersion(ver.replace(/^v/, ''))) return { ok: false, error: `${label} 响应 version 非法: ${ver}` }
|
|
193
214
|
return { ok: true, latest: `v${ver}`, releaseUrl: RELEASE_PAGE, source }
|
|
194
215
|
} catch (err) {
|
|
195
216
|
return { ok: false, error: String(err?.message ?? err) }
|
|
@@ -329,15 +350,22 @@ export function invalidateVersionCache() {
|
|
|
329
350
|
* 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
|
|
330
351
|
* 误报新版,升级提示整体抑制。检测源:npm registry 与 npmmirror 镜像并行
|
|
331
352
|
* 竞速(先成功者胜),两路皆败才串行请求 GitHub release 兜底;三路皆败才
|
|
332
|
-
* 算本次检测失败。并发调用经 in-flight
|
|
353
|
+
* 算本次检测失败。并发调用经 in-flight 去重复用同一次检测。`force: true`
|
|
354
|
+
* 为手动检测更新语义(路由 `update-check?fresh=1`):先把模块级缓存压成
|
|
355
|
+
* 空再走正常流程——只保证不命中 1h TTL 内的陈旧 cache,**不保证「另发
|
|
356
|
+
* 一轮全新外网请求」**:若已有一轮 in-flight 检测在跑,它本来就是缓存
|
|
357
|
+
* 失效后发起的新检测,复用等它落定即可(有意语义,与 invalidateVersionCache
|
|
358
|
+
* 的整体作废不同——这里刻意不动 in-flight)。成功结果的
|
|
333
359
|
* source 字段记录命中源。所有返回值均为缓存对象的浅拷贝——缓存要供最长
|
|
334
360
|
* 7 天的 lastGood 复用,调用方原地改写返回对象不得污染缓存。
|
|
335
361
|
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
|
|
336
362
|
* releaseUrl: string|null, checkedAt: string, error: string|null,
|
|
337
363
|
* source: 'npm'|'mirror'|'github'|null}>}
|
|
338
364
|
*/
|
|
339
|
-
export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
|
|
365
|
+
export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now, force = false } = {}) {
|
|
340
366
|
const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
|
|
367
|
+
// force(手动检测):缓存置空 → 下方 TTL 判定必不命中,走真实检测。
|
|
368
|
+
if (force) cache = { at: 0, info: null }
|
|
341
369
|
// TTL 内直接复用缓存;一律返回浅拷贝,隔离调用方的原地改写。
|
|
342
370
|
if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return { ...cache.info }
|
|
343
371
|
// in-flight 去重:并发调用共享同一次检测;浅拷贝统一在返回前做。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-maoying",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"index.mjs",
|
|
31
31
|
"bin/",
|
|
32
32
|
"lib/",
|
|
33
|
+
"scripts/",
|
|
33
34
|
"presets/",
|
|
34
35
|
"skills/",
|
|
35
36
|
"templates/",
|