dsh-maoying 0.1.10 → 0.1.12
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.md +15 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +31 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +250 -88
- package/lib/base.css +174 -25
- package/lib/client.js +404 -120
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +340 -0
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +6 -2
- package/presets/maoying/agent.cordis.v2.yml +6 -1
- package/presets/maoying/agent.cordis.yml +5 -2
- package/presets/maoying/restrict.mjs +5 -6
- package/skills/maoying-review-checklist/SKILL.md +1 -1
- package/templates/novel/README.md +3 -3
- package/templates/novel/foreshadows.yml +1 -0
- package/templates/novel/timeline.yml +1 -1
package/index.mjs
CHANGED
|
@@ -51,9 +51,10 @@
|
|
|
51
51
|
* 限额 JSON 读体、client.css 现读下发。
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
-
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync,
|
|
54
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
55
55
|
import { createHash } from 'node:crypto'
|
|
56
56
|
import { pathToFileURL } from 'node:url'
|
|
57
|
+
import { getVersionInfo } from './lib/version.mjs'
|
|
57
58
|
import { readFile } from 'node:fs/promises'
|
|
58
59
|
import { createRequire } from 'node:module'
|
|
59
60
|
import os from 'node:os'
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
mutateChapterStatus,
|
|
66
67
|
mutateForeshadowStatus,
|
|
67
68
|
mutatePhase,
|
|
69
|
+
mutateProject,
|
|
68
70
|
parseStateSnapshot,
|
|
69
71
|
readChapter,
|
|
70
72
|
rebuildIndex,
|
|
@@ -137,6 +139,44 @@ export function preprocessPatchText(text) {
|
|
|
137
139
|
.replaceAll("!!js process.platform !== 'win32'", isWin ? 'false' : 'true')
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 逐字节比较复制(预设文件与技能文件共用):dst 已存在且内容与 src 完全一致
|
|
144
|
+
* 时不写(保住 mtime,二跑零写盘);读不到/比较失败一律按「不一致」重写。
|
|
145
|
+
* 读/写失败原样抛出,由调用方决定 warn 还是中止——失败语义归调用方。
|
|
146
|
+
* @returns {boolean} 本次是否有复制动作
|
|
147
|
+
*/
|
|
148
|
+
function syncFileIfChanged(src, dst) {
|
|
149
|
+
let same = false
|
|
150
|
+
try {
|
|
151
|
+
same = existsSync(dst) && readFileSync(dst).equals(readFileSync(src))
|
|
152
|
+
} catch {
|
|
153
|
+
same = false
|
|
154
|
+
}
|
|
155
|
+
if (same) return false
|
|
156
|
+
mkdirSync(path.dirname(dst), { recursive: true })
|
|
157
|
+
copyFileSync(src, dst)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* .installed-version 版本标记写入(预设目录与技能目录共用):内容与 version
|
|
163
|
+
* 一致即不重写(mtime 也是产物,少动一次是一次)。标记读不到按过期处理;
|
|
164
|
+
* 写失败原样抛出,warn 文案由调用方按现场拼。
|
|
165
|
+
* @returns {boolean} 本次是否有写入动作
|
|
166
|
+
*/
|
|
167
|
+
function writeMarkerIfChanged(dir, version) {
|
|
168
|
+
const markerPath = path.join(dir, '.installed-version')
|
|
169
|
+
let current = false
|
|
170
|
+
try {
|
|
171
|
+
current = existsSync(markerPath) && readFileSync(markerPath, 'utf8').trim() === version
|
|
172
|
+
} catch {
|
|
173
|
+
current = false
|
|
174
|
+
}
|
|
175
|
+
if (current) return false
|
|
176
|
+
writeFileSync(markerPath, `${version}\n`, 'utf8')
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
140
180
|
// ── semver(手写,零依赖)────────────────────────────────────────────────────
|
|
141
181
|
|
|
142
182
|
/** 解析 major.minor.patch[-prerelease][+build];解析不了返回 null。 */
|
|
@@ -466,16 +506,25 @@ export function parsePresetRows(yaml, agentCordisV2Text) {
|
|
|
466
506
|
* 落空(注册表审计报 never started,0.1.8 host-debug 实测坐实)。Node ESM 对
|
|
467
507
|
* file:// URL 是合法模块说明符,指向 <home>/.agent-presets/maoying/ 安装副本
|
|
468
508
|
* (真实文件系统,任何进程可读)。
|
|
509
|
+
*
|
|
510
|
+
* 遍历策略:保守递归下钻声明行整棵结构(对象/数组逐层走),不只盯
|
|
511
|
+
* config.plugins 一层——v1 组合源的 cordis:group 子件就挂在 config 数组里,
|
|
512
|
+
* 未来再嵌套一层的 './' 本地名只盯单层会静默漏改。凡遍历到的 './' 开头字符串
|
|
513
|
+
* 一律视为插件名候选替换:声明行里其余字符串都是 persona/提示词类文案,约定
|
|
514
|
+
* 不会以 './' 开头,误替换面为零。
|
|
469
515
|
*/
|
|
470
516
|
function absolutizeLocalPluginNames(rows, presetDir) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
p.name = pathToFileURL(path.join(presetDir, p.name.slice(2))).href
|
|
475
|
-
}
|
|
517
|
+
const walk = (node) => {
|
|
518
|
+
if (typeof node === 'string') {
|
|
519
|
+
return node.startsWith('./') ? pathToFileURL(path.join(presetDir, node.slice(2))).href : node
|
|
476
520
|
}
|
|
521
|
+
if (Array.isArray(node)) return node.map(walk)
|
|
522
|
+
if (node !== null && typeof node === 'object') {
|
|
523
|
+
return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]))
|
|
524
|
+
}
|
|
525
|
+
return node
|
|
477
526
|
}
|
|
478
|
-
return rows
|
|
527
|
+
return walk(rows)
|
|
479
528
|
}
|
|
480
529
|
|
|
481
530
|
export function composeManagedRows(home, logger = console) {
|
|
@@ -514,6 +563,9 @@ export function stringifyManagedBlock(yaml, rows) {
|
|
|
514
563
|
// lineWidth: 0 关掉自动折行——plain 标量折行虽合法但产物不可读、diff 噪音大
|
|
515
564
|
return `${MANAGED_BEGIN}\n${yaml.stringify(doc, { lineWidth: 0 }).replace(/\n+$/, '')}\n${MANAGED_END}`
|
|
516
565
|
}
|
|
566
|
+
// JSON 回退分支生产不可达(宿主 <home>/node_modules 必带 yaml 包,loadYamlLib
|
|
567
|
+
// 读不到时 composeManagedRows 已提前返回 null rows)——仅测试直传 yaml=null
|
|
568
|
+
// 可达(test/installer-shape.test.mjs 验证 JSON 形态同为合法 YAML)。
|
|
517
569
|
return `${MANAGED_BEGIN}\n${JSON.stringify(doc)}\n${MANAGED_END}`
|
|
518
570
|
}
|
|
519
571
|
|
|
@@ -522,13 +574,22 @@ export function stringifyManagedBlock(yaml, rows) {
|
|
|
522
574
|
/**
|
|
523
575
|
* 在行数组里定位托管块 [begin, end](闭区间下标)。无起始标记返回 null;
|
|
524
576
|
* 有起始无结束(托管块损坏,多半是手工删了半截)throw——拒绝写盘,绝不覆盖
|
|
525
|
-
*
|
|
577
|
+
* 手工内容。只认首对:首对之后若再出现起始标记(用户误复制了一份托管块),
|
|
578
|
+
* warn 提醒手工清理,多余的块按普通用户内容原样保留、绝不动它。
|
|
526
579
|
*/
|
|
527
|
-
export function findManagedSpan(lines) {
|
|
580
|
+
export function findManagedSpan(lines, { logger = console } = {}) {
|
|
528
581
|
const begin = lines.findIndex((l) => l.trim() === MANAGED_BEGIN)
|
|
529
582
|
if (begin === -1) return null
|
|
530
583
|
for (let i = begin + 1; i < lines.length; i++) {
|
|
531
|
-
if (lines[i].trim() === MANAGED_END)
|
|
584
|
+
if (lines[i].trim() === MANAGED_END) {
|
|
585
|
+
if (lines.slice(i + 1).some((l) => l.trim() === MANAGED_BEGIN)) {
|
|
586
|
+
logger.warn?.(
|
|
587
|
+
'maoying-installer: cordis.patch.yml 里检测到第二处 dsh-maoying 托管块起始标记——' +
|
|
588
|
+
'疑似误复制;本安装器只维护第一对,多余的块请手工删除',
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
return { begin, end: i }
|
|
592
|
+
}
|
|
532
593
|
}
|
|
533
594
|
throw new Error(
|
|
534
595
|
`cordis.patch.yml 的 dsh-maoying 托管块已损坏(只有起始标记、找不到结束标记 "${MANAGED_END}"):` +
|
|
@@ -541,8 +602,16 @@ export function findManagedSpan(lines) {
|
|
|
541
602
|
* 'skipped' — 托管块已在且内容与版本标记都一致,零写盘;
|
|
542
603
|
* 'updated' — 整块替换(块外用户内容一字节不动);
|
|
543
604
|
* 'created' — 原无托管块,文末追加(文件不存在则连头注释一起新建)。
|
|
605
|
+
*
|
|
606
|
+
* 并发与原子性考量(宿主级共享文件,CLI 与桌面可能双开):写盘走「同目录
|
|
607
|
+
* 临时文件 + renameSync 原子替换」,不做整文件截断覆写——rename 在同一文件
|
|
608
|
+
* 系统内要么整体成功要么不动,任何时刻盘上只有完整的旧内容或完整的新内容,
|
|
609
|
+
* 进程中途崩溃也绝不会留下半截 YAML。跨进程仍是「各自基于快照整文件替换」
|
|
610
|
+
* 的后写者赢语义(零依赖设计不引文件锁);配合写前重读对比,把「内容并无
|
|
611
|
+
* 变化」的二跑收敛为零写盘,交错写的实际危害只剩同刻并发更新的丢失,不会
|
|
612
|
+
* 产生损坏文件。
|
|
544
613
|
*/
|
|
545
|
-
export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent }) {
|
|
614
|
+
export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent, logger = console }) {
|
|
546
615
|
const patchPath = path.join(dshHome, 'cordis.patch.yml')
|
|
547
616
|
let text = null
|
|
548
617
|
try {
|
|
@@ -554,7 +623,7 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
|
|
|
554
623
|
const lines = text !== null ? text.split(/\r?\n/) : [HOME_PATCH_HEADER, '']
|
|
555
624
|
const incoming = blockText.replace(/\r?\n/g, '\n')
|
|
556
625
|
|
|
557
|
-
const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
|
|
626
|
+
const span = findManagedSpan(lines, { logger }) // 损坏块 throw,直接中止(未写任何盘)
|
|
558
627
|
if (span !== null) {
|
|
559
628
|
const existing = lines.slice(span.begin, span.end + 1).join('\n')
|
|
560
629
|
if (versionMarkerCurrent && existing === incoming) return 'skipped'
|
|
@@ -565,8 +634,24 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
|
|
|
565
634
|
if (text !== null && text.trim() !== '' && lines[lines.length - 1].trim() !== '') lines.push('')
|
|
566
635
|
lines.push(...incoming.split('\n'), '')
|
|
567
636
|
}
|
|
637
|
+
const content = lines.join(eol)
|
|
638
|
+
// 写前重读对比(幂等强化):首次读到真正写盘之间文件可能已被并发进程改成
|
|
639
|
+
// 与目标一致的内容——已一致就不再写盘,省一次 mtime 抖动、也收窄交错写窗口。
|
|
640
|
+
try {
|
|
641
|
+
if (readFileSync(patchPath, 'utf8') === content) return 'skipped'
|
|
642
|
+
} catch { /* 重读失败(文件被并发删/权限抖动)按首次写处理 */ }
|
|
643
|
+
// 原子落盘:临时文件带 pid 命名,多进程各写各的临时件互不踩踏。
|
|
568
644
|
mkdirSync(dshHome, { recursive: true })
|
|
569
|
-
|
|
645
|
+
const tmpPath = path.join(dshHome, `.cordis.patch.yml.tmp-${process.pid}`)
|
|
646
|
+
writeFileSync(tmpPath, content, 'utf8')
|
|
647
|
+
try {
|
|
648
|
+
renameSync(tmpPath, patchPath)
|
|
649
|
+
} catch (err) {
|
|
650
|
+
// rename 失败(极端:目标被锁/跨设备等)清掉自己的临时件后原样抛出,
|
|
651
|
+
// 由调用方 warn 兜底——绝不让安装器炸宿主。
|
|
652
|
+
try { unlinkSync(tmpPath) } catch { /* 临时件清理失败无所谓,pid 命名不互踩 */ }
|
|
653
|
+
throw err
|
|
654
|
+
}
|
|
570
655
|
return span !== null ? 'updated' : 'created'
|
|
571
656
|
}
|
|
572
657
|
|
|
@@ -580,7 +665,9 @@ const SKILL_DIR_PREFIX = 'maoying-'
|
|
|
580
665
|
|
|
581
666
|
/**
|
|
582
667
|
* 递归收集目录下全部文件的相对路径(POSIX 分隔)。目录打不开/不存在返回
|
|
583
|
-
* []
|
|
668
|
+
* null——与「空目录返回 []」严格区分:源目录读失败时调用方必须跳过版本标记
|
|
669
|
+
* (否则标记谎报,下次启动误判无需重装);子目录读失败整棵按失败算。
|
|
670
|
+
* @returns {string[]|null} 相对路径列表;读取失败返回 null
|
|
584
671
|
*/
|
|
585
672
|
function listSkillFiles(dir, relBase = '') {
|
|
586
673
|
const files = []
|
|
@@ -588,12 +675,15 @@ function listSkillFiles(dir, relBase = '') {
|
|
|
588
675
|
try {
|
|
589
676
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
590
677
|
} catch {
|
|
591
|
-
return
|
|
678
|
+
return null
|
|
592
679
|
}
|
|
593
680
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
594
681
|
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`
|
|
595
|
-
if (entry.isDirectory())
|
|
596
|
-
|
|
682
|
+
if (entry.isDirectory()) {
|
|
683
|
+
const sub = listSkillFiles(path.join(dir, entry.name), rel)
|
|
684
|
+
if (sub === null) return null
|
|
685
|
+
files.push(...sub)
|
|
686
|
+
} else if (entry.isFile()) files.push(rel)
|
|
597
687
|
}
|
|
598
688
|
return files
|
|
599
689
|
}
|
|
@@ -621,25 +711,17 @@ export function installSkills(dshHome, { version, logger = console } = {}) {
|
|
|
621
711
|
const dstDir = path.join(skillsHome, entry.name)
|
|
622
712
|
let touched = false
|
|
623
713
|
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
if (!same) {
|
|
634
|
-
mkdirSync(path.dirname(dst), { recursive: true })
|
|
635
|
-
copyFileSync(src, dst)
|
|
636
|
-
touched = true
|
|
637
|
-
}
|
|
714
|
+
const files = listSkillFiles(srcDir)
|
|
715
|
+
if (files === null) {
|
|
716
|
+
// 源目录读失败:这次连「已装」的版本标记都不能写——标记谎报会让下次
|
|
717
|
+
// 启动误判无需重装。throw 进下面的 per-skill catch,warn 留痕待自愈。
|
|
718
|
+
throw new Error('包内技能源目录不可读')
|
|
719
|
+
}
|
|
720
|
+
for (const rel of files) {
|
|
721
|
+
if (syncFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) touched = true
|
|
638
722
|
}
|
|
639
|
-
const markerPath = path.join(dstDir, '.installed-version')
|
|
640
723
|
try {
|
|
641
|
-
|
|
642
|
-
if (!current) writeFileSync(markerPath, `${version}\n`, 'utf8')
|
|
724
|
+
writeMarkerIfChanged(dstDir, version)
|
|
643
725
|
} catch (err) {
|
|
644
726
|
logger.warn?.(`maoying-installer: 技能 ${entry.name} 的 .installed-version 标记写入失败(不影响技能本体): ${err?.message ?? err}`)
|
|
645
727
|
}
|
|
@@ -704,46 +786,98 @@ function isTrustedApiRequest(req, trustedHosts) {
|
|
|
704
786
|
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
705
787
|
const origin = header(req.headers, 'origin')
|
|
706
788
|
if (origin === undefined) return true
|
|
707
|
-
|
|
789
|
+
// origin 与 Host 做 scheme+host+port 全串比较(URL.origin 对 URL.origin):
|
|
790
|
+
// 只比 hostname 会放过「同站不同端口」的页面——sec-fetch-site 对它是
|
|
791
|
+
// same-site 而非 cross-site,http://localhost:9999 打本面板端口必须挡下。
|
|
792
|
+
// 威胁模型边界:这道门防外网页面借浏览器发跨源请求(CSRF/DNS-rebinding),
|
|
793
|
+
// 不防本机其他进程/内核——本机攻击面由「只听回环 + 部署信任表」约束。
|
|
794
|
+
try { return new URL(origin).origin === hostUrl.origin } catch { return false }
|
|
708
795
|
}
|
|
709
796
|
|
|
797
|
+
/** JSON 读体限额:5MB,对面板单章正文/设定批量写入量级余量充足。 */
|
|
798
|
+
const BODY_LIMIT_BYTES = 5 * 1024 * 1024
|
|
799
|
+
|
|
800
|
+
/** 读体 idle 超时:连续 30s 无任何数据到达即放弃(防挂起连接占住 handler)。 */
|
|
801
|
+
const BODY_IDLE_MS = 30_000
|
|
802
|
+
|
|
710
803
|
/**
|
|
711
|
-
* 读 JSON 请求体(限额 5MB
|
|
712
|
-
*
|
|
713
|
-
*
|
|
804
|
+
* 读 JSON 请求体(限额 5MB + 30s idle 超时)。约定:
|
|
805
|
+
* - 超限 413 / idle 超时 408:本函数先向 res 回 JSON、再销毁连接(顺序反了
|
|
806
|
+
* 客户端只能收到 ECONNRESET,拿不到明确状态码),并 reject 带 responded
|
|
807
|
+
* 标记的错误——外层 catch 看到 responded 不再重复回包;
|
|
808
|
+
* - 非法 JSON 400、客户端提前断开(close/aborted/流错误)499/400——错误都
|
|
809
|
+
* 带 statusCode,外层 catch 据此回状态码,绝不落到 500;
|
|
810
|
+
* - 响应已发出的场景 Promise 已结算,后续 reject 是无害 no-op。
|
|
714
811
|
*/
|
|
715
|
-
function readBody(req) {
|
|
812
|
+
function readBody(req, res) {
|
|
716
813
|
return new Promise((resolve, reject) => {
|
|
717
814
|
const chunks = []
|
|
718
815
|
let size = 0
|
|
816
|
+
let done = false
|
|
817
|
+
|
|
818
|
+
const rejectWith = (statusCode, message) => {
|
|
819
|
+
if (done) return
|
|
820
|
+
done = true
|
|
821
|
+
clearTimeout(idleTimer)
|
|
822
|
+
const err = new Error(message)
|
|
823
|
+
err.statusCode = statusCode
|
|
824
|
+
reject(err)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 先响应后断开(413/408 专用):响应写完再 destroy,客户端拿得到 JSON。
|
|
828
|
+
const respondThenClose = (statusCode, message) => {
|
|
829
|
+
if (done) return
|
|
830
|
+
done = true
|
|
831
|
+
clearTimeout(idleTimer)
|
|
832
|
+
json(res, statusCode, { ok: false, error: message })
|
|
833
|
+
req.destroy()
|
|
834
|
+
const err = new Error(message)
|
|
835
|
+
err.statusCode = statusCode
|
|
836
|
+
err.responded = true
|
|
837
|
+
reject(err)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// idle 超时:每收到一段数据重置一次;超时按 408 语义先回包再断开。
|
|
841
|
+
const idleTimer = setTimeout(() => respondThenClose(408, 'request body idle timeout (30s no data)'), BODY_IDLE_MS)
|
|
842
|
+
|
|
719
843
|
req.on('data', (c) => {
|
|
844
|
+
idleTimer.refresh?.()
|
|
720
845
|
size += c.length
|
|
721
|
-
if (size >
|
|
722
|
-
|
|
723
|
-
err.statusCode = 413
|
|
724
|
-
reject(err)
|
|
725
|
-
req.destroy()
|
|
846
|
+
if (size > BODY_LIMIT_BYTES) {
|
|
847
|
+
respondThenClose(413, `request body too large (limit ${BODY_LIMIT_BYTES} bytes)`)
|
|
726
848
|
return
|
|
727
849
|
}
|
|
728
850
|
chunks.push(c)
|
|
729
851
|
})
|
|
730
852
|
req.on('end', () => {
|
|
853
|
+
if (done) return
|
|
854
|
+
clearTimeout(idleTimer)
|
|
731
855
|
try {
|
|
732
|
-
|
|
856
|
+
// done 置真必须在解析成功之后:先置真再走 catch,rejectWith 的 done
|
|
857
|
+
// 守卫会把 400 拒绝吞掉——promise 永不结算,handler 挂死(坏 JSON 必挂)。
|
|
858
|
+
const parsed = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
|
|
859
|
+
done = true
|
|
860
|
+
resolve(parsed)
|
|
733
861
|
} catch {
|
|
734
|
-
|
|
735
|
-
err.statusCode = 400
|
|
736
|
-
reject(err)
|
|
862
|
+
rejectWith(400, 'invalid JSON body')
|
|
737
863
|
}
|
|
738
864
|
})
|
|
739
|
-
req.on('error',
|
|
740
|
-
|
|
741
|
-
|
|
865
|
+
req.on('error', (err) => {
|
|
866
|
+
// 流级错误(连接重置等):无码按 400 语义兜底,不落 500
|
|
867
|
+
if (done) return
|
|
868
|
+
done = true
|
|
869
|
+
clearTimeout(idleTimer)
|
|
870
|
+
if (!Number.isInteger(err?.statusCode)) err.statusCode = 400
|
|
871
|
+
reject(err)
|
|
872
|
+
})
|
|
873
|
+
req.on('close', () => rejectWith(499, 'request closed before body completed'))
|
|
874
|
+
req.on('aborted', () => rejectWith(499, 'request aborted'))
|
|
742
875
|
})
|
|
743
876
|
}
|
|
744
877
|
|
|
745
878
|
function json(res, status, payload) {
|
|
746
|
-
|
|
879
|
+
// 无缩进序列化:状态快照等响应可到百 KB 量级,缩进纯增体积(客户端自行格式化)
|
|
880
|
+
const text = JSON.stringify(payload)
|
|
747
881
|
res.writeHead(status, {
|
|
748
882
|
'content-type': 'application/json; charset=utf-8',
|
|
749
883
|
'content-length': Buffer.byteLength(text),
|
|
@@ -764,6 +898,20 @@ function sanitizeMessage(message) {
|
|
|
764
898
|
return text
|
|
765
899
|
}
|
|
766
900
|
|
|
901
|
+
/**
|
|
902
|
+
* 递归脱敏:结构里所有字符串值过一遍 sanitizeMessage(绝对路径换 ~)。host-debug
|
|
903
|
+
* 响应用——trace.via / runtime.argv / execPath / cwd 等带本机绝对路径的字段,
|
|
904
|
+
* 与错误 message 同一纪律:诊断信息照给,本机路径不出门。
|
|
905
|
+
*/
|
|
906
|
+
function sanitizeDeep(value) {
|
|
907
|
+
if (typeof value === 'string') return sanitizeMessage(value)
|
|
908
|
+
if (Array.isArray(value)) return value.map(sanitizeDeep)
|
|
909
|
+
if (value !== null && typeof value === 'object') {
|
|
910
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitizeDeep(v)]))
|
|
911
|
+
}
|
|
912
|
+
return value
|
|
913
|
+
}
|
|
914
|
+
|
|
767
915
|
// 宿主热切换竞态兜底:「禁用→启用」等操作会重建插件条目纤维,新 apply 先
|
|
768
916
|
// 跑、旧实例的路由注销异步落地,注册瞬间旧路由常仍在表里,webServer.register
|
|
769
917
|
// 对同 (kind, path) 抛 duplicate。只对 duplicate prefix route 这一种错误短
|
|
@@ -775,7 +923,10 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
|
|
|
775
923
|
let lastError
|
|
776
924
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
777
925
|
try {
|
|
778
|
-
|
|
926
|
+
// 必须 await:宿主 register 可能返回 rejected promise(异步校验路径),
|
|
927
|
+
// 不 await 则 rejection 接不住(unhandled rejection),duplicate 短重试
|
|
928
|
+
// 形同虚设;await 对同步抛出的路径行为不变,兼容保留。
|
|
929
|
+
return await webServer.register(route)
|
|
779
930
|
} catch (err) {
|
|
780
931
|
if (!DUPLICATE_PREFIX_ROUTE_RE.test(String(err?.message ?? ''))) throw err
|
|
781
932
|
lastError = err
|
|
@@ -786,7 +937,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
|
|
|
786
937
|
}
|
|
787
938
|
|
|
788
939
|
/**
|
|
789
|
-
* 注册创作台全部路由(docs/creator-panel.md §2 的
|
|
940
|
+
* 注册创作台全部路由(docs/creator-panel.md §2 的 11 条)。返回宿主 disposer;
|
|
790
941
|
* webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
|
|
791
942
|
*/
|
|
792
943
|
export async function registerCreatorRoutes(ctx) {
|
|
@@ -809,9 +960,10 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
809
960
|
json(res, 403, { error: 'forbidden' })
|
|
810
961
|
return
|
|
811
962
|
}
|
|
812
|
-
const url = new URL(req.url, 'http://dsh.internal')
|
|
813
|
-
const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
|
|
814
963
|
try {
|
|
964
|
+
// url/route 解析进 try:畸形 requestTarget 不至于绕过错误通道裸崩
|
|
965
|
+
const url = new URL(req.url, 'http://dsh.internal')
|
|
966
|
+
const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
|
|
815
967
|
// ── 静态样式:面板 CSS 正文(lib/base.css)按请求现读下发 ──────────
|
|
816
968
|
// 客户端只注入 <link href="/api/maoying/client.css">,正文现读——
|
|
817
969
|
// 改样式无需重启,GUI 刷新即生效(no-cache)。同前缀、已被围栏把关。
|
|
@@ -831,6 +983,13 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
831
983
|
return
|
|
832
984
|
}
|
|
833
985
|
|
|
986
|
+
if (route === 'update-check' && req.method === 'GET') {
|
|
987
|
+
// 新版检测(模仿 dsh-paoding):三级源 + 缓存都在 lib/version.mjs,
|
|
988
|
+
// 全失败也静默降级——这里只做转发,绝不影响面板主流程。
|
|
989
|
+
json(res, 200, { ok: true, ...(await getVersionInfo({ timeoutMs: 3000 })) })
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
|
|
834
993
|
if (route === 'host-debug' && req.method === 'GET') {
|
|
835
994
|
// 现场诊断:宿主版本五级探针轨迹 + 形状判定。与 client.css 同级信任
|
|
836
995
|
// 围栏(loopback + 可信 Host 才放行),只暴露版本与路径类信息。
|
|
@@ -864,16 +1023,18 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
864
1023
|
registryDump.presets = jsonSafe(rows)
|
|
865
1024
|
}
|
|
866
1025
|
} catch (e) { registryDump.error = String(e?.message ?? e) }
|
|
1026
|
+
// 出口统一过 sanitizeDeep:trace.via / argv / execPath / cwd 带本机
|
|
1027
|
+
// 绝对路径,诊断照给、路径脱敏(与错误 message 同一纪律)。
|
|
867
1028
|
json(res, 200, {
|
|
868
1029
|
ok: true,
|
|
869
1030
|
hostVersion: dbg.version,
|
|
870
|
-
trace: dbg.trace,
|
|
1031
|
+
trace: sanitizeDeep(dbg.trace),
|
|
871
1032
|
shape,
|
|
872
1033
|
registry: registryDump,
|
|
873
1034
|
runtime: {
|
|
874
|
-
argv: process.argv,
|
|
875
|
-
execPath: process.execPath,
|
|
876
|
-
cwd: process.cwd(),
|
|
1035
|
+
argv: sanitizeDeep(process.argv),
|
|
1036
|
+
execPath: sanitizeMessage(process.execPath),
|
|
1037
|
+
cwd: sanitizeMessage(process.cwd()),
|
|
877
1038
|
electron: process.versions?.electron ?? null,
|
|
878
1039
|
node: process.version,
|
|
879
1040
|
},
|
|
@@ -906,28 +1067,35 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
906
1067
|
}
|
|
907
1068
|
|
|
908
1069
|
if (route === 'chapter-status' && req.method === 'POST') {
|
|
909
|
-
const body = await readBody(req)
|
|
1070
|
+
const body = await readBody(req, res)
|
|
910
1071
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
911
1072
|
json(res, 200, mutateChapterStatus(novelDir, body.file, body.status))
|
|
912
1073
|
return
|
|
913
1074
|
}
|
|
914
1075
|
|
|
915
1076
|
if (route === 'foreshadow-status' && req.method === 'POST') {
|
|
916
|
-
const body = await readBody(req)
|
|
1077
|
+
const body = await readBody(req, res)
|
|
917
1078
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
918
1079
|
json(res, 200, mutateForeshadowStatus(novelDir, body.id, body.status))
|
|
919
1080
|
return
|
|
920
1081
|
}
|
|
921
1082
|
|
|
922
1083
|
if (route === 'phase' && req.method === 'POST') {
|
|
923
|
-
const body = await readBody(req)
|
|
1084
|
+
const body = await readBody(req, res)
|
|
924
1085
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
925
1086
|
json(res, 200, mutatePhase(novelDir, body.phase))
|
|
926
1087
|
return
|
|
927
1088
|
}
|
|
928
1089
|
|
|
1090
|
+
if (route === 'project-save' && req.method === 'POST') {
|
|
1091
|
+
const body = await readBody(req, res)
|
|
1092
|
+
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
1093
|
+
json(res, 200, mutateProject(novelDir, body.values))
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
|
|
929
1097
|
if (route === 'rebuild-index' && req.method === 'POST') {
|
|
930
|
-
const body = await readBody(req)
|
|
1098
|
+
const body = await readBody(req, res)
|
|
931
1099
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
932
1100
|
json(res, 200, rebuildIndex(novelDir))
|
|
933
1101
|
return
|
|
@@ -936,8 +1104,11 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
936
1104
|
json(res, 404, { ok: false, error: `no such route: ${req.method} /api/maoying/${route}` })
|
|
937
1105
|
} catch (err) {
|
|
938
1106
|
// 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏。读体的
|
|
939
|
-
// 413/
|
|
1107
|
+
// 413/408 已由 readBody 先回包再断开(err.responded),这里只留日志不
|
|
1108
|
+
// 重复回包;其余按 statusCode 回(读体断开 499/400、业务错误自带码),
|
|
1109
|
+
// 裸异常 500。
|
|
940
1110
|
ctx.logger?.warn?.('maoying 创作台: 请求处理失败: ' + String(err?.message ?? err))
|
|
1111
|
+
if (err?.responded) return
|
|
941
1112
|
json(res, Number.isInteger(err?.statusCode) ? err.statusCode : 500, {
|
|
942
1113
|
ok: false,
|
|
943
1114
|
error: sanitizeMessage(err?.message ?? String(err)),
|
|
@@ -968,30 +1139,24 @@ export function ensurePresetInstalled(ctx = {}) {
|
|
|
968
1139
|
const sourceDir = path.join(PLUGIN_ROOT, 'presets', PRESET_ID)
|
|
969
1140
|
const presetDir = path.join(home, '.agent-presets', PRESET_ID)
|
|
970
1141
|
|
|
971
|
-
// 1)
|
|
1142
|
+
// 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖。copyFileSync 必须在
|
|
1143
|
+
// per-file try 之内——单文件失败(如目标只读 EACCES)只 warn,不能冲出
|
|
1144
|
+
// 主流程把后面的版本标记/技能落盘/托管块全跳过(违背「各自可失败」;
|
|
1145
|
+
// 失败文件下次启动自愈重试)。
|
|
972
1146
|
const copied = []
|
|
973
1147
|
mkdirSync(presetDir, { recursive: true })
|
|
974
1148
|
for (const file of PRESET_FILES) {
|
|
975
|
-
const src = path.join(sourceDir, file)
|
|
976
|
-
const dst = path.join(presetDir, file)
|
|
977
|
-
let same = false
|
|
978
1149
|
try {
|
|
979
|
-
|
|
980
|
-
} catch {
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
if (!same) {
|
|
984
|
-
copyFileSync(src, dst)
|
|
985
|
-
copied.push(file)
|
|
1150
|
+
if (syncFileIfChanged(path.join(sourceDir, file), path.join(presetDir, file))) copied.push(file)
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
logger.warn?.(`maoying-installer: 预设文件 ${file} 复制失败(已跳过,不影响其余文件): ${err?.message ?? err}`)
|
|
986
1153
|
}
|
|
987
1154
|
}
|
|
988
1155
|
|
|
989
1156
|
// 2) 版本标记:一致不重写(mtime 也是产物,少动一次是一次)
|
|
990
|
-
const markerPath = path.join(presetDir, '.installed-version')
|
|
991
1157
|
let markerCurrent = false
|
|
992
1158
|
try {
|
|
993
|
-
markerCurrent =
|
|
994
|
-
if (!markerCurrent) writeFileSync(markerPath, `${version}\n`, 'utf8')
|
|
1159
|
+
markerCurrent = !writeMarkerIfChanged(presetDir, version)
|
|
995
1160
|
} catch (err) {
|
|
996
1161
|
logger.warn?.(`maoying-installer: .installed-version 标记写入失败(不影响预设本体): ${err?.message ?? err}`)
|
|
997
1162
|
}
|
|
@@ -1029,7 +1194,7 @@ export function ensurePresetInstalled(ctx = {}) {
|
|
|
1029
1194
|
}
|
|
1030
1195
|
|
|
1031
1196
|
const blockText = stringifyManagedBlock(composed.yaml, composed.rows)
|
|
1032
|
-
const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent })
|
|
1197
|
+
const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent, logger })
|
|
1033
1198
|
|
|
1034
1199
|
if (copied.length > 0) {
|
|
1035
1200
|
logger.info?.(`maoying-installer: 预设文件已更新 ${PRESET_ID}: ${copied.join(', ')}`)
|
|
@@ -1069,13 +1234,10 @@ export function apply(ctx = {}, _config = {}) {
|
|
|
1069
1234
|
// 按宿主版本自动选形状,所选形态带在安装日志里),不再需要降级提示。
|
|
1070
1235
|
|
|
1071
1236
|
// 创作台路由(三期):与安装段彼此独立——注册失败只 warn,面板不可用不
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
|
|
1077
|
-
})
|
|
1078
|
-
} catch (error) {
|
|
1237
|
+
// 拖累安装器语义。registerCreatorRoutes 是 async 函数,同步阶段不可能 throw
|
|
1238
|
+
//(异常一律进 rejected promise),外层 try 对它是永假防御——只挂 .catch
|
|
1239
|
+
// 一条兜底通道即可,异常不会冒出 apply。
|
|
1240
|
+
registerCreatorRoutes(ctx).catch((error) => {
|
|
1079
1241
|
logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
|
|
1080
|
-
}
|
|
1242
|
+
})
|
|
1081
1243
|
}
|