From f7db96fb0eca60317e286c669ace3152f408708f Mon Sep 17 00:00:00 2001 From: im0o <0ojixueseno0@gmail.com> Date: Mon, 10 Aug 2026 02:43:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=90=AD=E8=B5=B7=E9=A1=B9=E7=9B=AE=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E9=AA=8C=E8=AF=81=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=BE=80=E8=BF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心是确认「改配置而不弄乱文件」这件事成立:新增一路入站之后,其余 52 行逐字节不变。 搬进来的改写引擎用默认输出参数时会重排整个文件——行尾注释的对齐空格消失、流式数组被加上内边距—— 连根本没改动的段落也一起波及。不修的话「改一处只动一处」这个承诺直接落空,所以关掉了它的自动排版, 并在写回之后补了一步逐行还原。 --- .env.example | 15 + .gitignore | 15 + CONTEXT.md | 87 ++ bun.lock | 221 ++++ package.json | 25 + scripts/roundtrip.ts | 79 ++ shared/__fixtures__/config-with-comments.yaml | 35 + shared/yaml-cosmetics.test.ts | 95 ++ shared/yaml-cosmetics.ts | Bin 0 -> 2504 bytes tsconfig.json | 29 + vendor/config-editor/ORIGIN.md | 24 + vendor/config-editor/index.test.ts | 197 +++ vendor/config-editor/index.ts | 1084 +++++++++++++++++ 13 files changed, 1906 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 scripts/roundtrip.ts create mode 100644 shared/__fixtures__/config-with-comments.yaml create mode 100644 shared/yaml-cosmetics.test.ts create mode 100644 shared/yaml-cosmetics.ts create mode 100644 tsconfig.json create mode 100644 vendor/config-editor/ORIGIN.md create mode 100644 vendor/config-editor/index.test.ts create mode 100644 vendor/config-editor/index.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74c855f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d8f467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +.output/ +.nuxt/ +.vite/ +*.log + +# 含真实密钥,永远不要提交 +.env +.env.* +!.env.example + +# 从目标机器拷回来的配置副本 +*.local.yaml +config.local.* diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..09f73e5 --- /dev/null +++ b/CONTEXT.md @@ -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_: 主路由、根路由 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ffbf058 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17eb981 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/roundtrip.ts b/scripts/roundtrip.ts new file mode 100644 index 0000000..9e15c4f --- /dev/null +++ b/scripts/roundtrip.ts @@ -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> + 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`) +} diff --git a/shared/__fixtures__/config-with-comments.yaml b/shared/__fixtures__/config-with-comments.yaml new file mode 100644 index 0000000..011069d --- /dev/null +++ b/shared/__fixtures__/config-with-comments.yaml @@ -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 diff --git a/shared/yaml-cosmetics.test.ts b/shared/yaml-cosmetics.test.ts new file mode 100644 index 0000000..58f27db --- /dev/null +++ b/shared/yaml-cosmetics.test.ts @@ -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) + }) +}) diff --git a/shared/yaml-cosmetics.ts b/shared/yaml-cosmetics.ts new file mode 100644 index 0000000000000000000000000000000000000000..b348a4893fa4e852984a5328ce7b819d7652bedb GIT binary patch literal 2504 zcma)7ZBH6m7~RkOird)4!4NQ;X0z$mCe3C)G})$~H*FKB4mw*L2s7-qYe~>nDHeom z)rw+$sda5vg|2o5MENgu?q%kaf1&5zJ0P_$yGFxs=ef_zIp;YB`uaSikI<S3|lDE+@5vZWGe-$nqs@}?7|USt+CQJ`)AEw|C`O$==2t{JLw#o zuh_@WXu8gx|4V|JZmRL$LXpel`|mFG_mi%l9>U6b^6e!;D|y7ePi+SIbXIM}YAn+H z0((;U^!{fqp--#1#>UI&#j38$8Oe|q4ihJx<+(N%(GJaxV@8fr@r|>xhcJOu7YcM(J@Zy^|264YZ{4huS1Jz%rP zkRhEpl+y$}o&tcdDw1h0%WjKhUaaIl0GTg%~>vTJrRi@Kj{m2 z!U=uQ)S@~uViC=}u17Ro{V_T^s_Le~pDz=`jBEO^_c9qHgHfnSH``}F;6OyS4@iMrmRPmC5fdSInd;TV$ zM|!~aCe0sU6xy70)(*Qy$NG#;LyFhuUT`Vxy3oNVgQk4KVwPknGR3}TgY%J7|62GF`n zf)~f!xa2qdQ4~rBg~YiRK;%_*Jynfg6Qm+Tq<6R8%=4ed(HoI$LVgCPI7Ou)BEsCmdVMosLwJf zxDq&o*0>kv7p%iW>uAm{?^w0WX=6#0%+{PqPBi`zpsCKvca#ZpfCJ!_Y=x^`xDtmV zU2n})V5Dq&jn~d*mEk+VE=;ZCDqp zE>Uamv5!a=+TXGK(gnKqI;QGF@CA^stG|;Qq1Y7};&Vf9-MOmp{SHUtLLRUJt|$@> z6IskJ97pNBt@lF09kn7Z7K)n&5`V#xcviWg7eu62_>!h4)VAW01Ik1Op-)9b`CaMx zWO{rR@{`G=n(rnC@2MuPxwH9)RZ~HVvw||hOXR0NmM}(yO~?o}VyK+|xA@q1 zctjF^+*sP_4ebwAxX79PNZdULbhp?&;1hQPCMNZdhGL4W&(}*r*!JE31>(mrx+QtP zB7Zy8mvDiJe-&DZid(}?^6+6Byojm~n <输出目录>`,再拿输出跟源文件比对。 + +## 同步上游 + +上游修了 bug 想同步时:拉取 metacubexd 对应版本的 `packages/config-editor/src/index.ts`,与本目录的同名文件比对,把上表里的本地改动重新应用一遍。 diff --git a/vendor/config-editor/index.test.ts b/vendor/config-editor/index.test.ts new file mode 100644 index 0000000..afcc4e2 --- /dev/null +++ b/vendor/config-editor/index.test.ts @@ -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', + }), + ]) + }) +}) diff --git a/vendor/config-editor/index.ts b/vendor/config-editor/index.ts new file mode 100644 index 0000000..f3a11e6 --- /dev/null +++ b/vendor/config-editor/index.ts @@ -0,0 +1,1084 @@ +import { isMap, parseDocument } from 'yaml' + +export type ConfigValue = + | null + | boolean + | number + | string + | ConfigValue[] + | { [key: string]: ConfigValue } + +export type ConfigObject = Record + +export interface ConfigDiagnostic { + path: Array + code: + | 'invalid-root' + | 'duplicate-name' + | 'missing-name' + | 'missing-type' + | 'missing-reference' + | 'invalid-rule' + message: string + severity: 'error' | 'warning' +} + +export interface ConfigDocument { + data: ConfigObject + yaml: string + revision: string + diagnostics: ConfigDiagnostic[] +} + +export interface PatchTarget { + path: string[] + /** Field used as semantic identity for entries in an array. */ + key?: string + /** Identity value for a named resource. */ + id?: string +} + +export type ConfigPatchOperation = + | { + op: 'set' + target: PatchTarget + expectedHash: string + value: ConfigValue + } + | { + op: 'remove' + target: PatchTarget + expectedHash: string + } + | { + op: 'upsert' + target: Required + expectedHash: string + value: ConfigObject + } + | { + op: 'delete' + target: Required + expectedHash: string + } + | { + op: 'order' + target: PatchTarget + expectedHash: string + previousIds: string[] + ids: string[] + } + | { + op: 'sequence-replace' + target: PatchTarget & { id: string } + expectedHash: string + value: ConfigValue + } + | { + op: 'sequence-delete' + target: PatchTarget & { id: string } + expectedHash: string + } + | { + op: 'sequence-insert' + target: PatchTarget & { id: string } + expectedHash: string + value: ConfigValue + beforeId?: string + } + | { + op: 'sequence-order' + target: PatchTarget + expectedHash: string + previousIds: string[] + ids: string[] + } + +export interface ConfigPatchV1 { + version: 1 + baseRevision: string + operations: ConfigPatchOperation[] +} + +export interface ConfigPatchConflict { + operation: ConfigPatchOperation + path: Array + reason: 'changed' | 'missing' | 'duplicate' | 'invalid-target' + current?: ConfigValue +} + +export interface ApplyPatchResult { + document: ConfigDocument + conflicts: ConfigPatchConflict[] + applied: number +} + +export type ConfigResourceReferenceKind = + | 'proxy-group-member' + | 'rule-policy' + | 'dialer-proxy' + | 'provider-proxy' + | 'listener-proxy' + | 'tunnel-proxy' + +export interface ConfigResourceReference { + path: Array + kind: ConfigResourceReferenceKind + name: string +} + +export class ConfigDocumentError extends Error { + constructor( + message: string, + readonly errors: string[], + ) { + super(message) + this.name = 'ConfigDocumentError' + } +} + +const NAMED_ARRAYS: Record = { + proxies: 'name', + 'proxy-groups': 'name', + listeners: 'name', + tunnels: 'network', +} + +const NAMED_MAPS = new Set([ + 'proxy-providers', + 'rule-providers', + 'sub-rules', + 'hosts', +]) + +function isObject(value: unknown): value is ConfigObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function clone(value: T): T { + if (Array.isArray(value)) return value.map((item) => clone(item)) as T + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, clone(item)]), + ) as T + } + return value +} + +function canonical(value: unknown): string { + if (value === undefined) return 'undefined' + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` + if (isObject(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +/** Stable, browser-safe FNV-1a hash; used for optimistic concurrency, not crypto. */ +export function hashConfigValue(value: unknown): string { + const input = canonical(value) + let hash = 2_166_136_261 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +function parseYaml(yaml: string): ConfigObject { + const doc = parseDocument(yaml, { prettyErrors: true, uniqueKeys: true }) + const errors = doc.errors.map((error) => error.message) + if (errors.length) { + throw new ConfigDocumentError('Invalid YAML configuration', errors) + } + if (!isMap(doc.contents)) { + throw new ConfigDocumentError('Configuration root must be a mapping', [ + 'Configuration root must be a mapping', + ]) + } + const value = doc.toJS({ mapAsMap: false }) as unknown + if (!isObject(value)) { + throw new ConfigDocumentError('Configuration root must be a mapping', [ + 'Configuration root must be a mapping', + ]) + } + return value +} + +export function openDocument(yaml: string): ConfigDocument { + const normalizedYaml = yaml.trim() ? yaml : '{}\n' + const data = parseYaml(normalizedYaml) + return { + data, + yaml: normalizedYaml, + revision: hashConfigValue(data), + diagnostics: validateDocument(data), + } +} + +function getAtPath( + root: ConfigObject, + path: string[], +): ConfigValue | undefined { + let current: ConfigValue | undefined = root + for (const segment of path) { + if (current === undefined || !isObject(current)) return undefined + current = current[segment] + if (current === undefined) return undefined + } + return current +} + +function ensureParent(root: ConfigObject, path: string[]): ConfigObject | null { + let current: ConfigObject = root + for (const segment of path.slice(0, -1)) { + const next = current[segment] + if (next === undefined) { + const created: ConfigObject = {} + current[segment] = created + current = created + } else if (isObject(next)) { + current = next + } else { + return null + } + } + return current +} + +function valuesEqual(a: unknown, b: unknown): boolean { + return canonical(a) === canonical(b) +} + +function namedArrayDiff( + section: string, + key: string, + before: ConfigValue[], + after: ConfigValue[], +): ConfigPatchOperation[] | null { + const beforeById = new Map() + const afterById = new Map() + for (const value of before) { + if (!isObject(value) || typeof value[key] !== 'string') return null + const id = String(value[key]) + if (beforeById.has(id)) return null + beforeById.set(id, value) + } + for (const value of after) { + if (!isObject(value) || typeof value[key] !== 'string') return null + const id = String(value[key]) + if (afterById.has(id)) return null + afterById.set(id, value) + } + + const operations: ConfigPatchOperation[] = [] + for (const [id, previous] of beforeById) { + const next = afterById.get(id) + if (!next) { + operations.push({ + op: 'delete', + target: { path: [section], key, id }, + expectedHash: hashConfigValue(previous), + }) + } else if (!valuesEqual(previous, next)) { + operations.push({ + op: 'upsert', + target: { path: [section], key, id }, + expectedHash: hashConfigValue(previous), + value: clone(next), + }) + } + } + for (const [id, next] of afterById) { + if (beforeById.has(id)) continue + operations.push({ + op: 'upsert', + target: { path: [section], key, id }, + expectedHash: hashConfigValue(undefined), + value: clone(next), + }) + } + + const beforeOrder = before.map((value) => + String((value as ConfigObject)[key]), + ) + const afterOrder = after.map((value) => String((value as ConfigObject)[key])) + if (!valuesEqual(beforeOrder, afterOrder)) { + operations.push({ + op: 'order', + target: { path: [section], key }, + expectedHash: hashConfigValue( + beforeOrder.filter((id) => afterById.has(id)), + ), + previousIds: beforeOrder.filter((id) => afterById.has(id)), + ids: afterOrder, + }) + } + return operations +} + +function namedMapDiff( + section: string, + before: ConfigObject, + after: ConfigObject, +): ConfigPatchOperation[] { + const operations: ConfigPatchOperation[] = [] + for (const [id, previous] of Object.entries(before)) { + const next = after[id] + if (next === undefined) { + operations.push({ + op: 'remove', + target: { path: [section, id] }, + expectedHash: hashConfigValue(previous), + }) + } else if (!valuesEqual(previous, next)) { + operations.push({ + op: 'set', + target: { path: [section, id] }, + expectedHash: hashConfigValue(previous), + value: clone(next), + }) + } + } + for (const [id, next] of Object.entries(after)) { + if (before[id] !== undefined) continue + operations.push({ + op: 'set', + target: { path: [section, id] }, + expectedHash: hashConfigValue(undefined), + value: clone(next), + }) + } + return operations +} + +function sequenceEntries(values: ConfigValue[]) { + const occurrences = new Map() + return values.map((value) => { + const hash = hashConfigValue(value) + const occurrence = occurrences.get(hash) ?? 0 + occurrences.set(hash, occurrence + 1) + return { id: `${hash}:${occurrence}`, hash, value } + }) +} + +function sequenceDiff( + section: string, + before: ConfigValue[], + after: ConfigValue[], +): ConfigPatchOperation[] { + const previous = sequenceEntries(before) + const next = sequenceEntries(after) + const previousById = new Map(previous.map((entry) => [entry.id, entry])) + const nextById = new Map(next.map((entry) => [entry.id, entry])) + const operations: ConfigPatchOperation[] = [] + const consumedPrevious = new Set() + const consumedNext = new Set() + + // An in-place edit is represented as replace rather than delete+insert. + for (let index = 0; index < Math.min(previous.length, next.length); index++) { + const oldEntry = previous[index]! + const newEntry = next[index]! + if (oldEntry.id === newEntry.id) continue + if (!nextById.has(oldEntry.id) && !previousById.has(newEntry.id)) { + operations.push({ + op: 'sequence-replace', + target: { path: [section], id: oldEntry.id }, + expectedHash: oldEntry.hash, + value: clone(newEntry.value), + }) + consumedPrevious.add(oldEntry.id) + consumedNext.add(newEntry.id) + } + } + + for (const entry of previous) { + if (consumedPrevious.has(entry.id) || nextById.has(entry.id)) continue + operations.push({ + op: 'sequence-delete', + target: { path: [section], id: entry.id }, + expectedHash: entry.hash, + }) + } + for (let index = 0; index < next.length; index++) { + const entry = next[index]! + if (consumedNext.has(entry.id) || previousById.has(entry.id)) continue + const beforeId = next + .slice(index + 1) + .find((candidate) => previousById.has(candidate.id))?.id + operations.push({ + op: 'sequence-insert', + target: { path: [section], id: entry.id }, + expectedHash: hashConfigValue(undefined), + value: clone(entry.value), + ...(beforeId ? { beforeId } : {}), + }) + } + + const retainedPrevious = previous + .filter( + (entry) => nextById.has(entry.id) && !consumedPrevious.has(entry.id), + ) + .map((entry) => entry.id) + const retainedNext = next + .filter( + (entry) => previousById.has(entry.id) && !consumedNext.has(entry.id), + ) + .map((entry) => entry.id) + if (!valuesEqual(retainedPrevious, retainedNext)) { + operations.push({ + op: 'sequence-order', + target: { path: [section] }, + expectedHash: hashConfigValue(retainedPrevious), + previousIds: retainedPrevious, + ids: retainedNext, + }) + } + return operations +} + +export function diffDocument( + base: ConfigDocument | string, + draft: ConfigDocument | string, +): ConfigPatchV1 { + const before = typeof base === 'string' ? openDocument(base) : base + const after = typeof draft === 'string' ? openDocument(draft) : draft + const operations: ConfigPatchOperation[] = [] + const keys = new Set([ + ...Object.keys(before.data), + ...Object.keys(after.data), + ]) + + for (const key of keys) { + const previous = before.data[key] + const next = after.data[key] + if (valuesEqual(previous, next)) continue + const semanticKey = NAMED_ARRAYS[key] + if (semanticKey && Array.isArray(previous) && Array.isArray(next)) { + const semantic = namedArrayDiff(key, semanticKey, previous, next) + if (semantic) { + operations.push(...semantic) + continue + } + } + if (NAMED_MAPS.has(key) && isObject(previous) && isObject(next)) { + operations.push(...namedMapDiff(key, previous, next)) + continue + } + if (key === 'rules' && Array.isArray(previous) && Array.isArray(next)) { + operations.push(...sequenceDiff(key, previous, next)) + continue + } + if (next === undefined) { + operations.push({ + op: 'remove', + target: { path: [key] }, + expectedHash: hashConfigValue(previous), + }) + } else { + operations.push({ + op: 'set', + target: { path: [key] }, + expectedHash: hashConfigValue(previous), + value: clone(next), + }) + } + } + + return { version: 1, baseRevision: before.revision, operations } +} + +/** Replace the JS view while preserving untouched top-level YAML nodes. */ +export function replaceDocumentData( + source: ConfigDocument | string, + nextData: ConfigObject, +): ConfigDocument { + const current = typeof source === 'string' ? openDocument(source) : source + const touched = new Set() + const keys = new Set([...Object.keys(current.data), ...Object.keys(nextData)]) + for (const key of keys) { + if (!valuesEqual(current.data[key], nextData[key])) touched.add(key) + } + return openDocument(stringifyTouched(current.yaml, nextData, touched)) +} + +function conflict( + operation: ConfigPatchOperation, + reason: ConfigPatchConflict['reason'], + current?: ConfigValue, +): ConfigPatchConflict { + return { operation, path: operation.target.path, reason, current } +} + +function applyNamedOperation( + root: ConfigObject, + operation: Extract, +): ConfigPatchConflict | undefined { + const collection = getAtPath(root, operation.target.path) + if (!Array.isArray(collection)) { + if ( + operation.op === 'upsert' && + operation.expectedHash === hashConfigValue(undefined) + ) { + const parent = ensureParent(root, operation.target.path) + const field = operation.target.path.at(-1) + if (!parent || !field) return conflict(operation, 'invalid-target') + parent[field] = [clone(operation.value)] + return + } + return conflict(operation, 'invalid-target', collection) + } + const matches = collection + .map((value, index) => ({ value, index })) + .filter( + ({ value }) => + isObject(value) && + String(value[operation.target.key]) === operation.target.id, + ) + if (matches.length > 1) return conflict(operation, 'duplicate') + const match = matches[0] + if (!match) { + if ( + operation.op === 'upsert' && + operation.expectedHash === hashConfigValue(undefined) + ) { + collection.push(clone(operation.value)) + return + } + return conflict(operation, 'missing') + } + if (hashConfigValue(match.value) !== operation.expectedHash) { + return conflict(operation, 'changed', match.value) + } + if (operation.op === 'delete') collection.splice(match.index, 1) + else collection[match.index] = clone(operation.value) +} + +function applyOrderOperation( + root: ConfigObject, + operation: Extract, +): ConfigPatchConflict | undefined { + const collection = getAtPath(root, operation.target.path) + if (!Array.isArray(collection) || !operation.target.key) { + return conflict(operation, 'invalid-target', collection) + } + const keyed = new Map() + for (const value of collection) { + if (!isObject(value) || typeof value[operation.target.key] !== 'string') { + return conflict(operation, 'invalid-target', collection) + } + const id = String(value[operation.target.key]) + if (keyed.has(id)) return conflict(operation, 'duplicate') + keyed.set(id, value) + } + const currentOrder = [...keyed.keys()].filter((id) => + operation.previousIds.includes(id), + ) + if (operation.previousIds.some((id) => !keyed.has(id))) { + return conflict(operation, 'missing', currentOrder) + } + // Unknown resources added by a refreshed subscription remain at the end. + if (hashConfigValue(currentOrder) !== operation.expectedHash) { + return conflict(operation, 'changed', currentOrder) + } + const ordered = operation.ids + .map((id) => keyed.get(id)) + .filter((v) => v !== undefined) + const unknown = collection.filter((value) => { + const id = String((value as ConfigObject)[operation.target.key!]) + return !operation.ids.includes(id) + }) + collection.splice(0, collection.length, ...ordered, ...unknown) +} + +function applySequenceOperation( + root: ConfigObject, + operation: Extract< + ConfigPatchOperation, + { + op: + | 'sequence-replace' + | 'sequence-delete' + | 'sequence-insert' + | 'sequence-order' + } + >, +): ConfigPatchConflict | undefined { + const collection = getAtPath(root, operation.target.path) + if (!Array.isArray(collection)) { + return conflict(operation, 'invalid-target', collection) + } + const entries = sequenceEntries(collection) + if (operation.op === 'sequence-order') { + const byId = new Map(entries.map((entry) => [entry.id, entry.value])) + if (operation.previousIds.some((id) => !byId.has(id))) { + return conflict( + operation, + 'missing', + entries.map((entry) => entry.id), + ) + } + const currentOrder = entries + .map((entry) => entry.id) + .filter((id) => operation.previousIds.includes(id)) + if (hashConfigValue(currentOrder) !== operation.expectedHash) { + return conflict(operation, 'changed', currentOrder) + } + const ordered = operation.ids + .map((id) => byId.get(id)) + .filter((value) => value !== undefined) + const unknown = entries + .filter((entry) => !operation.ids.includes(entry.id)) + .map((entry) => entry.value) + collection.splice(0, collection.length, ...ordered, ...unknown) + return + } + + const index = entries.findIndex((entry) => entry.id === operation.target.id) + if (operation.op === 'sequence-insert') { + if (index >= 0) return conflict(operation, 'duplicate', collection[index]) + let targetIndex = collection.length + if (operation.beforeId) { + targetIndex = entries.findIndex( + (entry) => entry.id === operation.beforeId, + ) + if (targetIndex < 0) return conflict(operation, 'missing') + } + collection.splice(targetIndex, 0, clone(operation.value)) + return + } + if (index < 0) return conflict(operation, 'missing') + if (entries[index]!.hash !== operation.expectedHash) { + return conflict(operation, 'changed', collection[index]) + } + if (operation.op === 'sequence-delete') collection.splice(index, 1) + else collection[index] = clone(operation.value) +} + +function isSequenceOperation( + operation: ConfigPatchOperation, +): operation is Extract< + ConfigPatchOperation, + { + op: + | 'sequence-replace' + | 'sequence-delete' + | 'sequence-insert' + | 'sequence-order' + } +> { + return operation.op.startsWith('sequence-') +} + +function stringifyTouched( + sourceYaml: string, + data: ConfigObject, + touched: Set, +): string { + const doc = parseDocument(sourceYaml, { + prettyErrors: true, + uniqueKeys: true, + }) + if (!isMap(doc.contents)) return sourceYaml + for (const key of touched) { + if (data[key] === undefined) doc.delete(key) + else doc.set(key, doc.createNode(data[key])) + } + // 本地改动(上游是 `return String(doc)`,见 ORIGIN.md): + // flowCollectionPadding 关掉,保持 `[a, b]` 而不是被改写成 `[ a, b ]`; + // lineWidth 设 0 关掉自动折行,否则长订阅地址会被折断。 + return doc.toString({ flowCollectionPadding: false, lineWidth: 0 }) +} + +export function applyPatch( + current: ConfigDocument | string, + patch: ConfigPatchV1, +): ApplyPatchResult { + if (patch.version !== 1) { + throw new Error( + `Unsupported configuration patch version: ${String(patch.version)}`, + ) + } + const source = typeof current === 'string' ? openDocument(current) : current + const data = clone(source.data) + const conflicts: ConfigPatchConflict[] = [] + const touched = new Set() + let applied = 0 + + for (const operation of patch.operations) { + const top = operation.target.path[0] + if (top) touched.add(top) + let opConflict: ConfigPatchConflict | undefined + if (operation.op === 'upsert' || operation.op === 'delete') { + opConflict = applyNamedOperation(data, operation) + } else if (operation.op === 'order') { + opConflict = applyOrderOperation(data, operation) + } else if (isSequenceOperation(operation)) { + opConflict = applySequenceOperation(data, operation) + } else { + const parent = ensureParent(data, operation.target.path) + const field = operation.target.path.at(-1) + if (!parent || !field) { + opConflict = conflict(operation, 'invalid-target') + } else { + const currentValue = getAtPath(data, operation.target.path) + if (hashConfigValue(currentValue) !== operation.expectedHash) { + opConflict = conflict(operation, 'changed', currentValue) + } else if (operation.op === 'remove') { + delete parent[field] + } else { + parent[field] = clone(operation.value) + } + } + } + if (opConflict) conflicts.push(opConflict) + else applied++ + } + + const yaml = stringifyTouched(source.yaml, data, touched) + return { document: openDocument(yaml), conflicts, applied } +} + +function resourceNames(data: ConfigObject): Set { + const names = new Set([ + 'DIRECT', + 'REJECT', + 'REJECT-DROP', + 'REJECT-TINYGIF', + 'PASS', + 'COMPATIBLE', + 'GLOBAL', + ]) + for (const section of ['proxies', 'proxy-groups'] as const) { + const entries = data[section] + if (!Array.isArray(entries)) continue + for (const entry of entries) { + if (isObject(entry) && typeof entry.name === 'string') + names.add(entry.name) + } + } + return names +} + +function mapNames(data: ConfigObject, section: string): Set { + const value = data[section] + return isObject(value) ? new Set(Object.keys(value)) : new Set() +} + +/** + * Split a Mihomo rule at top-level commas only. Logical rules contain nested + * comma-separated expressions, and quoted payloads may contain commas too, so + * a bare String.split(',') corrupts both their policy lookup and round-trips. + */ +export function splitRuleFields(rule: string): string[] { + const fields: string[] = [] + let field = '' + let depth = 0 + let quote: '"' | "'" | null = null + let escaped = false + + for (const character of rule) { + if (escaped) { + field += character + escaped = false + continue + } + if (character === '\\') { + field += character + escaped = true + continue + } + if (quote) { + field += character + if (character === quote) quote = null + continue + } + if (character === '"' || character === "'") { + quote = character + field += character + continue + } + if (character === '(' || character === '[' || character === '{') { + depth++ + field += character + continue + } + if (character === ')' || character === ']' || character === '}') { + depth = Math.max(0, depth - 1) + field += character + continue + } + if (character === ',' && depth === 0) { + fields.push(field.trim()) + field = '' + continue + } + field += character + } + fields.push(field.trim()) + return fields +} + +function rulePolicy(rule: string): string | undefined { + const fields = splitRuleFields(rule) + const type = fields[0]?.toUpperCase() + if (type === 'MATCH') return fields[1] + // SUB-RULE's final field is a sub-rule collection name, not a proxy policy. + if (type === 'SUB-RULE') return undefined + return fields[2] +} + +function collectRuleReferences( + rules: ConfigValue | undefined, + path: Array, +): ConfigResourceReference[] { + if (!Array.isArray(rules)) return [] + return rules.flatMap((rule, index) => { + if (typeof rule !== 'string') return [] + const policy = rulePolicy(rule) + return policy + ? [{ path: [...path, index], kind: 'rule-policy' as const, name: policy }] + : [] + }) +} + +function collectKeyReferences( + value: ConfigValue | undefined, + path: Array, + key: string, + kind: ConfigResourceReferenceKind, +): ConfigResourceReference[] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => + collectKeyReferences(item, [...path, index], key, kind), + ) + } + if (!isObject(value)) return [] + + const references: ConfigResourceReference[] = [] + for (const [field, child] of Object.entries(value)) { + const childPath = [...path, field] + if (field === key && typeof child === 'string') { + references.push({ path: childPath, kind, name: child }) + } + references.push(...collectKeyReferences(child, childPath, key, kind)) + } + return references +} + +/** Return every named proxy/proxy-group reference in a config document. */ +export function listResourceReferences( + data: ConfigObject, +): ConfigResourceReference[] { + const references: ConfigResourceReference[] = [] + const groups = data['proxy-groups'] + if (Array.isArray(groups)) { + groups.forEach((group, groupIndex) => { + if (!isObject(group) || !Array.isArray(group.proxies)) return + group.proxies.forEach((name, memberIndex) => { + if (typeof name !== 'string') return + references.push({ + path: ['proxy-groups', groupIndex, 'proxies', memberIndex], + kind: 'proxy-group-member', + name, + }) + }) + }) + } + + references.push(...collectRuleReferences(data.rules, ['rules'])) + const subRules = data['sub-rules'] + if (isObject(subRules)) { + for (const [name, rules] of Object.entries(subRules)) { + references.push(...collectRuleReferences(rules, ['sub-rules', name])) + } + } + + // dialer-proxy has the same named-resource meaning wherever Mihomo accepts + // it (nodes, providers, overrides and NTP), so scan it across the document. + references.push( + ...collectKeyReferences(data, [], 'dialer-proxy', 'dialer-proxy'), + ) + for (const section of ['proxy-providers', 'rule-providers'] as const) { + references.push( + ...collectKeyReferences( + data[section], + [section], + 'proxy', + 'provider-proxy', + ), + ) + } + references.push( + ...collectKeyReferences( + data.listeners, + ['listeners'], + 'proxy', + 'listener-proxy', + ), + ...collectKeyReferences(data.tunnels, ['tunnels'], 'proxy', 'tunnel-proxy'), + ) + return references +} + +export function findResourceReferences( + data: ConfigObject, + name: string, +): ConfigResourceReference[] { + return listResourceReferences(data).filter( + (reference) => reference.name === name, + ) +} + +export function validateDocument(data: ConfigObject): ConfigDiagnostic[] { + const diagnostics: ConfigDiagnostic[] = [] + const names = resourceNames(data) + const proxyProviders = mapNames(data, 'proxy-providers') + const ruleProviders = mapNames(data, 'rule-providers') + + for (const [section, identity] of Object.entries(NAMED_ARRAYS)) { + const entries = data[section] + if (!Array.isArray(entries)) continue + const seen = new Set() + entries.forEach((entry, index) => { + if ( + !isObject(entry) || + typeof entry[identity] !== 'string' || + !entry[identity] + ) { + diagnostics.push({ + path: [section, index, identity], + code: 'missing-name', + message: `${section} entry requires ${identity}`, + severity: 'error', + }) + return + } + const id = String(entry[identity]) + if (seen.has(id)) { + diagnostics.push({ + path: [section, index, identity], + code: 'duplicate-name', + message: `Duplicate ${section} identity: ${id}`, + severity: 'error', + }) + } + seen.add(id) + if ( + ['proxies', 'proxy-groups', 'listeners'].includes(section) && + typeof entry.type !== 'string' + ) { + diagnostics.push({ + path: [section, index, 'type'], + code: 'missing-type', + message: `${section} entry requires type`, + severity: 'error', + }) + } + if (section === 'proxy-groups') { + const providers = entry.use + if (Array.isArray(providers)) { + providers.forEach((reference, refIndex) => { + if ( + typeof reference === 'string' && + !proxyProviders.has(reference) + ) { + diagnostics.push({ + path: [section, index, 'use', refIndex], + code: 'missing-reference', + message: `Unknown proxy provider: ${reference}`, + severity: 'error', + }) + } + }) + } + } + }) + } + + for (const reference of listResourceReferences(data)) { + if (names.has(reference.name)) continue + diagnostics.push({ + path: reference.path, + code: 'missing-reference', + message: `Unknown proxy or group: ${reference.name}`, + // Composite rules are parsed at top-level only. Keep their policy + // diagnostics advisory and let Mihomo's validator remain authoritative. + severity: reference.kind === 'rule-policy' ? 'warning' : 'error', + }) + } + + const ruleCollections: Array<{ + path: Array + rules: ConfigValue | undefined + }> = [{ path: ['rules'], rules: data.rules }] + const subRules = data['sub-rules'] + if (isObject(subRules)) { + for (const [name, rules] of Object.entries(subRules)) { + ruleCollections.push({ path: ['sub-rules', name], rules }) + } + } + for (const collection of ruleCollections) { + if (!Array.isArray(collection.rules)) continue + collection.rules.forEach((rule, index) => { + if (typeof rule !== 'string' || !rule.includes(',')) { + diagnostics.push({ + path: [...collection.path, index], + code: 'invalid-rule', + message: 'Rule must be a comma-separated string', + severity: 'error', + }) + return + } + const parts = splitRuleFields(rule) + if (parts[0] === 'RULE-SET' && parts[1] && !ruleProviders.has(parts[1])) { + diagnostics.push({ + path: [...collection.path, index], + code: 'missing-reference', + message: `Unknown rule provider: ${parts[1]}`, + severity: 'error', + }) + } + if ( + parts[0]?.toUpperCase() === 'SUB-RULE' && + parts[2] && + (!isObject(subRules) || !(parts[2] in subRules)) + ) { + diagnostics.push({ + path: [...collection.path, index], + code: 'missing-reference', + message: `Unknown sub-rule: ${parts[2]}`, + severity: 'error', + }) + } + }) + } + return diagnostics +} + +export const schemaSections = [ + 'general', + 'experimental', + 'profile', + 'iptables', + 'tls', + 'proxies', + 'proxy-providers', + 'proxy-groups', + 'listeners', + 'rule-providers', + 'sub-rules', + 'rules', + 'hosts', + 'ntp', + 'dns', + 'tun', + 'tuic-server', + 'authentication', + 'tunnels', + 'sniffer', + 'clash-for-android', +] as const