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/lib/epub.mjs CHANGED
@@ -121,9 +121,15 @@ export function buildZip(entries) {
121
121
  * XML 文本转义:& 必须先转(否则把刚生成的 &amp; 又转一遍),再 < > 与引号。
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, '&amp;')
128
134
  .replace(/</g, '&lt;')
129
135
  .replace(/>/g, '&gt;')
@@ -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
- for (const raw of text.split(/\r?\n/)) {
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('#')) continue
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 (const line of lines) {
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
- .filter((f) => f.endsWith('.md'))
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
- if (!file.endsWith('.md')) continue
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
- function readYamlArray(novelDir, name) {
467
- const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)))
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 = readYamlArray(novelDir, 'foreshadows.yml')
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 = readYamlArray(novelDir, 'timeline.yml')
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 = readYamlArray(novelDir, 'relations.yml')
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
- if (readYamlArray(novelDir, 'volumes.yml').length === 0) {
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 && readYamlArray(novelDir, 'relations.yml').length === 0) {
1211
+ if (cards.length >= 2 && relations.length === 0) {
1128
1212
  push('error', 'relations.yml', 'phase 已过 setting 但 relations.yml 为空(setting → outline 判据:两张及以上角色卡时关系网须登记)')
1129
1213
  }
1130
1214
  }
@@ -304,7 +304,9 @@ function collectOutlines(novelDir, warnings) {
304
304
  const dir = path.join(novelDir, 'outlines')
305
305
  let names = []
306
306
  try {
307
- names = readdirSync(dir).filter((n) => n.toLowerCase().endsWith('.md'))
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
- names = readdirSync(dir).filter((n) => n.toLowerCase().endsWith('.md')).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
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
- data = null
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) data = parseFlatYamlArray(text)
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
- data = null
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) {
@@ -30,8 +30,16 @@
30
30
  * 数组形式;darwin/linux 恒 shell:false,无注入面。
31
31
  *
32
32
  * 升级前版本预检:fetchLatestFromRegistry(10s)失败再 fetchLatestFromMirror
33
- * (10s),拿不到 latest 绝不做半截升级;compareVersions(target, current) <= 0
34
- * 直接短路「已是最新」,不 spawn。装完失效版本检测缓存。
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 与精确目标同版 → skipped: 'same',不 spawn;否则 spawn
59
- * `npm i -g dsh-maoying@<target>`(cwd=os.tmpdir()、独立 120s 超时,npm
60
- * 候选链与 pnpm 同款 spawn ENOENT 探测:裸 npm → execPath 同目录 →
61
- * /opt/homebrew/bin → /usr/local/bin → /opt/homebrew/opt/node@XX/bin)。
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 { child.kill('SIGTERM') } catch { /* 已退出:忽略 */ }
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
- * realpathSync 两级上溯得包根,包根 package.json 的 name 必须是 dsh-maoying
233
- * 才认;包根 realpath 落在 profileDir 内(或就是它)→ 是 profile 自己的
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
- const root = path.dirname(path.dirname(cli))
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
- if (g.version !== '' && compareVersions(g.version, targetVersion) === 0) {
310
- logger?.info?.(`maoying 创作台: 全局 maoying CLI 已是 ${g.version},跳过更新`)
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) 成功:版本缓存立刻失效(装的是磁盘新包,进程内 1h 缓存已是旧结论)。
517
- // to 取预检到的精确目标版本(退出码即成败信号,不做磁盘后验——本进程
518
- // 代码不随升级变化,无从对账)。随后跑全局 CLI 阶段(方案 B:desktop
519
- // profile 与全局 npm 安装是两份独立拷贝,顺带更到同一精确版本);全局
520
- // 阶段失败不拖累整体——profile 已装好,重启照常生效,globalCli.ok:false
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.20",
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/",