搭起项目骨架,验证配置文件往返

核心是确认「改配置而不弄乱文件」这件事成立:新增一路入站之后,其余 52 行逐字节不变。

搬进来的改写引擎用默认输出参数时会重排整个文件——行尾注释的对齐空格消失、流式数组被加上内边距——
连根本没改动的段落也一起波及。不修的话「改一处只动一处」这个承诺直接落空,所以关掉了它的自动排版,
并在写回之后补了一步逐行还原。
This commit is contained in:
im0o
2026-08-10 02:43:04 +08:00
commit f7db96fb0e
13 changed files with 1906 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# 复制成 .env 再填。.env 已在 .gitignore 里,不会被提交。
# mihomo 管理接口的地址。开发时通过 SSH 隧道映射到本机:
# ssh -N -f -L 9090:127.0.0.1:9090 用户名@服务器地址
MIHOMO_API=http://127.0.0.1:9090
# 管理接口的访问密钥,即配置文件里 secret: 那一行的值
MIHOMO_SECRET=
# 要编辑的配置文件路径。开发时指向本地副本,部署时指向 /etc/mihomo/config.yaml
PATCHBAY_CONFIG=../pkgs/sample-config.yaml
# Patchbay 自己监听的地址和端口。默认只监听本机,远程访问走 SSH 隧道。
PATCHBAY_HOST=127.0.0.1
PATCHBAY_PORT=7580
+15
View File
@@ -0,0 +1,15 @@
node_modules/
dist/
.output/
.nuxt/
.vite/
*.log
# 含真实密钥,永远不要提交
.env
.env.*
!.env.example
# 从目标机器拷回来的配置副本
*.local.yaml
config.local.*
+87
View File
@@ -0,0 +1,87 @@
# Patchbay
一个编辑 mihomo 配置文件的网页界面。核心动作是把某一路入站接到某一个出站上——名字取自录音棚的插线板。
这份文件是术语表。界面文案、代码命名、文档措辞都从这里取词,同一个概念不要有第二种叫法。
## 入站与出站
**入站**:
mihomo 上的一个监听端口,客户端连上它把流量交进来。
_Avoid_: inbound、监听器、listener、进站
**默认入站**:
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。
_Avoid_: 主端口、老式端口、默认端口
**命名入站**:
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
_Avoid_: listener、新式端口、自定义端口
**出站**:
流量离开 mihomo 的去处。可以是内置出站、线路或策略组。
_Avoid_: outbound、出口、上游、落地
**内置出站**:
内核自带、不需要配置就存在的出站,包括直连和拒绝。
_Avoid_: 系统出站、特殊出站
**出站模式**:
一路入站决定其出站的四种方式之一:跟着规则走、直连、指向策略组、固定线路。
_Avoid_: 代理模式、mode、路由模式
## 线路与分组
**线路**:
一个具体的代理服务器条目。
_Avoid_: 节点、proxy、代理、服务器
**自有线路**:
写在配置文件顶层 `proxies:` 里、由用户手写的线路。可以被按名字引用。
_Avoid_: 本地节点、手写节点
**订阅线路**:
由订阅拉取而来的线路。**不能被按名字引用**,只能通过策略组整批引入。
_Avoid_: 远程节点、provider 节点、机场节点
**订阅**:
一个远程地址,mihomo 定时从它拉取一批线路。
_Avoid_: provider、机场、订阅链接、节点池
**策略组**:
一组线路加一个挑选方式(手动选、自动测速选最快等)。策略组本身也可以作为出站被引用。
_Avoid_: 分组、group、代理组、策略
**筛选组**:
Patchbay 自动创建的策略组,用筛选条件从订阅里只挑出用户指定的那几条线路。它的存在是为了让入站能固定到具体线路——因为订阅线路无法被直接引用。
_Avoid_: 隐藏组、临时组、影子组
## 名字与引用
**名字表**:
内核启动时构建的、所有可被按名字引用的出站的集合。由内置出站、自有线路和策略组构成。**订阅线路不在其中**,这是许多约束的共同来源。
_Avoid_: 代理表、proxies map、出站列表
**断引用**:
配置里出现了一个名字表中不存在的名字。内核自带的校验命令不检查这种情况,只在有流量经过时才报错,因此 Patchbay 必须自行检查。
_Avoid_: 无效引用、悬空引用、坏引用
## 规则
**分流规则**:
配置文件 `rules:` 里的有序列表,自上而下匹配,决定一条连接走哪个出站。顺序本身是语义的一部分。
_Avoid_: 路由规则、规则表
**附加规则**:
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
_Avoid_: sub-rules、子规则、私有规则
## 配置文件
**接管段落**:
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
_Avoid_: 托管段落、受控段落
**默认路由**:
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
_Avoid_: 主路由、根路由
+221
View File
@@ -0,0 +1,221 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "patchbay",
"dependencies": {
"vue": "^3.5.13",
"yaml": "^2.9.0",
},
"devDependencies": {
"@types/bun": "latest",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.7.3",
"vite": "^7.0.0",
"vue-tsc": "^3.0.0",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
"@vue/language-core": ["@vue/language-core@3.3.9", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ=="],
"@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="],
"@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="],
"vue-tsc": ["vue-tsc@3.3.9", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.9" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "patchbay",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "mihomo 配置的网页界面:把每一路入站接到它自己的出站上",
"scripts": {
"dev": "bun run --hot server/index.ts",
"dev:web": "vite",
"build": "vite build",
"test": "bun test",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"yaml": "^2.9.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/bun": "latest",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.7.3",
"vite": "^7.0.0",
"vue-tsc": "^3.0.0"
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 往返验证:读一份真实配置 → 改一处 → 写回,看未改动的部分保住了多少。
*
* 这是整个项目最先要回答的问题。如果写回之后文件面目全非,界面做得再好也没人敢用。
*
* 用法:
* bun run scripts/roundtrip.ts <源配置> <输出目录>
*
* 产出三个文件,各对应一种改动,再用 git diff --no-index 跟源文件比对。
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { replaceDocumentData, openDocument } from '../vendor/config-editor/index.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
const [sourcePath, outDir] = process.argv.slice(2)
if (!sourcePath || !outDir) {
console.error('用法:bun run scripts/roundtrip.ts <源配置> <输出目录>')
process.exit(1)
}
const source = readFileSync(sourcePath, 'utf8')
const doc = openDocument(source)
mkdirSync(outDir, { recursive: true })
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
/** 每个场景改一处,模拟界面上一次真实操作。 */
const scenarios: Array<{ file: string; what: string; mutate: (d: ConfigObject) => void }> = [
{
file: 'A-加一路入站.yaml',
what: '新增命名入站 7778,出站固定走 AUTO(本项目的核心操作)',
mutate: (d) => {
d.listeners = [
{ name: '容器A', type: 'mixed', port: 7778, listen: '0.0.0.0', proxy: 'AUTO' },
]
},
},
{
file: 'B-改一条规则.yaml',
what: '把最后一条兜底规则的出站从 PROXY 换成 AUTO',
mutate: (d) => {
const rules = d.rules as string[]
rules[rules.length - 1] = 'MATCH,AUTO'
},
},
{
file: 'C-改一个策略组.yaml',
what: '给 PROXY 组的成员列表加一个 REJECT',
mutate: (d) => {
const groups = d['proxy-groups'] as Array<Record<string, unknown>>
const proxy = groups.find((g) => g.name === 'PROXY')!
proxy.proxies = [...(proxy.proxies as string[]), 'REJECT']
},
},
]
const countComments = (yaml: string) =>
yaml.split('\n').filter((line) => line.trim().startsWith('#') || / #\s/.test(line)).length
console.log(`源文件 ${sourcePath}`)
console.log(` ${source.split('\n').length} 行,其中带注释 ${countComments(source)} 行\n`)
for (const scenario of scenarios) {
const draft = clone(doc.data)
scenario.mutate(draft)
const result = replaceDocumentData(doc, draft)
const written = restoreInlineCommentAlignment(source, result.yaml)
const outPath = join(outDir, scenario.file)
writeFileSync(outPath, written, 'utf8')
const before = countComments(source)
const after = countComments(written)
console.log(`${scenario.file}`)
console.log(` 改动:${scenario.what}`)
console.log(` 注释行 ${before} → ${after}${after < before ? `(丢了 ${before - after} 行)` : '(没丢)'}`)
console.log(` 写到 ${outPath}\n`)
}
@@ -0,0 +1,35 @@
# 测试样本 — 仿照真实配置的形状,不含任何真实密钥或订阅地址
# 关键特征:整行注释、带对齐空格的行尾注释、流式写法的数组
mixed-port: 7777 # HTTP 和 SOCKS5 共用这一个端口
allow-lan: true # true = 同局域网的其它设备也能连这个代理
mode: rule
profile:
store-selected: true # 记住你在面板里选的节点,重启不丢
external-controller: 127.0.0.1:9090
secret: "占位密钥-不是真的"
proxy-providers:
subs:
type: http
url: "http://127.0.0.1:3001/占位路径/download/示例" # 来源标记
path: ./providers/subs.yaml
interval: 86400 # 每 24 小时自动拉取一次订阅
proxy-groups:
- name: PROXY # 总开关,手动选;默认跟着 AUTO 走
type: select
proxies: [AUTO, DIRECT]
use: [subs]
- name: AUTO # 全部节点里自动挑延迟最低的
type: url-test
use: [subs]
interval: 300
rules:
# 本机和内网地址一律直连,不然会绕出去再回来
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- MATCH,PROXY
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { openDocument, replaceDocumentData } from '../vendor/config-editor/index.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { restoreInlineCommentAlignment } from './yaml-cosmetics.ts'
const fixture = readFileSync(
join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'),
'utf8',
)
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
/** 走一遍真实的写回链路:解析 → 改 → 写回 → 修复格式。 */
function roundtrip(source: string, mutate: (draft: ConfigObject) => void): string {
const doc = openDocument(source)
const draft = clone(doc.data)
mutate(draft)
return restoreInlineCommentAlignment(source, replaceDocumentData(doc, draft).yaml)
}
describe('写回一份带注释的配置', () => {
it('原样写回时逐字节不变', () => {
expect(roundtrip(fixture, () => {})).toBe(fixture)
})
it('新增一个此前不存在的段落时,其余每一行都不变', () => {
const out = roundtrip(fixture, (draft) => {
draft.listeners = [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }]
})
// 这是这个项目最核心的操作,也是取信于用户的底线:只应多出新段落,别的一行都不许动。
const before = fixture.split('\n')
const after = out.split('\n')
expect(after.slice(0, before.length - 1)).toEqual(before.slice(0, before.length - 1))
expect(out).toContain('listeners:')
expect(out).toContain('port: 7778')
})
it('改动某个段落时,不碰其它段落的注释和写法', () => {
const out = roundtrip(fixture, (draft) => {
const rules = draft.rules as string[]
rules[rules.length - 1] = 'MATCH,AUTO'
})
// rules 之外的段落必须原封不动,含对齐空格和流式数组。
expect(out).toContain('mixed-port: 7777 # HTTP 和 SOCKS5 共用这一个端口')
expect(out).toContain(' - name: PROXY # 总开关,手动选;默认跟着 AUTO 走')
expect(out).toContain(' proxies: [AUTO, DIRECT]')
expect(out).toContain(' interval: 86400 # 每 24 小时自动拉取一次订阅')
expect(out).toContain('MATCH,AUTO')
})
it('被改动的段落内部注释会丢失,这是已知缺陷(见 ADR 0002)', () => {
const out = roundtrip(fixture, (draft) => {
const rules = draft.rules as string[]
rules[rules.length - 1] = 'MATCH,AUTO'
})
// 记录现状而非期望。将来若实现了段落内的就地修改,这条断言应当反转。
expect(out).not.toContain('# 本机和内网地址一律直连')
})
})
describe('restoreInlineCommentAlignment', () => {
it('内容与注释都相同时还原原本的对齐', () => {
const source = 'a: 1 # 说明\n'
const output = 'a: 1 # 说明\n'
expect(restoreInlineCommentAlignment(source, output)).toBe(source)
})
it('注释文字变了就不还原,避免掩盖真实改动', () => {
const source = 'a: 1 # 旧说明\n'
const output = 'a: 1 # 新说明\n'
expect(restoreInlineCommentAlignment(source, output)).toBe(output)
})
it('值变了就不还原', () => {
const source = 'a: 1 # 说明\n'
const output = 'a: 2 # 说明\n'
expect(restoreInlineCommentAlignment(source, output)).toBe(output)
})
it('相同的行出现多次时按顺序依次配对', () => {
const source = 'a: 1 # 同\nb: 2\na: 1 # 同\n'
const output = 'a: 1 # 同\nb: 2\na: 1 # 同\n'
expect(restoreInlineCommentAlignment(source, output)).toBe(source)
})
it('不把字符串里的井号当成注释', () => {
const line = 'url: "http://example.com/a#b"\n'
expect(restoreInlineCommentAlignment(line, line)).toBe(line)
})
})
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun", "vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"~/*": ["./web/*"],
"@config-editor": ["./vendor/config-editor/index.ts"]
}
},
"include": ["server/**/*.ts", "web/**/*.ts", "web/**/*.vue", "vendor/**/*.ts", "*.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
# config-editor 的来源
复制自 **metacubexd**(GitHub `MetaCubeX/metacubexd`,MIT 许可),路径 `packages/config-editor/src/`,取自版本 **1.271.0**。
采用它的理由和已知缺陷记在决策记录 0002。决策记录**不在本仓库里**,它们放在仓库外面的工作区目录 `docs/adr/`(与 `patchbay/` 平级)。
## 本地改动
对照上游逐条记录,方便将来同步上游修复时区分「我们改的」和「上游变的」。
| 文件 | 改动 | 原因 |
|---|---|---|
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
## 配套的本项目代码
`shared/yaml-cosmetics.ts` 里的 `restoreInlineCommentAlignment` 不属于上游,是本项目补的一步。`yaml` 库把行尾注释的文字存在节点上,但不存注释前的对齐空格,所以重新输出时 `interval: 86400 # 说明` 会变成 `interval: 86400 # 说明`。那个函数在写回之后逐行比对,把只差对齐空格的行还原成原文。
加上这两处之后的实测结果记在决策记录 0002 的「实测」一节。要自己重跑一遍:`bun run scripts/roundtrip.ts <配置文件> <输出目录>`,再拿输出跟源文件比对。
## 同步上游
上游修了 bug 想同步时:拉取 metacubexd 对应版本的 `packages/config-editor/src/index.ts`,与本目录的同名文件比对,把上表里的本地改动重新应用一遍。
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from 'bun:test'
import {
applyPatch,
ConfigDocumentError,
diffDocument,
findResourceReferences,
openDocument,
splitRuleFields,
validateDocument,
} from './index'
describe('config editor document', () => {
it('rejects invalid YAML and non-mapping roots', () => {
expect(() => openDocument('dns: [')).toThrow(ConfigDocumentError)
expect(() => openDocument('- one\n- two\n')).toThrow(ConfigDocumentError)
})
it('preserves untouched section comments while replacing touched sections', () => {
const base = `# header\nmode: rule # keep\ndns:\n enable: true\n`
const draft = `mode: rule\ndns:\n enable: false\n nameserver: [1.1.1.1]\n`
const result = applyPatch(base, diffDocument(base, draft))
expect(result.conflicts).toEqual([])
expect(result.document.yaml).toContain('# header')
expect(result.document.yaml).toContain('mode: rule # keep')
expect(result.document.data.dns).toEqual({
enable: false,
nameserver: ['1.1.1.1'],
})
})
it('upserts, deletes, and reorders named resources', () => {
const base = `proxies:\n - { name: a, type: ss, server: old }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: c, type: direct }\n - { name: a, type: ss, server: new }\n`
const patch = diffDocument(base, draft)
expect(patch.operations.map((op) => op.op)).toEqual([
'upsert',
'delete',
'upsert',
'order',
])
const result = applyPatch(base, patch)
expect(result.conflicts).toEqual([])
expect(result.document.data.proxies).toEqual([
{ name: 'c', type: 'direct' },
{ name: 'a', type: 'ss', server: 'new' },
])
})
it('rebases an independent change and reports an overlapping change', () => {
const base = `proxies:\n - { name: a, type: ss, server: one }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: a, type: ss, server: two }\n - { name: b, type: direct }\n`
const patch = diffDocument(base, draft)
const independent = applyPatch(`${base}dns:\n enable: true\n`, patch)
expect(independent.conflicts).toEqual([])
const changed = applyPatch(
`proxies:\n - { name: a, type: ss, server: upstream }\n - { name: b, type: direct }\n`,
patch,
)
expect(changed.conflicts).toHaveLength(1)
expect(changed.conflicts[0]?.reason).toBe('changed')
})
it('validates duplicate resources and broken references', () => {
const diagnostics = validateDocument({
proxies: [
{ name: 'same', type: 'direct' },
{ name: 'same', type: 'direct' },
],
'proxy-groups': [{ name: 'group', type: 'select', proxies: ['missing'] }],
rules: ['MATCH,missing'],
})
expect(diagnostics.map((item) => item.code)).toEqual([
'duplicate-name',
'missing-reference',
'missing-reference',
])
})
it('addresses rules by content identity instead of array index', () => {
const base = `rules:\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
const draft = `rules:\n - DOMAIN,one.test,REJECT\n - MATCH,DIRECT\n`
const patch = diffDocument(base, draft)
expect(patch.operations[0]?.op).toBe('sequence-replace')
const refreshed = `rules:\n - DOMAIN,new.test,DIRECT\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
const result = applyPatch(refreshed, patch)
expect(result.conflicts).toEqual([])
expect(result.document.data.rules).toEqual([
'DOMAIN,new.test,DIRECT',
'DOMAIN,one.test,REJECT',
'MATCH,DIRECT',
])
})
it('reports a conflict when a reorder anchor disappeared upstream', () => {
const base = `proxies:\n - { name: a, type: direct }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: b, type: direct }\n - { name: a, type: direct }\n`
const patch = diffDocument(base, draft)
const result = applyPatch(
`proxies:\n - { name: b, type: direct }\n`,
patch,
)
expect(result.conflicts).toEqual([
expect.objectContaining({ reason: 'missing' }),
])
})
it('splits logical and quoted rules only at top-level commas', () => {
expect(
splitRuleFields(
'AND,((DOMAIN,one.test),(NETWORK,UDP)),Proxy Group,no-resolve',
),
).toEqual([
'AND',
'((DOMAIN,one.test),(NETWORK,UDP))',
'Proxy Group',
'no-resolve',
])
expect(splitRuleFields('DOMAIN,"one,two.test",DIRECT')).toEqual([
'DOMAIN',
'"one,two.test"',
'DIRECT',
])
})
it('finds named proxy references across routing and network sections', () => {
const data = openDocument(`
proxies:
- { name: chained, type: direct, dialer-proxy: target }
proxy-groups:
- { name: group, type: select, proxies: [target] }
proxy-providers:
remote: { type: http, url: https://example.test, proxy: target }
rule-providers:
rules: { type: http, url: https://example.test, proxy: target }
listeners:
- { name: inbound, type: mixed, port: 7890, proxy: target }
tunnels:
- { network: tcp, address: 127.0.0.1:1, target: example.test:2, proxy: target }
ntp: { enable: true, dialer-proxy: target }
sub-rules:
child:
- MATCH,target
rules:
- AND,((DOMAIN,one.test),(NETWORK,UDP)),target
- SUB-RULE,(NETWORK,TCP),target
`).data
expect(
findResourceReferences(data, 'target').map((item) => item.kind),
).toEqual([
'proxy-group-member',
'rule-policy',
'rule-policy',
'dialer-proxy',
'dialer-proxy',
'provider-proxy',
'provider-proxy',
'listener-proxy',
'tunnel-proxy',
])
})
it('does not treat unrelated same-name strings as proxy references', () => {
const data = openDocument(`
proxies:
- { name: target, type: direct, server: target }
hosts: { target: 127.0.0.1 }
rules:
- DOMAIN,target,DIRECT
`).data
expect(findResourceReferences(data, 'target')).toEqual([])
})
it('validates missing references in sub-rules and dialer fields', () => {
const diagnostics = validateDocument({
proxies: [{ name: 'node', type: 'direct', 'dialer-proxy': 'missing' }],
'sub-rules': { child: ['MATCH,missing'] },
rules: ['SUB-RULE,(NETWORK,TCP),absent', 'MATCH,GLOBAL'],
})
expect(diagnostics).toEqual([
expect.objectContaining({
path: ['sub-rules', 'child', 0],
severity: 'warning',
}),
expect.objectContaining({
path: ['proxies', 0, 'dialer-proxy'],
severity: 'error',
}),
expect.objectContaining({
path: ['rules', 0],
message: 'Unknown sub-rule: absent',
severity: 'error',
}),
])
})
})
+1084
View File
File diff suppressed because it is too large Load Diff