dsh-maoying 0.1.19 → 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.
@@ -38,6 +38,15 @@
38
38
  * mutateChapterBody 章节正文整段替换(面板正文编辑态保存)
39
39
  * mutateOutlineBody 章纲整文件替换(面板章纲对照栏编辑态保存)
40
40
  *
41
+ * 受管文件面(0.1.20「全量可编辑写面」:白名单内的账本 yml / md 实体卡 /
42
+ * 章纲 / 章节删除走通用通道;白名单与结构校验口径见文末该节注释)
43
+ * managedRelCheck rel 白名单校验(按操作分类)
44
+ * validateManagedText 保存/新建前的结构校验(解析失败/缺围栏 422)
45
+ * readManagedFile 读全文 + 指纹(file-source)
46
+ * saveManagedFile 整文件覆写 + 409 过期基线保护(file-save)
47
+ * createManagedFile 新建(已存在 409)(file-create)
48
+ * deleteManagedFile 删除(不存在 404,UI 层二次确认)(file-delete)
49
+ *
41
50
  * 错误约定:本模块抛 PanelError(带 statusCode),路由层按 statusCode 回
42
51
  * JSON(403 拒绝 / 404 找不到 / 409 拒写 / 422 入参不合法);未带状态码的
43
52
  * 异常一律按 500 兜底。绝不静默吞错。
@@ -295,7 +304,9 @@ function collectOutlines(novelDir, warnings) {
295
304
  const dir = path.join(novelDir, 'outlines')
296
305
  let names = []
297
306
  try {
298
- 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))
299
310
  } catch (err) {
300
311
  if (err?.code !== 'ENOENT') warnings.push(`outlines: 遍历失败(${err?.code ?? err?.message ?? err})`)
301
312
  return []
@@ -327,7 +338,8 @@ function collectMdEntities(novelDir, sub, yamlLib, warnings) {
327
338
  const dir = path.join(novelDir, sub)
328
339
  let names = []
329
340
  try {
330
- 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 }))
331
343
  } catch {
332
344
  return []
333
345
  }
@@ -363,11 +375,16 @@ function collectArrayYml(novelDir, fileName, yamlLib, warnings) {
363
375
  if (yamlLib) {
364
376
  try {
365
377
  data = yamlLib.parse(text)
366
- } catch {
367
- data = null
378
+ } catch (err) {
379
+ // H1:真解析失败不再静默——先记文件名+一句话错误再降级极简解析,畸形
380
+ // 文件(如未闭合 flow 序列)在面板至少留下「坏过」的痕迹,条目残缺可归因
381
+ warnings.push(`${fileName}: yaml 解析失败(${err?.message ?? err}),已降级极简解析`)
368
382
  }
369
383
  }
370
- 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
+ }
371
388
  if (!Array.isArray(data)) {
372
389
  warnings.push(`${fileName}: 顶层不是数组,已按空表处理`)
373
390
  return []
@@ -393,8 +410,9 @@ function readProject(novelDir, yamlLib, warnings) {
393
410
  try {
394
411
  const parsed = yamlLib.parse(text)
395
412
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed
396
- } catch {
397
- data = null
413
+ } catch (err) {
414
+ // H1:与 collectArrayYml 同理——真解析失败记 warning 再降级,不静默吞
415
+ warnings.push(`project.yml: yaml 解析失败(${err?.message ?? err}),已降级极简解析`)
398
416
  }
399
417
  }
400
418
  if (data === null) {
@@ -1046,3 +1064,184 @@ export function rebuildIndex(novelDir) {
1046
1064
  writeAtomic(path.join(novelDir, 'index.yml'), text)
1047
1065
  return { ok: true, chapters: chaptersCount }
1048
1066
  }
1067
+
1068
+ // ── 受管文件(0.1.20「全量可编辑写面」):通用读/存/建/删 ────────────────────
1069
+ //
1070
+ // 边界口径(docs/creator-panel.md §0):语义规则(状态机、必填章号、memories
1071
+ // 不改旧账等)是 agent 纪律,不是人工编辑的禁区。人工编辑走「保存时结构校验
1072
+ // (解析失败/缺围栏 422 拒存)+ 保存后语义体检(check 黄条 / CLI check)+
1073
+ // git 回滚兜底」;所以这里的结构校验只管「面板存得进、快照就读得出」这一层,
1074
+ // 其余语义一律不问——留给 check。
1075
+ //
1076
+ // 白名单按操作分类、集中一处定义(只此一份,路由层不做二次判断):
1077
+ // - 读/存/建/删皆可:六个账本 yml + 四个 md 实体目录(slug 卡)+ outlines
1078
+ // 章纲(stem 复用 chapterSortKey)。md 实体卡与账本是快照与 agent 共享的
1079
+ // 数据源,人工改它们正是本次开放的目的。
1080
+ // - 仅删:chapters/<vXX-cXXX>.md。正文有 chapter-save 专用路由(frontmatter
1081
+ // 围栏保护 + 状态机语义在那边),通用存若放开会绕过这层;删除是建章/重写
1082
+ // 的前置动作,必须开放,且 chapter-create 一条路即可重建。
1083
+ // - 绝不进白名单:index.yml(派生文件,rebuild-index 覆盖,编辑无意义且
1084
+ // 误导——改完下次重建就被冲掉)、project.yml(有 project-save 表单路由,
1085
+ // 枚举/注释保留逻辑在那边)、novel/style.md(有 style-save 专用路由)。
1086
+ // 这三个不在 yml 六件套里,靠枚举穷举天然挡住;chapters 通用存靠操作分类
1087
+ // 挡住。rel 不在白名单一律 403——防穿越与防越权同码,不给探测回显差异。
1088
+
1089
+ /** 受管 md 实体目录(<slug>.md,slug 全小写)。 */
1090
+ const MANAGED_MD_DIRS = ['characters', 'world', 'science', 'research']
1091
+
1092
+ /** 受管顶层账本 yml(六个,快照同源读取)。 */
1093
+ const MANAGED_YML_FILES = ['relations.yml', 'timeline.yml', 'memories.yml', 'clue-board.yml', 'foreshadows.yml', 'volumes.yml']
1094
+
1095
+ /** md 实体卡 slug 规范:全小写字母/数字,`-` 连接,不以 `-` 开头。 */
1096
+ const MANAGED_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
1097
+
1098
+ /**
1099
+ * rel 白名单校验:op ∈ read|save|create|delete。校验不过(缺参/slug 或 stem
1100
+ * 不合规范)422,rel 不在白名单(含穿越形状与仅删目录的非删操作)403。返回
1101
+ * { dir, name, kind }——kind 供 validateManagedText 分派结构校验口径。
1102
+ */
1103
+ export function managedRelCheck(rel, op) {
1104
+ if (typeof rel !== 'string' || rel.trim() === '') fail(422, '缺少 rel 参数(novel 根内的相对路径)')
1105
+ if (rel.includes('\\')) fail(403, `rel 含反斜杠,已拒绝:${rel}`) // Windows 分隔符一律不认,形状先钉死
1106
+ const segments = rel.split('/')
1107
+ if (segments.length > 2) fail(403, `rel 超出「目录/文件名」一层深度,已拒绝:${rel}`)
1108
+ const dir = segments.length === 2 ? segments[0] : ''
1109
+ const name = segments[segments.length - 1]
1110
+ if (dir === '' && MANAGED_YML_FILES.includes(name)) return { dir: '', name, kind: 'yml' }
1111
+ if (dir !== '' && MANAGED_MD_DIRS.includes(dir)) {
1112
+ if (!/\.md$/i.test(name) || !MANAGED_SLUG_RE.test(name.slice(0, -3))) {
1113
+ fail(422, `文件名不合 slug 规范(${MANAGED_SLUG_RE.source} + .md):${rel}`)
1114
+ }
1115
+ return { dir, name, kind: 'md' }
1116
+ }
1117
+ if (dir === 'outlines') {
1118
+ const stem = name.replace(/\.md$/i, '')
1119
+ if (!/\.md$/i.test(name) || chapterSortKey(stem) === null) {
1120
+ fail(422, `章纲文件名不合 vXX-cXXX 规范:${rel}`)
1121
+ }
1122
+ return { dir, name, kind: 'outline' }
1123
+ }
1124
+ if (dir === 'chapters') {
1125
+ // 仅删:正文存/读各有专用路由(chapter-save / chapter),通用通道只放
1126
+ // 删除——放开通用存会绕过 frontmatter 围栏保护与状态机语义。
1127
+ if (op !== 'delete') fail(403, `chapters/ 只开放删除(正文编辑走 chapter-save),已拒绝:${rel}`)
1128
+ const stem = name.replace(/\.md$/i, '')
1129
+ if (!/\.md$/i.test(name) || chapterSortKey(stem) === null) {
1130
+ fail(422, `章节文件名不合 vXX-cXXX 规范:${rel}`)
1131
+ }
1132
+ return { dir, name, kind: 'chapter' }
1133
+ }
1134
+ fail(403, `rel 不在受管文件白名单内,已拒绝:${rel}`)
1135
+ }
1136
+
1137
+ /** 白名单校验之后再来一道 resolve 包含校验:拼接结果必须落在 novel 根内
1138
+ * (双保险防符号形状的意外,与 locateChapter 同纪律)。 */
1139
+ function managedTargetOf(novelDir, rel, op) {
1140
+ const check = managedRelCheck(rel, op)
1141
+ const root = path.resolve(novelDir)
1142
+ const target = path.resolve(root, rel)
1143
+ const within = path.relative(root, target)
1144
+ if (within === '' || within.startsWith('..') || path.isAbsolute(within)) {
1145
+ fail(403, `受管路径越界,已拒绝:${rel}`)
1146
+ }
1147
+ return { ...check, target }
1148
+ }
1149
+
1150
+ /**
1151
+ * 读受管文件(面板编辑态打开时取数):不存在 404。hash 为原文 sha256,作
1152
+ * file-save 的 base_hash 基线——与 chapter 路由同口径(读与指纹同刻同源)。
1153
+ */
1154
+ export function readManagedFile(novelDir, rel) {
1155
+ const { target } = managedTargetOf(novelDir, rel, 'read')
1156
+ const text = readTarget(target, `源文件 ${rel}`)
1157
+ return { ok: true, rel, text, hash: sha256Hex(text) }
1158
+ }
1159
+
1160
+ /**
1161
+ * 受管文本的结构校验(保存/新建共一道门,服务端只验形状):
1162
+ * - 六个账本 yml:按快照同源口径解析(yamlLib 可用走真解析,否则
1163
+ * parseFlatYamlArray 极简回退——两条路必须同一结论,否则面板存得进、快照
1164
+ * 读不出);必须解析为顶层数组且条目全是平键对象(空数组/空文件允许)。
1165
+ * 快照会把 null/裸标量/数组条目/空对象条目静默丢弃——存得进却读不出等于
1166
+ * 丢数据,这里一并 422 带原因。
1167
+ * - 四个 md 实体目录:必须含成对 frontmatter 围栏(其他语义一律不管,留给
1168
+ * check);outlines 章纲纯 markdown 无围栏,跳过此检查。
1169
+ */
1170
+ export function validateManagedText(rel, text, yamlLib) {
1171
+ const { kind } = managedRelCheck(rel, 'save')
1172
+ if (kind === 'outline') return
1173
+ if (kind === 'yml') {
1174
+ // 空文件(含纯注释/空白)按空表放行:与快照「文件缺失按空表」同一宽容。
1175
+ if (String(text).trim() === '') return
1176
+ let data = null
1177
+ if (yamlLib) {
1178
+ try {
1179
+ data = yamlLib.parse(text)
1180
+ } catch {
1181
+ data = null // 真解析不认——与快照同款回退,不因 yaml 在场而更严
1182
+ }
1183
+ }
1184
+ if (data === null || data === undefined) data = parseFlatYamlArray(text)
1185
+ if (!Array.isArray(data)) fail(422, `${rel} 顶层不是数组——受管账本 yml 只接受「- key: value」条目列表`)
1186
+ data.forEach((item, index) => {
1187
+ const plain = item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0
1188
+ if (!plain) fail(422, `${rel} 第 ${index + 1} 条不是平键对象(快照会静默丢弃该条,请删掉或补全后再存)`)
1189
+ })
1190
+ return
1191
+ }
1192
+ // md 实体卡:只验围栏成对,卡内键值/枚举全不问(角色 role 越枚举这类语义
1193
+ // 由 check 标黄条,人工编辑不因此被挡)。
1194
+ if (splitFrontmatter(text).raw === null) fail(422, `${rel} 缺少成对的 frontmatter 围栏(--- … ---),请补齐后再存`)
1195
+ }
1196
+
1197
+ /**
1198
+ * 存受管文件(编辑态保存):目标不存在 404(新建走 file-create,一条路一个
1199
+ * 语义);text 非 string 422;base_hash 过期 409 拒写不落盘(人工改动保护,
1200
+ * 与 project-save 同口径文案);结构校验 422 带原因。写前读、原子写,尾无
1201
+ * 换行补一个 \n(POSIX 文本文件惯例,与正文/章纲保存同惯例)。返回写后新
1202
+ * 指纹 hash,客户端下一轮 base_hash 直接取它。
1203
+ */
1204
+ export function saveManagedFile(novelDir, rel, text, options = {}) {
1205
+ if (typeof text !== 'string') fail(422, 'text 必须是字符串(文件全文)')
1206
+ validateManagedText(rel, text, yamlLibOf(options))
1207
+ const { target } = managedTargetOf(novelDir, rel, 'save')
1208
+ const current = readTarget(target, `源文件 ${rel}`)
1209
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
1210
+ if (baseHash !== '' && baseHash !== sha256Hex(current)) {
1211
+ fail(409, `${rel} 已被外部修改,请刷新后基于最新内容重改再存`)
1212
+ }
1213
+ const next = text.endsWith('\n') ? text : `${text}\n`
1214
+ writeAtomic(target, next)
1215
+ return { ok: true, rel, hash: sha256Hex(next) }
1216
+ }
1217
+
1218
+ /**
1219
+ * 新建受管文件:已存在 409(先到先建,绝不覆盖——与 chapter-create 同纪律);
1220
+ * 结构校验同 saveManagedText(新角色卡/新章纲由客户端模板预填,服务端只验
1221
+ * 形状)。目录缺席先建(新 slug 落在还没建过的目录、测试夹具都可能出现)。
1222
+ */
1223
+ export function createManagedFile(novelDir, rel, text, options = {}) {
1224
+ if (typeof text !== 'string') fail(422, 'text 必须是字符串(文件全文)')
1225
+ const { target } = managedTargetOf(novelDir, rel, 'create')
1226
+ if (existsSync(target)) fail(409, `${rel} 已存在——不覆盖既有文件,改名或改走编辑`)
1227
+ validateManagedText(rel, text, yamlLibOf(options))
1228
+ const next = text.endsWith('\n') ? text : `${text}\n`
1229
+ mkdirSync(path.dirname(target), { recursive: true })
1230
+ writeAtomic(target, next)
1231
+ return { ok: true, rel, hash: sha256Hex(next) }
1232
+ }
1233
+
1234
+ /**
1235
+ * 删除受管文件:不存在 404。不可逆操作,服务端不做二次确认(UI 层强制确认
1236
+ * 弹层),白名单上它是最严的一档(见 managedRelCheck)。
1237
+ */
1238
+ export function deleteManagedFile(novelDir, rel) {
1239
+ const { target } = managedTargetOf(novelDir, rel, 'delete')
1240
+ try {
1241
+ unlinkSync(target)
1242
+ } catch (err) {
1243
+ if (err?.code === 'ENOENT') fail(404, `源文件不存在:${rel}`)
1244
+ fail(500, `源文件删除失败(${err?.code ?? err?.message ?? err})`)
1245
+ }
1246
+ return { ok: true, rel }
1247
+ }
@@ -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.19",
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/",