dsh-maoying 0.1.11 → 0.1.13
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 +169 -0
- package/README.md +17 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +33 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +242 -88
- package/lib/base.css +156 -25
- package/lib/client.js +688 -232
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +122 -36
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +7 -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,7 +51,7 @@
|
|
|
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
57
|
import { getVersionInfo } from './lib/version.mjs'
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
mutateChapterStatus,
|
|
67
67
|
mutateForeshadowStatus,
|
|
68
68
|
mutatePhase,
|
|
69
|
+
mutateProject,
|
|
69
70
|
parseStateSnapshot,
|
|
70
71
|
readChapter,
|
|
71
72
|
rebuildIndex,
|
|
@@ -138,6 +139,44 @@ export function preprocessPatchText(text) {
|
|
|
138
139
|
.replaceAll("!!js process.platform !== 'win32'", isWin ? 'false' : 'true')
|
|
139
140
|
}
|
|
140
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
|
+
|
|
141
180
|
// ── semver(手写,零依赖)────────────────────────────────────────────────────
|
|
142
181
|
|
|
143
182
|
/** 解析 major.minor.patch[-prerelease][+build];解析不了返回 null。 */
|
|
@@ -467,16 +506,25 @@ export function parsePresetRows(yaml, agentCordisV2Text) {
|
|
|
467
506
|
* 落空(注册表审计报 never started,0.1.8 host-debug 实测坐实)。Node ESM 对
|
|
468
507
|
* file:// URL 是合法模块说明符,指向 <home>/.agent-presets/maoying/ 安装副本
|
|
469
508
|
* (真实文件系统,任何进程可读)。
|
|
509
|
+
*
|
|
510
|
+
* 遍历策略:保守递归下钻声明行整棵结构(对象/数组逐层走),不只盯
|
|
511
|
+
* config.plugins 一层——v1 组合源的 cordis:group 子件就挂在 config 数组里,
|
|
512
|
+
* 未来再嵌套一层的 './' 本地名只盯单层会静默漏改。凡遍历到的 './' 开头字符串
|
|
513
|
+
* 一律视为插件名候选替换:声明行里其余字符串都是 persona/提示词类文案,约定
|
|
514
|
+
* 不会以 './' 开头,误替换面为零。
|
|
470
515
|
*/
|
|
471
516
|
function absolutizeLocalPluginNames(rows, presetDir) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
p.name = pathToFileURL(path.join(presetDir, p.name.slice(2))).href
|
|
476
|
-
}
|
|
517
|
+
const walk = (node) => {
|
|
518
|
+
if (typeof node === 'string') {
|
|
519
|
+
return node.startsWith('./') ? pathToFileURL(path.join(presetDir, node.slice(2))).href : node
|
|
477
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
|
|
478
526
|
}
|
|
479
|
-
return rows
|
|
527
|
+
return walk(rows)
|
|
480
528
|
}
|
|
481
529
|
|
|
482
530
|
export function composeManagedRows(home, logger = console) {
|
|
@@ -515,6 +563,9 @@ export function stringifyManagedBlock(yaml, rows) {
|
|
|
515
563
|
// lineWidth: 0 关掉自动折行——plain 标量折行虽合法但产物不可读、diff 噪音大
|
|
516
564
|
return `${MANAGED_BEGIN}\n${yaml.stringify(doc, { lineWidth: 0 }).replace(/\n+$/, '')}\n${MANAGED_END}`
|
|
517
565
|
}
|
|
566
|
+
// JSON 回退分支生产不可达(宿主 <home>/node_modules 必带 yaml 包,loadYamlLib
|
|
567
|
+
// 读不到时 composeManagedRows 已提前返回 null rows)——仅测试直传 yaml=null
|
|
568
|
+
// 可达(test/installer-shape.test.mjs 验证 JSON 形态同为合法 YAML)。
|
|
518
569
|
return `${MANAGED_BEGIN}\n${JSON.stringify(doc)}\n${MANAGED_END}`
|
|
519
570
|
}
|
|
520
571
|
|
|
@@ -523,13 +574,22 @@ export function stringifyManagedBlock(yaml, rows) {
|
|
|
523
574
|
/**
|
|
524
575
|
* 在行数组里定位托管块 [begin, end](闭区间下标)。无起始标记返回 null;
|
|
525
576
|
* 有起始无结束(托管块损坏,多半是手工删了半截)throw——拒绝写盘,绝不覆盖
|
|
526
|
-
*
|
|
577
|
+
* 手工内容。只认首对:首对之后若再出现起始标记(用户误复制了一份托管块),
|
|
578
|
+
* warn 提醒手工清理,多余的块按普通用户内容原样保留、绝不动它。
|
|
527
579
|
*/
|
|
528
|
-
export function findManagedSpan(lines) {
|
|
580
|
+
export function findManagedSpan(lines, { logger = console } = {}) {
|
|
529
581
|
const begin = lines.findIndex((l) => l.trim() === MANAGED_BEGIN)
|
|
530
582
|
if (begin === -1) return null
|
|
531
583
|
for (let i = begin + 1; i < lines.length; i++) {
|
|
532
|
-
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
|
+
}
|
|
533
593
|
}
|
|
534
594
|
throw new Error(
|
|
535
595
|
`cordis.patch.yml 的 dsh-maoying 托管块已损坏(只有起始标记、找不到结束标记 "${MANAGED_END}"):` +
|
|
@@ -542,8 +602,16 @@ export function findManagedSpan(lines) {
|
|
|
542
602
|
* 'skipped' — 托管块已在且内容与版本标记都一致,零写盘;
|
|
543
603
|
* 'updated' — 整块替换(块外用户内容一字节不动);
|
|
544
604
|
* 'created' — 原无托管块,文末追加(文件不存在则连头注释一起新建)。
|
|
605
|
+
*
|
|
606
|
+
* 并发与原子性考量(宿主级共享文件,CLI 与桌面可能双开):写盘走「同目录
|
|
607
|
+
* 临时文件 + renameSync 原子替换」,不做整文件截断覆写——rename 在同一文件
|
|
608
|
+
* 系统内要么整体成功要么不动,任何时刻盘上只有完整的旧内容或完整的新内容,
|
|
609
|
+
* 进程中途崩溃也绝不会留下半截 YAML。跨进程仍是「各自基于快照整文件替换」
|
|
610
|
+
* 的后写者赢语义(零依赖设计不引文件锁);配合写前重读对比,把「内容并无
|
|
611
|
+
* 变化」的二跑收敛为零写盘,交错写的实际危害只剩同刻并发更新的丢失,不会
|
|
612
|
+
* 产生损坏文件。
|
|
545
613
|
*/
|
|
546
|
-
export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent }) {
|
|
614
|
+
export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent, logger = console }) {
|
|
547
615
|
const patchPath = path.join(dshHome, 'cordis.patch.yml')
|
|
548
616
|
let text = null
|
|
549
617
|
try {
|
|
@@ -555,7 +623,7 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
|
|
|
555
623
|
const lines = text !== null ? text.split(/\r?\n/) : [HOME_PATCH_HEADER, '']
|
|
556
624
|
const incoming = blockText.replace(/\r?\n/g, '\n')
|
|
557
625
|
|
|
558
|
-
const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
|
|
626
|
+
const span = findManagedSpan(lines, { logger }) // 损坏块 throw,直接中止(未写任何盘)
|
|
559
627
|
if (span !== null) {
|
|
560
628
|
const existing = lines.slice(span.begin, span.end + 1).join('\n')
|
|
561
629
|
if (versionMarkerCurrent && existing === incoming) return 'skipped'
|
|
@@ -566,8 +634,24 @@ export function upsertManagedBlock(dshHome, blockText, { versionMarkerCurrent })
|
|
|
566
634
|
if (text !== null && text.trim() !== '' && lines[lines.length - 1].trim() !== '') lines.push('')
|
|
567
635
|
lines.push(...incoming.split('\n'), '')
|
|
568
636
|
}
|
|
637
|
+
const content = lines.join(eol)
|
|
638
|
+
// 写前重读对比(幂等强化):首次读到真正写盘之间文件可能已被并发进程改成
|
|
639
|
+
// 与目标一致的内容——已一致就不再写盘,省一次 mtime 抖动、也收窄交错写窗口。
|
|
640
|
+
try {
|
|
641
|
+
if (readFileSync(patchPath, 'utf8') === content) return 'skipped'
|
|
642
|
+
} catch { /* 重读失败(文件被并发删/权限抖动)按首次写处理 */ }
|
|
643
|
+
// 原子落盘:临时文件带 pid 命名,多进程各写各的临时件互不踩踏。
|
|
569
644
|
mkdirSync(dshHome, { recursive: true })
|
|
570
|
-
|
|
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
|
+
}
|
|
571
655
|
return span !== null ? 'updated' : 'created'
|
|
572
656
|
}
|
|
573
657
|
|
|
@@ -581,7 +665,9 @@ const SKILL_DIR_PREFIX = 'maoying-'
|
|
|
581
665
|
|
|
582
666
|
/**
|
|
583
667
|
* 递归收集目录下全部文件的相对路径(POSIX 分隔)。目录打不开/不存在返回
|
|
584
|
-
* []
|
|
668
|
+
* null——与「空目录返回 []」严格区分:源目录读失败时调用方必须跳过版本标记
|
|
669
|
+
* (否则标记谎报,下次启动误判无需重装);子目录读失败整棵按失败算。
|
|
670
|
+
* @returns {string[]|null} 相对路径列表;读取失败返回 null
|
|
585
671
|
*/
|
|
586
672
|
function listSkillFiles(dir, relBase = '') {
|
|
587
673
|
const files = []
|
|
@@ -589,12 +675,15 @@ function listSkillFiles(dir, relBase = '') {
|
|
|
589
675
|
try {
|
|
590
676
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
591
677
|
} catch {
|
|
592
|
-
return
|
|
678
|
+
return null
|
|
593
679
|
}
|
|
594
680
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
595
681
|
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`
|
|
596
|
-
if (entry.isDirectory())
|
|
597
|
-
|
|
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)
|
|
598
687
|
}
|
|
599
688
|
return files
|
|
600
689
|
}
|
|
@@ -622,25 +711,17 @@ export function installSkills(dshHome, { version, logger = console } = {}) {
|
|
|
622
711
|
const dstDir = path.join(skillsHome, entry.name)
|
|
623
712
|
let touched = false
|
|
624
713
|
try {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
if (!same) {
|
|
635
|
-
mkdirSync(path.dirname(dst), { recursive: true })
|
|
636
|
-
copyFileSync(src, dst)
|
|
637
|
-
touched = true
|
|
638
|
-
}
|
|
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
|
|
639
722
|
}
|
|
640
|
-
const markerPath = path.join(dstDir, '.installed-version')
|
|
641
723
|
try {
|
|
642
|
-
|
|
643
|
-
if (!current) writeFileSync(markerPath, `${version}\n`, 'utf8')
|
|
724
|
+
writeMarkerIfChanged(dstDir, version)
|
|
644
725
|
} catch (err) {
|
|
645
726
|
logger.warn?.(`maoying-installer: 技能 ${entry.name} 的 .installed-version 标记写入失败(不影响技能本体): ${err?.message ?? err}`)
|
|
646
727
|
}
|
|
@@ -705,46 +786,98 @@ function isTrustedApiRequest(req, trustedHosts) {
|
|
|
705
786
|
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
706
787
|
const origin = header(req.headers, 'origin')
|
|
707
788
|
if (origin === undefined) return true
|
|
708
|
-
|
|
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 }
|
|
709
795
|
}
|
|
710
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
|
+
|
|
711
803
|
/**
|
|
712
|
-
* 读 JSON 请求体(限额 5MB
|
|
713
|
-
*
|
|
714
|
-
*
|
|
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。
|
|
715
811
|
*/
|
|
716
|
-
function readBody(req) {
|
|
812
|
+
function readBody(req, res) {
|
|
717
813
|
return new Promise((resolve, reject) => {
|
|
718
814
|
const chunks = []
|
|
719
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
|
+
|
|
720
843
|
req.on('data', (c) => {
|
|
844
|
+
idleTimer.refresh?.()
|
|
721
845
|
size += c.length
|
|
722
|
-
if (size >
|
|
723
|
-
|
|
724
|
-
err.statusCode = 413
|
|
725
|
-
reject(err)
|
|
726
|
-
req.destroy()
|
|
846
|
+
if (size > BODY_LIMIT_BYTES) {
|
|
847
|
+
respondThenClose(413, `request body too large (limit ${BODY_LIMIT_BYTES} bytes)`)
|
|
727
848
|
return
|
|
728
849
|
}
|
|
729
850
|
chunks.push(c)
|
|
730
851
|
})
|
|
731
852
|
req.on('end', () => {
|
|
853
|
+
if (done) return
|
|
854
|
+
clearTimeout(idleTimer)
|
|
732
855
|
try {
|
|
733
|
-
|
|
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)
|
|
734
861
|
} catch {
|
|
735
|
-
|
|
736
|
-
err.statusCode = 400
|
|
737
|
-
reject(err)
|
|
862
|
+
rejectWith(400, 'invalid JSON body')
|
|
738
863
|
}
|
|
739
864
|
})
|
|
740
|
-
req.on('error',
|
|
741
|
-
|
|
742
|
-
|
|
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'))
|
|
743
875
|
})
|
|
744
876
|
}
|
|
745
877
|
|
|
746
878
|
function json(res, status, payload) {
|
|
747
|
-
|
|
879
|
+
// 无缩进序列化:状态快照等响应可到百 KB 量级,缩进纯增体积(客户端自行格式化)
|
|
880
|
+
const text = JSON.stringify(payload)
|
|
748
881
|
res.writeHead(status, {
|
|
749
882
|
'content-type': 'application/json; charset=utf-8',
|
|
750
883
|
'content-length': Buffer.byteLength(text),
|
|
@@ -765,6 +898,20 @@ function sanitizeMessage(message) {
|
|
|
765
898
|
return text
|
|
766
899
|
}
|
|
767
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
|
+
|
|
768
915
|
// 宿主热切换竞态兜底:「禁用→启用」等操作会重建插件条目纤维,新 apply 先
|
|
769
916
|
// 跑、旧实例的路由注销异步落地,注册瞬间旧路由常仍在表里,webServer.register
|
|
770
917
|
// 对同 (kind, path) 抛 duplicate。只对 duplicate prefix route 这一种错误短
|
|
@@ -776,7 +923,10 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
|
|
|
776
923
|
let lastError
|
|
777
924
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
778
925
|
try {
|
|
779
|
-
|
|
926
|
+
// 必须 await:宿主 register 可能返回 rejected promise(异步校验路径),
|
|
927
|
+
// 不 await 则 rejection 接不住(unhandled rejection),duplicate 短重试
|
|
928
|
+
// 形同虚设;await 对同步抛出的路径行为不变,兼容保留。
|
|
929
|
+
return await webServer.register(route)
|
|
780
930
|
} catch (err) {
|
|
781
931
|
if (!DUPLICATE_PREFIX_ROUTE_RE.test(String(err?.message ?? ''))) throw err
|
|
782
932
|
lastError = err
|
|
@@ -787,7 +937,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
|
|
|
787
937
|
}
|
|
788
938
|
|
|
789
939
|
/**
|
|
790
|
-
* 注册创作台全部路由(docs/creator-panel.md §2 的
|
|
940
|
+
* 注册创作台全部路由(docs/creator-panel.md §2 的 11 条)。返回宿主 disposer;
|
|
791
941
|
* webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
|
|
792
942
|
*/
|
|
793
943
|
export async function registerCreatorRoutes(ctx) {
|
|
@@ -810,9 +960,10 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
810
960
|
json(res, 403, { error: 'forbidden' })
|
|
811
961
|
return
|
|
812
962
|
}
|
|
813
|
-
const url = new URL(req.url, 'http://dsh.internal')
|
|
814
|
-
const route = url.pathname.replace(/^\/api\/maoying\/?/, '')
|
|
815
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\/?/, '')
|
|
816
967
|
// ── 静态样式:面板 CSS 正文(lib/base.css)按请求现读下发 ──────────
|
|
817
968
|
// 客户端只注入 <link href="/api/maoying/client.css">,正文现读——
|
|
818
969
|
// 改样式无需重启,GUI 刷新即生效(no-cache)。同前缀、已被围栏把关。
|
|
@@ -872,16 +1023,18 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
872
1023
|
registryDump.presets = jsonSafe(rows)
|
|
873
1024
|
}
|
|
874
1025
|
} catch (e) { registryDump.error = String(e?.message ?? e) }
|
|
1026
|
+
// 出口统一过 sanitizeDeep:trace.via / argv / execPath / cwd 带本机
|
|
1027
|
+
// 绝对路径,诊断照给、路径脱敏(与错误 message 同一纪律)。
|
|
875
1028
|
json(res, 200, {
|
|
876
1029
|
ok: true,
|
|
877
1030
|
hostVersion: dbg.version,
|
|
878
|
-
trace: dbg.trace,
|
|
1031
|
+
trace: sanitizeDeep(dbg.trace),
|
|
879
1032
|
shape,
|
|
880
1033
|
registry: registryDump,
|
|
881
1034
|
runtime: {
|
|
882
|
-
argv: process.argv,
|
|
883
|
-
execPath: process.execPath,
|
|
884
|
-
cwd: process.cwd(),
|
|
1035
|
+
argv: sanitizeDeep(process.argv),
|
|
1036
|
+
execPath: sanitizeMessage(process.execPath),
|
|
1037
|
+
cwd: sanitizeMessage(process.cwd()),
|
|
885
1038
|
electron: process.versions?.electron ?? null,
|
|
886
1039
|
node: process.version,
|
|
887
1040
|
},
|
|
@@ -914,28 +1067,35 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
914
1067
|
}
|
|
915
1068
|
|
|
916
1069
|
if (route === 'chapter-status' && req.method === 'POST') {
|
|
917
|
-
const body = await readBody(req)
|
|
1070
|
+
const body = await readBody(req, res)
|
|
918
1071
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
919
1072
|
json(res, 200, mutateChapterStatus(novelDir, body.file, body.status))
|
|
920
1073
|
return
|
|
921
1074
|
}
|
|
922
1075
|
|
|
923
1076
|
if (route === 'foreshadow-status' && req.method === 'POST') {
|
|
924
|
-
const body = await readBody(req)
|
|
1077
|
+
const body = await readBody(req, res)
|
|
925
1078
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
926
1079
|
json(res, 200, mutateForeshadowStatus(novelDir, body.id, body.status))
|
|
927
1080
|
return
|
|
928
1081
|
}
|
|
929
1082
|
|
|
930
1083
|
if (route === 'phase' && req.method === 'POST') {
|
|
931
|
-
const body = await readBody(req)
|
|
1084
|
+
const body = await readBody(req, res)
|
|
932
1085
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
933
1086
|
json(res, 200, mutatePhase(novelDir, body.phase))
|
|
934
1087
|
return
|
|
935
1088
|
}
|
|
936
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
|
+
|
|
937
1097
|
if (route === 'rebuild-index' && req.method === 'POST') {
|
|
938
|
-
const body = await readBody(req)
|
|
1098
|
+
const body = await readBody(req, res)
|
|
939
1099
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
940
1100
|
json(res, 200, rebuildIndex(novelDir))
|
|
941
1101
|
return
|
|
@@ -944,8 +1104,11 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
944
1104
|
json(res, 404, { ok: false, error: `no such route: ${req.method} /api/maoying/${route}` })
|
|
945
1105
|
} catch (err) {
|
|
946
1106
|
// 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏。读体的
|
|
947
|
-
// 413/
|
|
1107
|
+
// 413/408 已由 readBody 先回包再断开(err.responded),这里只留日志不
|
|
1108
|
+
// 重复回包;其余按 statusCode 回(读体断开 499/400、业务错误自带码),
|
|
1109
|
+
// 裸异常 500。
|
|
948
1110
|
ctx.logger?.warn?.('maoying 创作台: 请求处理失败: ' + String(err?.message ?? err))
|
|
1111
|
+
if (err?.responded) return
|
|
949
1112
|
json(res, Number.isInteger(err?.statusCode) ? err.statusCode : 500, {
|
|
950
1113
|
ok: false,
|
|
951
1114
|
error: sanitizeMessage(err?.message ?? String(err)),
|
|
@@ -976,30 +1139,24 @@ export function ensurePresetInstalled(ctx = {}) {
|
|
|
976
1139
|
const sourceDir = path.join(PLUGIN_ROOT, 'presets', PRESET_ID)
|
|
977
1140
|
const presetDir = path.join(home, '.agent-presets', PRESET_ID)
|
|
978
1141
|
|
|
979
|
-
// 1)
|
|
1142
|
+
// 1) 预设文件:内容一致不写(逐字节比较),变化才覆盖。copyFileSync 必须在
|
|
1143
|
+
// per-file try 之内——单文件失败(如目标只读 EACCES)只 warn,不能冲出
|
|
1144
|
+
// 主流程把后面的版本标记/技能落盘/托管块全跳过(违背「各自可失败」;
|
|
1145
|
+
// 失败文件下次启动自愈重试)。
|
|
980
1146
|
const copied = []
|
|
981
1147
|
mkdirSync(presetDir, { recursive: true })
|
|
982
1148
|
for (const file of PRESET_FILES) {
|
|
983
|
-
const src = path.join(sourceDir, file)
|
|
984
|
-
const dst = path.join(presetDir, file)
|
|
985
|
-
let same = false
|
|
986
1149
|
try {
|
|
987
|
-
|
|
988
|
-
} catch {
|
|
989
|
-
|
|
990
|
-
}
|
|
991
|
-
if (!same) {
|
|
992
|
-
copyFileSync(src, dst)
|
|
993
|
-
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}`)
|
|
994
1153
|
}
|
|
995
1154
|
}
|
|
996
1155
|
|
|
997
1156
|
// 2) 版本标记:一致不重写(mtime 也是产物,少动一次是一次)
|
|
998
|
-
const markerPath = path.join(presetDir, '.installed-version')
|
|
999
1157
|
let markerCurrent = false
|
|
1000
1158
|
try {
|
|
1001
|
-
markerCurrent =
|
|
1002
|
-
if (!markerCurrent) writeFileSync(markerPath, `${version}\n`, 'utf8')
|
|
1159
|
+
markerCurrent = !writeMarkerIfChanged(presetDir, version)
|
|
1003
1160
|
} catch (err) {
|
|
1004
1161
|
logger.warn?.(`maoying-installer: .installed-version 标记写入失败(不影响预设本体): ${err?.message ?? err}`)
|
|
1005
1162
|
}
|
|
@@ -1037,7 +1194,7 @@ export function ensurePresetInstalled(ctx = {}) {
|
|
|
1037
1194
|
}
|
|
1038
1195
|
|
|
1039
1196
|
const blockText = stringifyManagedBlock(composed.yaml, composed.rows)
|
|
1040
|
-
const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent })
|
|
1197
|
+
const blockAction = upsertManagedBlock(home, blockText, { versionMarkerCurrent: markerCurrent, logger })
|
|
1041
1198
|
|
|
1042
1199
|
if (copied.length > 0) {
|
|
1043
1200
|
logger.info?.(`maoying-installer: 预设文件已更新 ${PRESET_ID}: ${copied.join(', ')}`)
|
|
@@ -1077,13 +1234,10 @@ export function apply(ctx = {}, _config = {}) {
|
|
|
1077
1234
|
// 按宿主版本自动选形状,所选形态带在安装日志里),不再需要降级提示。
|
|
1078
1235
|
|
|
1079
1236
|
// 创作台路由(三期):与安装段彼此独立——注册失败只 warn,面板不可用不
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
|
|
1085
|
-
})
|
|
1086
|
-
} catch (error) {
|
|
1237
|
+
// 拖累安装器语义。registerCreatorRoutes 是 async 函数,同步阶段不可能 throw
|
|
1238
|
+
//(异常一律进 rejected promise),外层 try 对它是永假防御——只挂 .catch
|
|
1239
|
+
// 一条兜底通道即可,异常不会冒出 apply。
|
|
1240
|
+
registerCreatorRoutes(ctx).catch((error) => {
|
|
1087
1241
|
logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
|
|
1088
|
-
}
|
|
1242
|
+
})
|
|
1089
1243
|
}
|