mihomo-cli 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.0] - 2026-08-22
4
+
5
+ ### 修复
6
+
7
+ - **错误响应体绕过大小上限,可致 OOM** - `!response.ok` 分支直接 `await response.json()`,不经流式大小检查。实测 60MB 错误体使客户端 RSS 增长 303MB——攻击者只需返回非 2xx 即可绕过 50MB 防护。现错误体限量 64KB 读取(仅用于诊断),修后 RSS 增长 9MB
8
+ - **机场返回错误 JSON 会覆盖磁盘上可用的订阅** - 下载后只要内容能解析成对象就原子覆盖写盘。机场返回 `{"error":"quota exceeded"}` 之类响应时报「已更新 (0 节点)」,把原本可用的订阅**不可恢复地覆盖**,随后 mihomo 带零节点启动导致断网;且这在 `start` 的自动更新路径上,用户无操作即触发。现写盘前要求 `proxies`/`proxy-groups`/`proxy-providers` 至少其一非空,并提取服务端错误信息作为提示
9
+ - **`ps` 输出截断致测速实例泄漏并占用端口** - `isProcessCommandMatching` 未带 `-ww`,BSD/macOS 的 `ps` 即使 stdout 非终端也把 command 列截断到 79 列。测速实例的匹配串(`test/runtime/config.yaml`)起始偏移随用户名增长(`alice` 为 80、`jonathan.smith` 为 98),常见家目录下均越界 → 匹配恒失败 → `stopTestInstance` 跳过 SIGKILL 却仍删 pid 文件,内核残留占着 27890/29090 且再无记录,下次 `sub test` 直接启动失败
10
+ - **`sub add` 失败时劫持当前活跃订阅** - `setDefaultSubscription` 在下载之前执行,回滚时 `removeSubscription` 把活跃订阅落到列表首项而非用户原选择。复现:活跃为 `work`、列表为 `[airport-a, work]`,添加一个不可达 URL 失败后活跃变成 `airport-a`,下次 `start` 静默连错机场。现切换移到下载成功之后
11
+ - **`MIHOMO_CLI_DAEMON_LABEL` 未校验导致 root 任意路径写** - 该值经 `path.join` 拼成 plist 路径后,是 `sudo install -m 644 -o root` 的写入目标与 `sudo rm -f` 的删除目标,而 `path.join` 会折叠 `..`(`../../etc/sudoers.d/evil` → `/etc/sudoers.d/evil.plist`),内容还部分可控。现加字符集校验:非法值回退默认标签,并在 `enableDaemon`/`disableDaemon` 入口报错
12
+ - **覆写注入节点的 `exclude-filter` 误排除同前缀节点** - mihomo 的 `exclude-filter` 是无锚点正则搜索。注入名为 `HK` 的节点后,订阅里的 `HK-01`/`HK-02` 全被踢出 `include-all` 分组。现改为 `^(?:...)$` 整名锚定
13
+ - **覆写 YAML 笔误抛裸 `TypeError` 并打印堆栈** - `validateConfig` 的类型断言无校验,四种常见笔误会崩溃:`proxies` 含空列表项、`rules` 漏写 `-` 成标量、`proxy-groups` 写成映射、`rules` 含非字符串。用户配置错误被当成程序 bug。现全部转为带修正提示的 `CliError`
14
+ - **`~key`/`+key` 作用于非数组时静默损坏配置** - 静默包成单元素数组:`~dns: {enable: true}` 把映射 `dns` 变成 `[{enable: true}]` 并丢掉原有字段,而 mihomo 要求 `dns` 是映射;`log-level+: debug` → `["debug"]` 同理。现报错并提示改用 `key!` 或直接写 `key`;目标不存在时仍放行(新增数组的正常用法)
15
+ - **`maskUrl` 逗号切分致 token 明文泄漏** - 无条件按逗号切分,`?nodes=us,hk&token=SECRET` 被劈开后两段都识别不出 token 参数,密钥明文输出。同一根因还让 query 含逗号的合法单 URL(`?flag=clash,meta`)被误判多源、`sub add` 报「无效的 URL」而无法添加。现统一判据为「切分后每段都是合法 http(s) URL 且不止一段」
16
+ - **`settings.json` 为合法 JSON 但非对象时绕过损坏恢复** - `null`/`[]`/`123`/`"hi"` 都直接进缓存,不备份不告警:`null` 让 `getSubscriptions()` 抛裸 `TypeError` 且缓存判定恒失效,字符串被展开成 `{"0":"h","1":"i",...}`。现一并走备份+回退分支
17
+ - **`subscriptions` 非数组时被按字符展开** - 手改成 `"oops"` 后 `addSubscription` 写出 `["o","o","p","s",{...}]` 且不报错。现在唯一读取入口 `getSubscriptions()` 收口校验,并滤掉缺 name/url 的残缺条目
18
+ - **`reset` 忽略停止进程的结果** - root 实例(TUN)下走 `sudo pkill`,用户取消密码时失败的 pid 被静默丢弃,仍继续删除数据,留下孤儿 root 进程跑在已删配置上。现删数据前复查并中止
19
+ - **`reset --full` 残留含密钥的备份文件** - `settings.json.bak`(损坏恢复时生成)带 `controller_secret` 与订阅 token 明文留下,与「已重置: 设置」矛盾。现纳入删除路径
20
+ - **`reset` 结果依赖参数顺序** - `subs` 目标的后置钩子会重建 `settings.json`,故 `reset settings subs` 留下 `{}` 而 `reset subs settings` 才真删。现按注册表顺序执行
21
+ - **`match` 的订阅名匹配与 `sub use` 口径不一致** - `sub use home` 能切到订阅 `Home`(模糊匹配大小写不敏感),但 `match: {subscription: home}` 精确比对匹配不上。现统一为大小写不敏感
22
+ - **`parseIntArg` 接受危险值** - 无范围校验:`-j 0` 让测速起 0 个 worker、结果全空洞、被报成「所有节点失败」(伪造结果);`-t 5s` 静默取 5ms 让全部节点超时。现非正整数一律报错,并把 `test`/`clean` 的参数校验移到运行状态检查之前
23
+ - **合并订阅的错误指向被连带取消的 URL** - 任一 URL 失败即中断其余请求,按顺序取第一个错误报出的往往是被取消的那条,真正的 403/token 过期被隐藏。现优先报非取消类错误
24
+ - **`dir` 子命令的错误绕过统一渲染** - `cmdDirectory` 用 `void` 丢弃 async 分发的 Promise,`dir open <未知目标>` 抛的错误退化成「未处理的 Promise 拒绝」,丢掉标签颜色与可用目标列表
25
+ - **`ow`/`dir` 未知子命令静默回落** - `ow onn` 静默打印列表且退出码 0(对比 `sub adz` 会报错并给纠错建议)。现补齐纠错提示
26
+ - **测速实例的 pid 记录时机存在泄漏窗口** - spawn 与写 pid 文件之间被 Ctrl+C 中断时,只认 pid 文件的清理逻辑会漏掉 detached 子进程。现增加内存记录作为第二来源
27
+ - **`reset` 保活取消路径退出码为 0** - `console.error` + `return` 使「重置中止」被脚本误判成功,且绕过统一渲染
28
+ - **`mihomo on`/`off` 丢弃启动选项** - 唯二不透传后续参数的快捷命令,`mihomo on -s` 静默吞掉 `-s`,而 README 声明其与 `ow on` 等价
29
+ - **`restartDaemon` 抛裸 `Error`** - 最后一处未迁移的数据层预期错误
30
+
31
+ ### 新增
32
+
33
+ - **平台守卫** - `package.json` 声明 `"os": ["darwin"]`,`main()` 开头校验平台(豁免 `help`/`version`,`MIHOMO_CLI_ALLOW_ANY_PLATFORM=1` 为开发逃生阀)。此前非 macOS 上是「部分成功」:`status`/`sub` 看着正常,`daemon on` 输完 root 密码才撞 `/Library/LaunchDaemons`,`ui` 报告成功却什么都没打开(`open` 命令缺失被吞掉,且 Debian 的 `open` 指向 `run-mailcap` 会把 URL 当附件处理)。守卫先于目录创建,避免在不支持的平台留下数据目录
34
+ - **`sub remove` 模糊匹配需确认** - 精确名称直接删除;模糊命中时展示完整名称并要求确认(`-y`/`--yes` 跳过)。此前 `sub remove air` 会无提示删掉 `production-airport`
35
+ - **`subs` 别名** - 与 `directory` 的 `dirs` 对称,落地命名规范的「简写复数」档
36
+ - **GitHub Actions CI** - 在 `macos-latest` 上跑 typecheck / lint / test / build
37
+ - **`prepublishOnly` 钩子** - `dist/` 被 gitignore 且此前无发布钩子,漏跑构建即发布陈旧或缺失产物
38
+
39
+ ### 变更
40
+
41
+ - **破坏性操作在非交互环境报错而非静默取消** - `reset`(无 `-y`)与 `sub remove`(模糊匹配)在管道/CI 下此前打印「已取消」并退出 0,脚本会误判操作已完成。现报错退出 1 并提示加 `-y` 或用完整名称
42
+ - **`confirmPrompt` 收敛到 `commands/shared.ts`** 并增加 TTY 守卫(此前在非交互环境会挂住等输入)
43
+
44
+ ### 内部
45
+
46
+ - **单测从 55 增至 101** - 新增覆盖:配置形态校验、`exclude-filter` 锚定、覆写数组语义误用、`match` 大小写、`parseIntArg` 边界、多源 URL 逗号判据
47
+ - **`CODE_REVIEW.md` 重写** - 上一轮(v2.9.x 基线)的「仍待处理」清单已逐项复核:#12/#14 实际已修复,#10 的后果比原描述严重(是 token 泄漏而非仅显示切碎),#17 的建议不可行——上游 v1.19.30 的 127 个资产中零 checksum 文件,故真实缺口是下载地址的 host 未钉死。新增 9 项待处理(tar symlink 致任意文件 chmod 755、热重载信任任意 9090 响应、`Subscription-Userinfo` 边界等)
48
+ - **`README.md` / `CLAUDE.md` 校正** - 平台说明从「Windows / Linux 正在适配中」改为「仅支持 macOS」(此前无对应代码);覆写实为默认启用(此前文档教用户先 `ow on`);`log -o` 是系统默认程序而非编辑器;补齐 `logs current`、长选项、`reset`/`dir open` 完整目标列表、分阶段调试文件;新增「选项写法」「数据保护」两节
49
+
3
50
  ## [3.6.0] - 2026-08-15
4
51
 
5
52
  ### 修复
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # mihomo-cli
2
2
 
3
- 一个基于命令行的 mihomo (Clash.Meta) 客户端,专为 macOS 设计。Windows / Linux 正在适配中,敬请期待。
3
+ 一个基于命令行的 mihomo (Clash.Meta) 客户端,**仅支持 macOS**。
4
+
5
+ 进程保活依赖 launchd、目录/UI 打开依赖 `open`、提权依赖 `sudo`,均无其他平台实现,故在非 macOS 上会直接报错退出而非部分可用。Windows / Linux 适配尚无时间表。
4
6
 
5
7
  ## 功能特性
6
8
 
7
9
  - 🌐 **订阅管理** - 添加/更新订阅,支持流量统计和到期时间显示
8
10
  - 🔄 **自动更新** - 启动时自动检查并更新过期订阅
9
- - 🔍 **模糊匹配** - `sub use` / `sub web` 支持订阅名称模糊匹配
11
+ - 🔍 **模糊匹配** - `sub use` / `web` / `update` / `remove` / `test` / `clean` 均支持订阅名称模糊匹配(大小写不敏感)
10
12
  - 🧹 **节点测速清理** - `test` 快速测试、`clean` 清理并重启;`sub test/clean` 独立进程测试任意订阅
11
13
  - 📝 **覆写配置** - 在订阅基础上进行自定义覆写,支持强制覆盖、数组合并、按 name 就地 patch、按订阅限定作用域
12
14
  - 🔄 **智能重启** - `sub use` 切换订阅、`ow on/off` 切换覆写后自动重启
@@ -88,8 +90,10 @@ mihomo ui yacd # YACD
88
90
  | `mihomo start [tun\|mixed]` | 启动/重启/切换代理模式(`-s` 跳过更新,`-u` 更新超时,`-r` 清理轮次,`-t` 超时,`-j` 并发,`--no-clean` 跳过启动自动清理) |
89
91
  | `mihomo stop` | 停止代理 |
90
92
  | `mihomo status` | 查看运行状态(含订阅流量、到期时间) |
91
- | `mihomo log` | 实时查看日志 (`-o` 用系统编辑器打开) |
93
+ | `mihomo log` | 实时查看日志 (`-o` 用系统默认程序打开) |
92
94
  | `mihomo logs` | 列出所有日志(当前 + 历史归档) |
95
+ | `mihomo logs current` | 查看当前日志(等同 `logs 0`) |
96
+ | `mihomo logs <名称/子串>` | 按文件名或子串查看指定归档日志 |
93
97
  | `mihomo logs <编号>` | 查看指定日志(`0`=当前日志,`1+`=归档日志,支持 `-n N` 指定行数、`-o` 打开) |
94
98
 
95
99
  ### 订阅管理
@@ -101,10 +105,10 @@ mihomo ui yacd # YACD
101
105
  | `mihomo sub add <url> [name]` | 添加订阅并自动切换(支持逗号分隔多 URL 合并,名称不可重复) |
102
106
  | `mihomo sub update` | 更新所有订阅 |
103
107
  | `mihomo sub update <name>` | 更新指定订阅(支持模糊匹配) |
104
- | `mihomo sub remove <name>` | 删除订阅(支持模糊匹配) |
105
- | `mihomo sub web [name]` | 打开订阅页面(无参打开默认) |
108
+ | `mihomo sub remove <name>` | 删除订阅(别名 `rm`/`delete`;精确名直接删,模糊匹配需确认,`-y` 跳过) |
109
+ | `mihomo sub web [name]` | 打开订阅页面(别名 `open`,无参打开默认,支持模糊匹配) |
106
110
  | `mihomo sub test [name]` | 测试节点连通性(独立隔离实例,无需运行主实例,`-t` 超时,`-j` 并发) |
107
- | `mihomo sub clean [name]` | 测速并清理失败节点(独立实例,不动主实例,`-r` 轮数,默认2)|
111
+ | `mihomo sub clean [name]` | 测速并清理失败节点(独立实例,不动主实例,`-r` 轮数默认 2,`-t` 超时,`-j` 并发) |
108
112
  | `mihomo test` | 测试当前节点(经运行中的主实例,`-t` 超时,`-j` 并发) |
109
113
  | `mihomo clean` | 清理失败节点并重启(经主实例,`-t` 超时,`-j` 并发,`-r` 轮数) |
110
114
 
@@ -112,9 +116,9 @@ mihomo ui yacd # YACD
112
116
 
113
117
  | 命令 | 说明 |
114
118
  | ------------------------------ | -------------------------- |
115
- | `mihomo ow` / `mihomo ow list` | 查看覆写配置状态和文件列表 |
116
- | `mihomo ow on` | 启用覆写配置(自动重启) |
117
- | `mihomo ow off` | 禁用覆写配置(自动重启) |
119
+ | `mihomo ow` / `mihomo ow list` | 查看覆写配置状态和文件列表(别名 `enable`/`disable` 亦可用于开关) |
120
+ | `mihomo ow on` | 启用覆写配置(**默认已启用**,自动重启) |
121
+ | `mihomo ow off` | 禁用覆写配置(自动重启) |
118
122
 
119
123
  ### 其他命令
120
124
 
@@ -125,8 +129,8 @@ mihomo ui yacd # YACD
125
129
  | `mihomo update` | 更新 mihomo-cli(先查 npm 最新版,已是最新则跳过重装) |
126
130
  | `mihomo ui [zash\|dash\|yacd]` | 打开 Web UI |
127
131
  | `mihomo dir` | 显示数据目录位置 |
128
- | `mihomo dir open [target]` | 打开指定目录(`root`, `subs`, `logs`, `kernel` 等) |
129
- | `mihomo reset [目标...] [--full] [-y]` | 重置用户数据(可用目标:`subs`, `logs`, `kernel`, `overwrites` 等;`--full` 删全部,`-y` 跳过确认) |
132
+ | `mihomo dir open [target]` | 打开指定目录(`root`, `subs`, `logs`, `data`, `runtime`, `kernel`) |
133
+ | `mihomo reset [目标...] [--full] [-y]` | 重置用户数据(可用目标:`subs`, `logs`, `data`, `runtime`, `settings`, `kernel`, `overwrites`, `daemon`;`--full` 删全部,`-y` 跳过确认) |
130
134
  | `mihomo version` | 显示版本信息 |
131
135
  | `mihomo help` | 显示帮助信息 |
132
136
 
@@ -139,6 +143,8 @@ mihomo ui yacd # YACD
139
143
  - `mhm`
140
144
  - `mh`
141
145
 
146
+ 子命令组亦有别名:`subscription` = `sub`/`subs`/`subscriptions`,`directory` = `dir`/`dirs`/`directories`,`overwrite` = `ow`
147
+
142
148
  ### 快捷命令
143
149
 
144
150
  常用操作的快捷方式:
@@ -235,6 +241,35 @@ mihomo kernel --mirror-all hk.gh-proxy.org
235
241
  - 同一订阅 12 小时内只自动清理一次(冷却记录在订阅缓存),避免每次启动都全量测速
236
242
  - `--no-clean` 可跳过;随时可用 `mihomo clean` / `mihomo sub clean` 手动清理
237
243
 
244
+ ## 选项写法
245
+
246
+ 带值选项支持三种等价写法,长短选项对应关系:
247
+
248
+ | 短 | 长 | 用途 | 默认 |
249
+ | --- | --- | --- | --- |
250
+ | `-t` | `--timeout` | 测速超时(ms) | 2000 |
251
+ | `-j` | `--concurrency` | 测速并发数 | 100 |
252
+ | `-r` | `--rounds` | 清理时失败节点重试轮数 | 2 |
253
+ | `-u` | `--update-timeout` | 启动时自动更新订阅超时(ms) | 10000 |
254
+ | `-n` | `--lines` | 日志显示行数 | 100 |
255
+
256
+ ```bash
257
+ mihomo test -t 3000 # 短选项 + 空格
258
+ mihomo test --timeout 3000 # 长选项 + 空格
259
+ mihomo test --timeout=3000 # 长选项 + 等号
260
+ ```
261
+
262
+ 布尔开关:`-s`(跳过订阅更新)、`--no-update`、`--no-clean`、`-y`/`--yes`(跳过确认)、`-o`(用系统默认程序打开)。
263
+
264
+ 上述数值选项只接受 **>= 1 的整数**,非法值(`0`、负数、`5s`、`abc`)会直接报错而非静默取默认值——避免 `-j 0` 之类静默产出"全部节点失败"的假结果。
265
+
266
+ ## 数据保护
267
+
268
+ - **订阅内容校验**:下载到的内容必须含 `proxies` / `proxy-groups` / `proxy-providers` 之一才写盘。机场返回配额或错误 JSON(如 `{"error":"quota exceeded"}`)时报错并**保留磁盘上原有的可用配置**,不会被覆盖
269
+ - **`sub add` 失败回滚**:下载失败时移除半成品订阅,且不改动当前活跃订阅
270
+ - **`settings.json` 损坏恢复**:格式损坏(含合法 JSON 但非对象的情况)时自动备份为 `.bak` 并回退默认设置
271
+ - **`reset` 停止确认**:需要停止进程的重置会先确认进程真的已终止,未能停止时中止重置而非留下孤儿进程跑在已删配置上
272
+
238
273
  ## 数据目录
239
274
 
240
275
  用户数据存储位置(与安装位置分离,更新不丢失):
@@ -255,7 +290,10 @@ mihomo kernel --mirror-all hk.gh-proxy.org
255
290
  ├── data/ # mihomo 运行数据(GeoIP 等,由内核自行管理)
256
291
  └── runtime/ # 运行时临时文件(stop 自动清除)
257
292
  ├── pid # 进程 PID
258
- └── config.yaml # 运行时生成的配置
293
+ ├── config.yaml # 运行时生成的配置
294
+ ├── 1.subscription.yaml # 分阶段调试:订阅原始配置
295
+ ├── 2.overwrite.yaml # 分阶段调试:应用覆写后
296
+ └── 3.system.yaml # 分阶段调试:合并系统配置后
259
297
  ```
260
298
 
261
299
  可通过环境变量 `MIHOMO_CLI_DIR` 自定义数据目录位置。
@@ -270,7 +308,7 @@ mihomo kernel --mirror-all hk.gh-proxy.org
270
308
  - `overwrite.yaml` — 主覆写文件
271
309
  - `overwrite.dns.yaml` — 按功能拆分的扩展文件(`overwrite.*.yaml` 格式)
272
310
  2. `overwrite.yaml` 始终最先加载,扩展文件按文件名排序加载
273
- 3. 使用 `mihomo ow on` 启用覆写配置(会自动重启)
311
+ 3. 覆写**默认即启用**,放好文件后重启生效(`mihomo start`);如曾 `ow off` 禁用过,用 `mihomo ow on` 重新启用(会自动重启)
274
312
 
275
313
  ### 特殊语法
276
314
 
@@ -286,14 +324,16 @@ mihomo kernel --mirror-all hk.gh-proxy.org
286
324
 
287
325
  `~key` 用于**只修改数组里某一个元素的部分字段**,而不动其余元素、也不必复制整个元素。以 `name` 为主键匹配:命中同名元素则深度合并该元素,找不到则追加。典型用途:修改订阅下发的某个 `proxy-group` 的字段(如默认选中的节点),订阅更新后依然生效。
288
326
 
327
+ > `~key` / `+key` / `key+` 都是**数组语义**:若目标键已存在且不是数组(如 `~dns` 作用于映射、`log-level+` 作用于字符串),会直接报错而非静默包成单元素数组——后者会丢掉原有字段并生成 mihomo 无法解析的配置。要覆盖非数组值请用 `key!`(强制覆盖)或直接写 `key`(深度合并)。
328
+
289
329
  ### 作用域限定(match)
290
330
 
291
331
  在覆写文件顶部加 `match:` 块,可让该文件**只对指定订阅生效**(无 `match` 则全局生效)。所列条件需全部满足(AND),条件值为数组时其内部为 OR:
292
332
 
293
333
  | 匹配键 | 作用 |
294
334
  | ------------- | ----------------------------- |
295
- | `subscription` | 按订阅名精确匹配 |
296
- | `url-domain` | 按订阅 URL 的 hostname 后缀匹配 |
335
+ | `subscription` | 按订阅名匹配(大小写不敏感,与 `sub use` 口径一致) |
336
+ | `url-domain` | 按订阅 URL 的 hostname 后缀匹配(大小写不敏感) |
297
337
 
298
338
  ### 示例
299
339
 
@@ -364,7 +404,7 @@ sudo pkill -9 mihomo
364
404
 
365
405
  ## 安全特性
366
406
 
367
- - **URL 脱敏**:订阅 URL 中的 token、key、password 等敏感参数(含 query、userinfo 及路径型令牌)自动替换为 `***`
407
+ - **URL 脱敏**:订阅 URL 中的 token、key、password 等敏感参数(含 query、userinfo 及路径型令牌)自动替换为 `***`。逗号分隔的多源订阅逐段脱敏;query 内含逗号的单条 URL 不会被误拆(否则参数被劈开会导致 token 漏脱敏)
368
408
  - **文件权限**:配置文件使用 `0o600` 权限(仅所有者可读可写),目录使用 `0o700` 权限
369
409
  - **入站默认关闭**:订阅/覆写未指定时 `allow-lan` 默认 `false`;如需局域网设备连入代理端口,可在订阅或覆写中显式开启
370
410
  - **信号处理**:优雅处理 SIGINT/SIGTERM 信号
package/dist/index.js CHANGED
@@ -3067,7 +3067,14 @@ var UI_URLS = {
3067
3067
  dash: "https://metacubex.github.io/metacubexd",
3068
3068
  yacd: "https://yacd.metacubex.one"
3069
3069
  };
3070
- var LAUNCH_DAEMON_LABEL = process.env.MIHOMO_CLI_DAEMON_LABEL || "com.mihomo-cli.daemon";
3070
+ var DEFAULT_DAEMON_LABEL = "com.mihomo-cli.daemon";
3071
+ var DAEMON_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
3072
+ function isValidDaemonLabel(label) {
3073
+ return DAEMON_LABEL_RE.test(label) && !label.includes("..");
3074
+ }
3075
+ var RAW_DAEMON_LABEL = process.env.MIHOMO_CLI_DAEMON_LABEL;
3076
+ var RAW_DAEMON_LABEL_INPUT = RAW_DAEMON_LABEL;
3077
+ var LAUNCH_DAEMON_LABEL = RAW_DAEMON_LABEL && isValidDaemonLabel(RAW_DAEMON_LABEL) ? RAW_DAEMON_LABEL : DEFAULT_DAEMON_LABEL;
3071
3078
  var CONTROLLER_PORT = 9090;
3072
3079
  var CONTROLLER_ADDR = `127.0.0.1:${CONTROLLER_PORT}`;
3073
3080
  var CONTROLLER_BASE_URL = `http://${CONTROLLER_ADDR}`;
@@ -3120,6 +3127,41 @@ var AUTO_CLEAN_THRESHOLD = 100;
3120
3127
  var AUTO_CLEAN_THRESHOLD_GITHUB = 50;
3121
3128
  var AUTO_CLEAN_COOLDOWN_HOURS = 12;
3122
3129
 
3130
+ // src/errors.ts
3131
+ var TimeoutError = class extends Error {
3132
+ constructor() {
3133
+ super("timeout");
3134
+ this.name = "TimeoutError";
3135
+ }
3136
+ };
3137
+ var CliError = class extends Error {
3138
+ hint;
3139
+ label;
3140
+ exitCode;
3141
+ constructor(message, options = {}) {
3142
+ super(message);
3143
+ this.name = "CliError";
3144
+ this.label = options.label ?? "\u9519\u8BEF";
3145
+ this.hint = options.hint === void 0 ? [] : Array.isArray(options.hint) ? options.hint : [options.hint];
3146
+ this.exitCode = options.exitCode ?? 1;
3147
+ }
3148
+ };
3149
+ function withTimeout(promise, ms) {
3150
+ return new Promise((resolve, reject) => {
3151
+ const timer = setTimeout(() => reject(new TimeoutError()), ms);
3152
+ promise.then(
3153
+ (v) => {
3154
+ clearTimeout(timer);
3155
+ resolve(v);
3156
+ },
3157
+ (e) => {
3158
+ clearTimeout(timer);
3159
+ reject(e);
3160
+ }
3161
+ );
3162
+ });
3163
+ }
3164
+
3123
3165
  // src/overwrite.ts
3124
3166
  import fs3 from "fs";
3125
3167
  import path3 from "path";
@@ -3191,62 +3233,32 @@ function rmrf(dir) {
3191
3233
  // src/settings.ts
3192
3234
  import fs2 from "fs";
3193
3235
  import path2 from "path";
3194
-
3195
- // src/errors.ts
3196
- var TimeoutError = class extends Error {
3197
- constructor() {
3198
- super("timeout");
3199
- this.name = "TimeoutError";
3200
- }
3201
- };
3202
- var CliError = class extends Error {
3203
- hint;
3204
- label;
3205
- exitCode;
3206
- constructor(message, options = {}) {
3207
- super(message);
3208
- this.name = "CliError";
3209
- this.label = options.label ?? "\u9519\u8BEF";
3210
- this.hint = options.hint === void 0 ? [] : Array.isArray(options.hint) ? options.hint : [options.hint];
3211
- this.exitCode = options.exitCode ?? 1;
3212
- }
3213
- };
3214
- function withTimeout(promise, ms) {
3215
- return new Promise((resolve, reject) => {
3216
- const timer = setTimeout(() => reject(new TimeoutError()), ms);
3217
- promise.then(
3218
- (v) => {
3219
- clearTimeout(timer);
3220
- resolve(v);
3221
- },
3222
- (e) => {
3223
- clearTimeout(timer);
3224
- reject(e);
3225
- }
3226
- );
3227
- });
3228
- }
3229
-
3230
- // src/settings.ts
3231
3236
  var settingsCache = null;
3232
3237
  function readSettings() {
3233
3238
  if (settingsCache !== null) return settingsCache;
3234
3239
  ensureDirs();
3235
3240
  if (fs2.existsSync(PATHS.settingsFile)) {
3241
+ let parsed;
3236
3242
  try {
3237
- const content = fs2.readFileSync(PATHS.settingsFile, "utf8");
3238
- settingsCache = JSON.parse(content);
3239
- return settingsCache;
3243
+ parsed = JSON.parse(fs2.readFileSync(PATHS.settingsFile, "utf8"));
3240
3244
  } catch {
3241
- try {
3242
- fs2.copyFileSync(PATHS.settingsFile, `${PATHS.settingsFile}.bak`);
3243
- console.warn(`\u8B66\u544A: settings.json \u683C\u5F0F\u635F\u574F\uFF0C\u5DF2\u5907\u4EFD\u5230 ${PATHS.settingsFile}.bak\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u8BBE\u7F6E`);
3244
- } catch {
3245
- console.warn("\u8B66\u544A: settings.json \u683C\u5F0F\u635F\u574F\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u8BBE\u7F6E");
3246
- }
3247
- settingsCache = {};
3248
- return settingsCache;
3245
+ return recoverCorruptedSettings();
3246
+ }
3247
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3248
+ return recoverCorruptedSettings();
3249
3249
  }
3250
+ settingsCache = parsed;
3251
+ return settingsCache;
3252
+ }
3253
+ settingsCache = {};
3254
+ return settingsCache;
3255
+ }
3256
+ function recoverCorruptedSettings() {
3257
+ try {
3258
+ fs2.copyFileSync(PATHS.settingsFile, `${PATHS.settingsFile}.bak`);
3259
+ console.warn(`\u8B66\u544A: settings.json \u683C\u5F0F\u635F\u574F\uFF0C\u5DF2\u5907\u4EFD\u5230 ${PATHS.settingsFile}.bak\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u8BBE\u7F6E`);
3260
+ } catch {
3261
+ console.warn("\u8B66\u544A: settings.json \u683C\u5F0F\u635F\u574F\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u8BBE\u7F6E");
3250
3262
  }
3251
3263
  settingsCache = {};
3252
3264
  return settingsCache;
@@ -3265,11 +3277,7 @@ function writeSettings(settings) {
3265
3277
  function invalidateSettingsCache() {
3266
3278
  settingsCache = null;
3267
3279
  }
3268
- function maskUrl(url) {
3269
- if (!url) return url;
3270
- if (url.includes(",")) {
3271
- return url.split(",").map((u) => maskUrl(u.trim())).join(", ");
3272
- }
3280
+ function maskSingleUrl(url) {
3273
3281
  try {
3274
3282
  const parsed = new URL(url);
3275
3283
  const tokenKeys = ["token", "key", "secret", "pass", "password", "auth", "access_token", "api_key"];
@@ -3289,6 +3297,26 @@ function maskUrl(url) {
3289
3297
  return url;
3290
3298
  }
3291
3299
  }
3300
+ function looksLikeMultiUrl(url) {
3301
+ if (!url.includes(",")) return false;
3302
+ const parts = url.split(",").map((u) => u.trim()).filter(Boolean);
3303
+ if (parts.length < 2) return false;
3304
+ return parts.every((p) => {
3305
+ try {
3306
+ const u = new URL(p);
3307
+ return u.protocol === "http:" || u.protocol === "https:";
3308
+ } catch {
3309
+ return false;
3310
+ }
3311
+ });
3312
+ }
3313
+ function maskUrl(url) {
3314
+ if (!url) return url;
3315
+ if (looksLikeMultiUrl(url)) {
3316
+ return url.split(",").map((u) => maskSingleUrl(u.trim())).join(", ");
3317
+ }
3318
+ return maskSingleUrl(url);
3319
+ }
3292
3320
  function readSubscriptionCache() {
3293
3321
  ensureDirs();
3294
3322
  if (fs2.existsSync(PATHS.subscriptionsCacheFile)) {
@@ -3318,7 +3346,14 @@ function saveSubscriptionCache(subName, data) {
3318
3346
  }
3319
3347
  function getSubscriptions() {
3320
3348
  const settings = readSettings();
3321
- return settings.subscriptions || [];
3349
+ const subs = settings.subscriptions;
3350
+ if (!Array.isArray(subs)) {
3351
+ if (subs !== void 0) {
3352
+ console.warn("\u8B66\u544A: settings.json \u7684 subscriptions \u4E0D\u662F\u5217\u8868\uFF0C\u5DF2\u5FFD\u7565\uFF08\u53EF\u7528 mihomo sub add \u91CD\u65B0\u6DFB\u52A0\uFF09");
3353
+ }
3354
+ return [];
3355
+ }
3356
+ return subs.filter((s) => s != null && typeof s === "object" && typeof s.name === "string" && typeof s.url === "string");
3322
3357
  }
3323
3358
  function getSubscriptionsWithCache() {
3324
3359
  const subs = getSubscriptions();
@@ -3337,7 +3372,7 @@ function validateSubscriptionName(name) {
3337
3372
  function addSubscription(url, name = "default") {
3338
3373
  validateSubscriptionName(name);
3339
3374
  const settings = readSettings();
3340
- const subs = [...settings.subscriptions || []];
3375
+ const subs = [...getSubscriptions()];
3341
3376
  if (subs.some((s) => s.name === name)) {
3342
3377
  throw new CliError(`\u8BA2\u9605 "${name}" \u5DF2\u5B58\u5728\uFF0C\u8BF7\u6362\u4E2A\u540D\u79F0\uFF08mihomo sub add <url> <\u540D\u79F0>\uFF09\uFF0C\u6216\u5148\u5220\u9664\uFF08mihomo sub remove ${name}\uFF09`);
3343
3378
  }
@@ -3350,7 +3385,7 @@ function addSubscription(url, name = "default") {
3350
3385
  }
3351
3386
  function removeSubscription(name) {
3352
3387
  const settings = readSettings();
3353
- const subs = [...settings.subscriptions || []];
3388
+ const subs = [...getSubscriptions()];
3354
3389
  const idx = subs.findIndex((s) => s.name === name);
3355
3390
  if (idx < 0) return null;
3356
3391
  subs.splice(idx, 1);
@@ -3374,7 +3409,7 @@ function removeSubscription(name) {
3374
3409
  }
3375
3410
  function setDefaultSubscription(name) {
3376
3411
  const settings = readSettings();
3377
- const subs = settings.subscriptions || [];
3412
+ const subs = getSubscriptions();
3378
3413
  const idx = subs.findIndex((s) => s.name === name);
3379
3414
  if (idx < 0) return false;
3380
3415
  if (settings.active_subscription === name) return true;
@@ -3459,6 +3494,18 @@ function deepMergeWithOverrides(target, override) {
3459
3494
  const { key, forceOverwrite, arrayPrepend, arrayAppend, arrayMergeByName } = parseOverrideKey(rawKey);
3460
3495
  const existingValue = result[key];
3461
3496
  if (arrayMergeByName) {
3497
+ if (existingValue !== void 0 && !Array.isArray(existingValue)) {
3498
+ throw new CliError(
3499
+ `\u8986\u5199\u952E "${rawKey}" \u7684 ~ \u8BED\u4E49\u53EA\u9002\u7528\u4E8E\u6570\u7EC4\uFF0C\u4F46 "${key}" \u5F53\u524D\u662F${existingValue === null ? " null" : typeof existingValue === "object" ? "\u6620\u5C04" : `\u6807\u91CF\uFF08${typeof existingValue}\uFF09`}`,
3500
+ {
3501
+ label: "\u8986\u5199\u914D\u7F6E\u9519\u8BEF",
3502
+ hint: [
3503
+ `~${key} \u7528\u4E8E\u6309 name \u5C31\u5730\u5408\u5E76\u6570\u7EC4\u5143\u7D20\uFF08\u5982 ~proxy-groups\uFF09\u3002`,
3504
+ `\u82E5\u8981\u8986\u76D6\u975E\u6570\u7EC4\u7684 ${key}\uFF0C\u8BF7\u7528 ${key}!\uFF08\u5F3A\u5236\u8986\u76D6\uFF09\u6216\u76F4\u63A5\u5199 ${key}\uFF08\u6DF1\u5EA6\u5408\u5E76\uFF09\u3002`
3505
+ ]
3506
+ }
3507
+ );
3508
+ }
3462
3509
  const existingArr = Array.isArray(existingValue) ? existingValue : [];
3463
3510
  const overrideArr = Array.isArray(value) ? value : [value];
3464
3511
  const merged = [...existingArr];
@@ -3475,6 +3522,15 @@ function deepMergeWithOverrides(target, override) {
3475
3522
  continue;
3476
3523
  }
3477
3524
  if (arrayPrepend || arrayAppend) {
3525
+ if (existingValue !== void 0 && !Array.isArray(existingValue)) {
3526
+ throw new CliError(
3527
+ `\u8986\u5199\u952E "${rawKey}" \u7684\u6570\u7EC4\u62FC\u63A5\u8BED\u4E49\u53EA\u9002\u7528\u4E8E\u6570\u7EC4\uFF0C\u4F46 "${key}" \u5F53\u524D\u662F${existingValue === null ? " null" : typeof existingValue === "object" ? "\u6620\u5C04" : `\u6807\u91CF\uFF08${typeof existingValue}\uFF09`}`,
3528
+ {
3529
+ label: "\u8986\u5199\u914D\u7F6E\u9519\u8BEF",
3530
+ hint: [`+${key} / ${key}+ \u7528\u4E8E\u5411\u6570\u7EC4\u524D\u7F6E/\u8FFD\u52A0\u5143\u7D20\uFF08\u5982 rules+\uFF09\u3002`, `\u82E5\u8981\u66FF\u6362\u975E\u6570\u7EC4\u7684 ${key}\uFF0C\u8BF7\u76F4\u63A5\u5199 ${key}: <\u503C>\u3002`]
3531
+ }
3532
+ );
3533
+ }
3478
3534
  const existingArr = Array.isArray(existingValue) ? existingValue : [];
3479
3535
  const overrideArr = Array.isArray(value) ? value : [value];
3480
3536
  if (arrayPrepend) {
@@ -3537,7 +3593,18 @@ function summarizeMatch(match) {
3537
3593
  return parts.length > 0 ? parts.join(", ") : void 0;
3538
3594
  }
3539
3595
  function splitUrlsLocal(url) {
3540
- return url.split(",").map((u) => u.trim()).filter(Boolean);
3596
+ const isValidHttp = (u) => {
3597
+ try {
3598
+ const parsed = new URL(u.trim());
3599
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
3600
+ } catch {
3601
+ return false;
3602
+ }
3603
+ };
3604
+ if (!url.includes(",")) return [url.trim()];
3605
+ const parts = url.split(",").map((u) => u.trim()).filter(Boolean);
3606
+ if (parts.length > 1 && parts.every(isValidHttp)) return parts;
3607
+ return [url.trim()];
3541
3608
  }
3542
3609
  function hostMatchesDomain(host, domain) {
3543
3610
  const h = host.toLowerCase();
@@ -3548,7 +3615,9 @@ function matchesScope(match, scope) {
3548
3615
  if (!match) return true;
3549
3616
  if (match.subscription) {
3550
3617
  const names = Array.isArray(match.subscription) ? match.subscription : [match.subscription];
3551
- if (!scope?.subName || !names.includes(scope.subName)) return false;
3618
+ if (!scope?.subName) return false;
3619
+ const subName = scope.subName.toLowerCase();
3620
+ if (!names.some((n) => n.toLowerCase() === subName)) return false;
3552
3621
  }
3553
3622
  if (match["url-domain"]) {
3554
3623
  const domains = Array.isArray(match["url-domain"]) ? match["url-domain"] : [match["url-domain"]];
@@ -3672,15 +3741,25 @@ function hasFlag(args, short, long) {
3672
3741
  }
3673
3742
  function parseIntArg(args, short, long, defaultValue) {
3674
3743
  if (!args) return defaultValue;
3744
+ const parse = (raw, flag) => {
3745
+ if (!/^\d+$/.test(raw.trim())) {
3746
+ throw new CliError(`\u9009\u9879 ${flag} \u9700\u8981\u6B63\u6574\u6570\uFF0C\u6536\u5230 "${raw}"`, { hint: [`\u4F8B\u5982: ${flag} ${defaultValue}`] });
3747
+ }
3748
+ const val = Number(raw);
3749
+ if (!Number.isSafeInteger(val) || val < 1) {
3750
+ throw new CliError(`\u9009\u9879 ${flag} \u9700\u8981 >= 1 \u7684\u6574\u6570\uFF0C\u6536\u5230 "${raw}"`, { hint: [`\u4F8B\u5982: ${flag} ${defaultValue}`] });
3751
+ }
3752
+ return val;
3753
+ };
3675
3754
  for (let i = 0; i < args.length; i++) {
3676
3755
  if (args[i] === short || args[i] === long) {
3677
3756
  if (i + 1 < args.length) {
3678
- const val = parseInt(args[i + 1], 10);
3679
- return Number.isNaN(val) ? defaultValue : val;
3757
+ return parse(args[i + 1], args[i]);
3680
3758
  }
3681
- } else if (args[i].startsWith(`${long}=`)) {
3682
- const val = parseInt(args[i].slice(long.length + 1), 10);
3683
- if (!Number.isNaN(val)) return val;
3759
+ throw new CliError(`\u9009\u9879 ${args[i]} \u7F3A\u5C11\u503C`, { hint: [`\u4F8B\u5982: ${args[i]} ${defaultValue}`] });
3760
+ }
3761
+ if (args[i].startsWith(`${long}=`)) {
3762
+ return parse(args[i].slice(long.length + 1), long);
3684
3763
  }
3685
3764
  }
3686
3765
  return defaultValue;
@@ -3829,7 +3908,7 @@ function excludeOverwriteProxiesFromIncludeAll(config, overwriteFiles) {
3829
3908
  if (injectedNames.length === 0) return;
3830
3909
  const groups = config["proxy-groups"];
3831
3910
  if (!groups) return;
3832
- const excludePattern = injectedNames.map((n) => escapeRegExp(n)).join("|");
3911
+ const excludePattern = `^(?:${injectedNames.map((n) => escapeRegExp(n)).join("|")})$`;
3833
3912
  for (const group of groups) {
3834
3913
  if (!group["include-all"] && !group["include-all-proxies"]) continue;
3835
3914
  const existing = group["exclude-filter"];
@@ -3864,7 +3943,58 @@ function getRuleTarget(rule) {
3864
3943
  }
3865
3944
  return last;
3866
3945
  }
3946
+ function assertConfigShape(config) {
3947
+ const listSections = [
3948
+ { key: "proxies", label: "\u8282\u70B9", needsName: true },
3949
+ { key: "proxy-groups", label: "\u4EE3\u7406\u7EC4", needsName: true },
3950
+ { key: "rules", label: "\u89C4\u5219", needsName: false }
3951
+ ];
3952
+ for (const { key, label, needsName } of listSections) {
3953
+ const value = config[key];
3954
+ if (value === void 0 || value === null) continue;
3955
+ if (!Array.isArray(value)) {
3956
+ throw new CliError(`${key} \u5FC5\u987B\u662F\u5217\u8868\uFF0C\u5F53\u524D\u4E3A ${typeof value === "object" ? "\u6620\u5C04" : typeof value}`, {
3957
+ label: "\u914D\u7F6E\u9519\u8BEF",
3958
+ hint: [
3959
+ `${label}\u6BB5\uFF08${key}\uFF09\u9700\u5199\u6210 YAML \u5217\u8868\uFF0C\u6BCF\u9879\u4EE5 "- " \u5F00\u5934\u3002`,
3960
+ `\u4F8B\u5982: ${key}:`,
3961
+ key === "rules" ? " - MATCH,DIRECT" : " - {name: xxx, ...}"
3962
+ ]
3963
+ });
3964
+ }
3965
+ for (let i = 0; i < value.length; i++) {
3966
+ const item = value[i];
3967
+ if (item === null || item === void 0) {
3968
+ throw new CliError(`${key}[${i}] \u4E3A\u7A7A`, {
3969
+ label: "\u914D\u7F6E\u9519\u8BEF",
3970
+ hint: [`${label}\u6BB5\uFF08${key}\uFF09\u7B2C ${i + 1} \u9879\u662F\u7A7A\u503C\uFF0C\u901A\u5E38\u662F\u5217\u8868\u91CC\u7559\u4E86\u7A7A\u7684 "- " \u884C\u3002`]
3971
+ });
3972
+ }
3973
+ if (needsName) {
3974
+ if (typeof item !== "object" || Array.isArray(item)) {
3975
+ throw new CliError(`${key}[${i}] \u5FC5\u987B\u662F\u6620\u5C04`, {
3976
+ label: "\u914D\u7F6E\u9519\u8BEF",
3977
+ hint: [`${label}\u6BB5\uFF08${key}\uFF09\u7B2C ${i + 1} \u9879\u5E94\u4E3A {name: ..., ...} \u5F62\u5F0F\uFF0C\u5F53\u524D\u662F ${Array.isArray(item) ? "\u5217\u8868" : typeof item}\u3002`]
3978
+ });
3979
+ }
3980
+ const name = item.name;
3981
+ if (typeof name !== "string" || name === "") {
3982
+ throw new CliError(`${key}[${i}] \u7F3A\u5C11\u6709\u6548\u7684 name`, {
3983
+ label: "\u914D\u7F6E\u9519\u8BEF",
3984
+ hint: [`${label}\u6BB5\uFF08${key}\uFF09\u7B2C ${i + 1} \u9879\u6CA1\u6709 name \u5B57\u6BB5\uFF08\u6216\u4E3A\u7A7A\uFF09\uFF0Cmihomo \u4F1A\u62D2\u7EDD\u542F\u52A8\u3002`]
3985
+ });
3986
+ }
3987
+ } else if (typeof item !== "string") {
3988
+ throw new CliError(`${key}[${i}] \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`, {
3989
+ label: "\u914D\u7F6E\u9519\u8BEF",
3990
+ hint: [`${label}\u6BB5\uFF08${key}\uFF09\u7B2C ${i + 1} \u9879\u5E94\u4E3A\u5F62\u5982 "MATCH,DIRECT" \u7684\u5B57\u7B26\u4E32\uFF0C\u5F53\u524D\u662F ${typeof item}\u3002`]
3991
+ });
3992
+ }
3993
+ }
3994
+ }
3995
+ }
3867
3996
  function validateConfig(config) {
3997
+ assertConfigShape(config);
3868
3998
  const warnings = [];
3869
3999
  const proxies = config.proxies || [];
3870
4000
  const groups = config["proxy-groups"] || [];
@@ -4187,7 +4317,7 @@ function isProcessRunning(pid) {
4187
4317
  function isProcessCommandMatching(pid, needle) {
4188
4318
  if (!pid) return false;
4189
4319
  try {
4190
- const result = spawnSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", timeout: PS_TIMEOUT_MS });
4320
+ const result = spawnSync2("ps", ["-ww", "-p", String(pid), "-o", "command="], { encoding: "utf8", timeout: PS_TIMEOUT_MS });
4191
4321
  return (result.stdout || "").includes(needle);
4192
4322
  } catch {
4193
4323
  return false;
@@ -4697,6 +4827,17 @@ function viewLogWithTail(logPath, options) {
4697
4827
 
4698
4828
  // src/daemon.ts
4699
4829
  var SERVICE_TARGET = `system/${LAUNCH_DAEMON_LABEL}`;
4830
+ function assertDaemonLabelSafe() {
4831
+ if (RAW_DAEMON_LABEL_INPUT !== void 0 && !isValidDaemonLabel(RAW_DAEMON_LABEL_INPUT)) {
4832
+ throw new CliError(`MIHOMO_CLI_DAEMON_LABEL \u65E0\u6548: "${RAW_DAEMON_LABEL_INPUT}"`, {
4833
+ label: "\u914D\u7F6E\u9519\u8BEF",
4834
+ hint: [
4835
+ '\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u4E0B\u5212\u7EBF\u3001\u77ED\u6A2A\u7EBF\uFF0C\u4E14\u4E0D\u80FD\u542B ".."\u3002',
4836
+ "\u8BE5\u503C\u4F1A\u6210\u4E3A /Library/LaunchDaemons/ \u4E0B\u7684 plist \u6587\u4EF6\u540D\uFF0C\u5E76\u4F5C\u4E3A root \u5199\u5165/\u5220\u9664\u7684\u76EE\u6807\u8DEF\u5F84\u3002"
4837
+ ]
4838
+ });
4839
+ }
4840
+ }
4700
4841
  var HOT_RELOAD_TIMEOUT_MS = 5e3;
4701
4842
  var DAEMON_BOOT_WAIT_MS = 500;
4702
4843
  var LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
@@ -4778,6 +4919,7 @@ function isDaemonRunning(status) {
4778
4919
  return status.loaded && status.pid !== null;
4779
4920
  }
4780
4921
  function enableDaemon() {
4922
+ assertDaemonLabelSafe();
4781
4923
  if (!fs6.existsSync(PATHS.mihomoBinary)) {
4782
4924
  throw new CliError("\u672A\u627E\u5230 mihomo \u5185\u6838\uFF0C\u8BF7\u5148\u4E0B\u8F7D\u5185\u6838");
4783
4925
  }
@@ -4818,6 +4960,7 @@ function enableDaemon() {
4818
4960
  }
4819
4961
  }
4820
4962
  function disableDaemon() {
4963
+ assertDaemonLabelSafe();
4821
4964
  if (!isDaemonEnabled()) return;
4822
4965
  const target = shellQuote(SERVICE_TARGET);
4823
4966
  const plistDest = shellQuote(PATHS.launchDaemonPlist);
@@ -4862,7 +5005,7 @@ async function tryHotReload() {
4862
5005
  }
4863
5006
  async function restartDaemon() {
4864
5007
  if (!fs6.existsSync(PATHS.launchDaemonPlist)) {
4865
- throw new Error("\u4FDD\u6D3B\u672A\u542F\u7528\uFF0C\u65E0\u6CD5\u91CD\u542F");
5008
+ throw new CliError("\u4FDD\u6D3B\u672A\u542F\u7528\uFF0C\u65E0\u6CD5\u91CD\u542F", { hint: "\u542F\u7528\u4FDD\u6D3B: mihomo daemon on" });
4866
5009
  }
4867
5010
  if (!logOversized() && await tryHotReload()) return;
4868
5011
  const target = shellQuote(SERVICE_TARGET);
@@ -4889,6 +5032,7 @@ async function restartDaemon() {
4889
5032
 
4890
5033
  // src/http.ts
4891
5034
  var MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
5035
+ var MAX_ERROR_BODY_BYTES = 64 * 1024;
4892
5036
  function createHttpClient(options = {}) {
4893
5037
  const { timeout = 6e4, secret } = options;
4894
5038
  const authHeaders = secret ? { Authorization: `Bearer ${secret}` } : {};
@@ -4906,7 +5050,8 @@ function createHttpClient(options = {}) {
4906
5050
  const error = new Error(`HTTP ${response.status}`);
4907
5051
  error.response = { status: response.status };
4908
5052
  try {
4909
- error.response.data = await response.json();
5053
+ const text2 = await readBodyWithLimit(response, controller, MAX_ERROR_BODY_BYTES);
5054
+ error.response.data = JSON.parse(text2);
4910
5055
  } catch {
4911
5056
  }
4912
5057
  throw error;
@@ -4915,7 +5060,7 @@ function createHttpClient(options = {}) {
4915
5060
  if (Number.isFinite(declaredLen) && declaredLen > MAX_RESPONSE_BYTES) {
4916
5061
  throw new Error(`\u54CD\u5E94\u4F53\u8FC7\u5927\uFF08${formatBytes(declaredLen)}\uFF0C\u4E0A\u9650 ${formatBytes(MAX_RESPONSE_BYTES)}\uFF09`);
4917
5062
  }
4918
- const text = await readBodyWithLimit(response, controller);
5063
+ const text = await readBodyWithLimit(response, controller, MAX_RESPONSE_BYTES);
4919
5064
  const data = config?.responseType === "json" ? JSON.parse(text) : text;
4920
5065
  return { data, headers: response.headers, status: response.status };
4921
5066
  } finally {
@@ -4924,7 +5069,7 @@ function createHttpClient(options = {}) {
4924
5069
  }
4925
5070
  };
4926
5071
  }
4927
- async function readBodyWithLimit(response, controller) {
5072
+ async function readBodyWithLimit(response, controller, limit) {
4928
5073
  if (!response.body) return response.text();
4929
5074
  const reader = response.body.getReader();
4930
5075
  const chunks = [];
@@ -4935,9 +5080,9 @@ async function readBodyWithLimit(response, controller) {
4935
5080
  if (done) break;
4936
5081
  if (value) {
4937
5082
  total += value.byteLength;
4938
- if (total > MAX_RESPONSE_BYTES) {
5083
+ if (total > limit) {
4939
5084
  controller.abort();
4940
- throw new Error(`\u54CD\u5E94\u4F53\u8D85\u8FC7\u5927\u5C0F\u4E0A\u9650\uFF08${formatBytes(MAX_RESPONSE_BYTES)}\uFF09`);
5085
+ throw new Error(`\u54CD\u5E94\u4F53\u8D85\u8FC7\u5927\u5C0F\u4E0A\u9650\uFF08${formatBytes(limit)}\uFF09`);
4941
5086
  }
4942
5087
  chunks.push(value);
4943
5088
  }
@@ -4964,7 +5109,9 @@ function resolveUpdateInterval(url, cachedInterval) {
4964
5109
  }
4965
5110
  var HTTP_CLIENT = createHttpClient({ timeout: 6e4 });
4966
5111
  function isMultiUrl(url) {
4967
- return url.includes(",");
5112
+ if (!url.includes(",")) return false;
5113
+ const parts = url.split(",").map((u) => u.trim()).filter(Boolean);
5114
+ return parts.length > 1 && parts.every(isValidHttpUrl);
4968
5115
  }
4969
5116
  function isValidHttpUrl(url) {
4970
5117
  try {
@@ -4975,6 +5122,7 @@ function isValidHttpUrl(url) {
4975
5122
  }
4976
5123
  }
4977
5124
  function splitUrls(url) {
5125
+ if (!isMultiUrl(url)) return [url.trim()];
4978
5126
  return url.split(",").map((u) => u.trim()).filter(Boolean);
4979
5127
  }
4980
5128
  function loadSubscriptionConfig(subName) {
@@ -5097,6 +5245,23 @@ function pickSingleSubscription(subs, pattern) {
5097
5245
  function resolveSubscription(subs, pattern) {
5098
5246
  return pickSingleSubscription(findSubscriptionFuzzy(subs, pattern), pattern);
5099
5247
  }
5248
+ function assertLooksLikeSubscription(parsed, maskedUrl) {
5249
+ const hasProxies = Array.isArray(parsed.proxies) && parsed.proxies.length > 0;
5250
+ const hasGroups = Array.isArray(parsed["proxy-groups"]) && parsed["proxy-groups"].length > 0;
5251
+ const providers = parsed["proxy-providers"];
5252
+ const hasProviders = providers != null && typeof providers === "object" && Object.keys(providers).length > 0;
5253
+ if (hasProxies || hasGroups || hasProviders) return;
5254
+ const serverMsg = ["error", "message", "msg", "info"].map((k) => parsed[k]).find((v) => typeof v === "string" && v.length > 0);
5255
+ throw new CliError("\u8BA2\u9605\u5185\u5BB9\u4E0D\u542B\u4EFB\u4F55\u8282\u70B9\u6765\u6E90\uFF08proxies / proxy-groups / proxy-providers \u5747\u4E3A\u7A7A\uFF09", {
5256
+ label: "\u8BA2\u9605\u65E0\u6548",
5257
+ hint: [
5258
+ ...serverMsg ? [`\u670D\u52A1\u7AEF\u8FD4\u56DE: ${serverMsg}`] : [],
5259
+ `URL: ${maskedUrl}`,
5260
+ "\u5E38\u89C1\u539F\u56E0\uFF1A\u8BA2\u9605\u94FE\u63A5\u8FC7\u671F\u3001\u6D41\u91CF\u8017\u5C3D\u3001\u9700\u8981\u91CD\u65B0\u83B7\u53D6\u8BA2\u9605\u5730\u5740\u3002",
5261
+ "\u78C1\u76D8\u4E0A\u539F\u6709\u7684\u8BA2\u9605\u914D\u7F6E\u672A\u88AB\u8986\u76D6\u3002"
5262
+ ]
5263
+ });
5264
+ }
5100
5265
  async function downloadSubscription(url, subName = "default", signal, persist = true) {
5101
5266
  let response;
5102
5267
  try {
@@ -5118,6 +5283,7 @@ async function downloadSubscription(url, subName = "default", signal, persist =
5118
5283
  }
5119
5284
  const parsed = parseYamlOrJson(content, "\u8BA2\u9605\u5185\u5BB9");
5120
5285
  if (!parsed) throw new Error("\u8BA2\u9605\u5185\u5BB9\u4E3A\u7A7A");
5286
+ assertLooksLikeSubscription(parsed, maskUrl(url));
5121
5287
  if (persist) {
5122
5288
  saveSubscriptionRawConfig(subName, content);
5123
5289
  }
@@ -5150,12 +5316,13 @@ async function downloadMergedSubscription(urls, subName, signal, persist = true)
5150
5316
  }
5151
5317
  })
5152
5318
  );
5153
- for (const r of responses) {
5154
- if (r.error) {
5155
- const maskedUrl = maskUrl(r.url);
5156
- throw new Error(`\u5408\u5E76\u8BA2\u9605\u7B2C ${r.index + 1} \u4E2A URL \u83B7\u53D6\u5931\u8D25: ${r.error.message}
5319
+ const failures = responses.filter((r) => r.error);
5320
+ if (failures.length > 0) {
5321
+ const isAbort = (e) => e.name === "AbortError" || /abort/i.test(e.message);
5322
+ const real = failures.find((r) => !isAbort(r.error)) ?? failures[0];
5323
+ const maskedUrl = maskUrl(real.url);
5324
+ throw new Error(`\u5408\u5E76\u8BA2\u9605\u7B2C ${real.index + 1} \u4E2A URL \u83B7\u53D6\u5931\u8D25: ${real.error.message}
5157
5325
  URL: ${maskedUrl}`);
5158
- }
5159
5326
  }
5160
5327
  const parsed = responses.map((r, i) => {
5161
5328
  const content = r.response?.data;
@@ -5176,6 +5343,7 @@ async function downloadMergedSubscription(urls, subName, signal, persist = true)
5176
5343
  }
5177
5344
  base.proxies = baseProxies;
5178
5345
  const mergedContent = dumpYaml(base);
5346
+ assertLooksLikeSubscription(base, urls.map((u) => maskUrl(u)).join(", "));
5179
5347
  if (persist) {
5180
5348
  saveSubscriptionRawConfig(subName, mergedContent);
5181
5349
  }
@@ -5465,6 +5633,9 @@ async function autoCleanSubscription(subName, options = {}) {
5465
5633
  return { summary, removedProxies, updatedGroups, removedGroups, skipped };
5466
5634
  }
5467
5635
 
5636
+ // src/commands/shared.ts
5637
+ import readline from "readline";
5638
+
5468
5639
  // src/runtime.ts
5469
5640
  function getRuntimeMode() {
5470
5641
  if (isDaemonEnabled()) return "mixed";
@@ -5768,6 +5939,17 @@ async function dispatchSubcommand(args, table, options) {
5768
5939
  }
5769
5940
  return options.fallback(args);
5770
5941
  }
5942
+ async function confirmPrompt(question) {
5943
+ if (!process.stdin.isTTY) return false;
5944
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5945
+ const answer = await new Promise((resolve) => {
5946
+ rl.question(`${question} (y/N) `, (a) => {
5947
+ rl.close();
5948
+ resolve(a);
5949
+ });
5950
+ });
5951
+ return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
5952
+ }
5771
5953
  function requireRunning() {
5772
5954
  const state = getRunningState();
5773
5955
  if (!state.running) {
@@ -5927,9 +6109,26 @@ function printDirectoryInfo() {
5927
6109
  console.log(" MIHOMO_CLI_DIR: \u81EA\u5B9A\u4E49\u6839\u76EE\u5F55\u4F4D\u7F6E");
5928
6110
  console.log("");
5929
6111
  }
5930
- var SUBCOMMANDS2 = [{ name: "open", handler: openDirectory }];
5931
- function cmdDirectory(args) {
5932
- void dispatchSubcommand(args, SUBCOMMANDS2, { fallback: printDirectoryInfo });
6112
+ var SUBCOMMANDS2 = [
6113
+ { name: "open", handler: openDirectory },
6114
+ { name: "list", handler: printDirectoryInfo }
6115
+ ];
6116
+ async function cmdDirectory(args) {
6117
+ await dispatchSubcommand(args, SUBCOMMANDS2, {
6118
+ fallback: printDirectoryInfo,
6119
+ onUnknown: (action) => {
6120
+ const names = SUBCOMMANDS2.flatMap((c) => [c.name, ...c.aliases ?? []]);
6121
+ const suggestion = suggestSimilar(action, names);
6122
+ throw new CliError(`\u672A\u77E5\u7684\u76EE\u5F55\u5B50\u547D\u4EE4: ${action}`, {
6123
+ hint: [
6124
+ ...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [],
6125
+ "",
6126
+ "\u53EF\u7528\u5B50\u547D\u4EE4: open, list",
6127
+ "\u6253\u5F00\u6307\u5B9A\u76EE\u5F55: mihomo dir open <target>"
6128
+ ]
6129
+ });
6130
+ }
6131
+ });
5933
6132
  }
5934
6133
 
5935
6134
  // src/kernel.ts
@@ -6375,20 +6574,35 @@ async function setOverwrite(enabled, args) {
6375
6574
  }
6376
6575
  var SUBCOMMANDS3 = [
6377
6576
  { name: "on", aliases: ["enable"], handler: (args) => setOverwrite(true, args) },
6378
- { name: "off", aliases: ["disable"], handler: (args) => setOverwrite(false, args) }
6577
+ { name: "off", aliases: ["disable"], handler: (args) => setOverwrite(false, args) },
6578
+ // list 显式注册:onUnknown 生效后,未注册的子命令会报错,不能再靠 fallback 兜住 `ow list`
6579
+ {
6580
+ name: "list",
6581
+ handler: () => {
6582
+ console.log("");
6583
+ printOverwriteList();
6584
+ }
6585
+ }
6379
6586
  ];
6380
6587
  async function cmdOverwrite(args) {
6381
6588
  await dispatchSubcommand(args, SUBCOMMANDS3, {
6589
+ // 无子命令 → 列表;未知子命令 → 报错(与 sub/daemon 同构,避免 `ow onn` 静默当成 list)
6382
6590
  fallback: () => {
6383
6591
  console.log("");
6384
6592
  printOverwriteList();
6593
+ },
6594
+ onUnknown: (action) => {
6595
+ const names = SUBCOMMANDS3.flatMap((c) => [c.name, ...c.aliases ?? []]);
6596
+ const suggestion = suggestSimilar(action, names);
6597
+ throw new CliError(`\u672A\u77E5\u7684\u8986\u5199\u5B50\u547D\u4EE4: ${action}`, {
6598
+ hint: [...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [], "", "\u53EF\u7528\u5B50\u547D\u4EE4: on, off, list"]
6599
+ });
6385
6600
  }
6386
6601
  });
6387
6602
  }
6388
6603
 
6389
6604
  // src/commands/reset.ts
6390
6605
  import fs8 from "fs";
6391
- import readline from "readline";
6392
6606
  var RESET_TARGETS = [
6393
6607
  {
6394
6608
  id: "subs",
@@ -6425,7 +6639,10 @@ var RESET_TARGETS = [
6425
6639
  id: "settings",
6426
6640
  aliases: ["setting", "settings", "config"],
6427
6641
  label: "\u8BBE\u7F6E",
6428
- paths: () => [PATHS.settingsFile],
6642
+ // 同时删 .bak:readSettings 遇格式损坏会备份原文件(settings.ts),里面含
6643
+ // controller_secret 与订阅 URL 的 token。只删主文件会让 "已重置: 设置" 名不副实,
6644
+ // 凭据仍明文留在数据目录(cache.json.bak 在 subscriptions/ 内,随整目录删除,无需单列)
6645
+ paths: () => [PATHS.settingsFile, `${PATHS.settingsFile}.bak`],
6429
6646
  needsStop: false
6430
6647
  },
6431
6648
  {
@@ -6475,18 +6692,9 @@ function resolveResetTargets(names) {
6475
6692
  unmatched.push(name);
6476
6693
  }
6477
6694
  }
6695
+ matched.sort((a, b) => RESET_TARGETS.indexOf(a) - RESET_TARGETS.indexOf(b));
6478
6696
  return { matched, unmatched };
6479
6697
  }
6480
- async function confirmPrompt(question) {
6481
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6482
- const answer = await new Promise((resolve) => {
6483
- rl.question(`${question} (y/N) `, (a) => {
6484
- rl.close();
6485
- resolve(a);
6486
- });
6487
- });
6488
- return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
6489
- }
6490
6698
  async function cmdReset(args) {
6491
6699
  const flags = (args || []).filter((a) => a.startsWith("-"));
6492
6700
  const names = (args || []).slice(1).filter((a) => !a.startsWith("-"));
@@ -6541,25 +6749,41 @@ async function cmdReset(args) {
6541
6749
  console.log(colors.yellow("\u4FDD\u6D3B\u5DF2\u542F\u7528\uFF0C\u91CD\u7F6E\u5C06\u4E00\u5E76\u5173\u95ED\u4FDD\u6D3B\uFF08\u79FB\u9664\u5F00\u673A\u81EA\u542F\uFF09"));
6542
6750
  }
6543
6751
  console.log(`\u5C06\u5220\u9664: ${targets.map((t) => t.label).join("\u3001")}`);
6544
- if (!skipConfirm && !await confirmPrompt("\u786E\u8BA4?")) {
6545
- console.log("\u5DF2\u53D6\u6D88");
6546
- return;
6752
+ if (!skipConfirm) {
6753
+ if (!process.stdin.isTTY) {
6754
+ throw new CliError("\u975E\u4EA4\u4E92\u73AF\u5883\u65E0\u6CD5\u786E\u8BA4", { label: "\u5DF2\u53D6\u6D88", hint: ["\u8DF3\u8FC7\u786E\u8BA4\u8BF7\u52A0 -y: mihomo reset ... -y"] });
6755
+ }
6756
+ if (!await confirmPrompt("\u786E\u8BA4?")) {
6757
+ console.log("\u5DF2\u53D6\u6D88");
6758
+ return;
6759
+ }
6547
6760
  }
6548
6761
  if (disablesDaemon && isDaemonEnabled()) {
6549
6762
  try {
6550
6763
  disableDaemon();
6551
6764
  } catch (e) {
6552
- console.error(`${colors.red("\u4FDD\u6D3B\u5173\u95ED\u5DF2\u53D6\u6D88\uFF0C\u91CD\u7F6E\u4E2D\u6B62:")} ${e.message.split("\n")[0]}`);
6553
- return;
6765
+ if (e instanceof CliError) throw e;
6766
+ throw new CliError(e.message.split("\n")[0], { label: "\u4FDD\u6D3B\u5173\u95ED\u5DF2\u53D6\u6D88\uFF0C\u91CD\u7F6E\u4E2D\u6B62" });
6554
6767
  }
6555
6768
  }
6556
6769
  if (needsStop && getMihomoPids().length > 0) {
6557
6770
  console.log("\u505C\u6B62\u8FDB\u7A0B...");
6558
- cleanupAll();
6771
+ const cleanup = cleanupAll();
6559
6772
  for (let i = 0; i < PROCESS_WAIT_ATTEMPTS; i++) {
6560
6773
  if (getMihomoPids().length === 0) break;
6561
6774
  await new Promise((r) => setTimeout(r, PROCESS_WAIT_INTERVAL));
6562
6775
  }
6776
+ const remaining = getMihomoPids();
6777
+ if (remaining.length > 0) {
6778
+ throw new CliError(remaining.join(", "), {
6779
+ label: "\u8FDB\u7A0B\u672A\u80FD\u505C\u6B62\uFF0C\u91CD\u7F6E\u4E2D\u6B62",
6780
+ hint: [
6781
+ `\u672A\u7EC8\u6B62\u7684\u8FDB\u7A0B: ${remaining.join(", ")}${cleanup.failed > 0 ? `\uFF08${cleanup.failed} \u4E2A\u7EC8\u6B62\u5931\u8D25\uFF09` : ""}`,
6782
+ "\u8BF7\u624B\u52A8\u8FD0\u884C: sudo pkill -9 mihomo",
6783
+ "\u5426\u5219\u6B8B\u7559\u8FDB\u7A0B\u4F1A\u7EE7\u7EED\u4F7F\u7528\u5373\u5C06\u5220\u9664\u7684\u914D\u7F6E\u3002"
6784
+ ]
6785
+ });
6786
+ }
6563
6787
  }
6564
6788
  for (const t of targets) {
6565
6789
  for (const p of t.paths()) {
@@ -6585,6 +6809,7 @@ import { spawn as spawn2 } from "child_process";
6585
6809
  import fs9 from "fs";
6586
6810
  import path8 from "path";
6587
6811
  function isProxyValid(proxy) {
6812
+ if (proxy === null || typeof proxy !== "object") return false;
6588
6813
  if (!proxy.name || !proxy.server || !proxy.port) return false;
6589
6814
  if (!proxy.type) return false;
6590
6815
  if (proxy.type === "ss" && typeof proxy.cipher === "string" && proxy.cipher.startsWith("2022-blake3")) {
@@ -6661,6 +6886,7 @@ async function startTestInstance() {
6661
6886
  child.unref();
6662
6887
  const pid = child.pid;
6663
6888
  if (!pid) throw new CliError("\u6D4B\u8BD5\u5B9E\u4F8B\u542F\u52A8\u5931\u8D25\uFF1A\u65E0\u6CD5\u521B\u5EFA\u8FDB\u7A0B\uFF08\u5185\u6838\u4E8C\u8FDB\u5236\u53EF\u80FD\u4E0D\u53EF\u6267\u884C\uFF09");
6889
+ spawnedTestPid = pid;
6664
6890
  fs9.writeFileSync(TEST_PATHS.pidFile, pid.toString(), { mode: 384 });
6665
6891
  const client = createHttpClient({ timeout: 2e3 });
6666
6892
  let ready = false;
@@ -6687,13 +6913,16 @@ ${errorDetail}` : ""}`);
6687
6913
  throw new CliError("\u6D4B\u8BD5\u5B9E\u4F8B\u542F\u52A8\u8D85\u65F6\uFF0CAPI \u672A\u54CD\u5E94");
6688
6914
  }
6689
6915
  }
6916
+ var spawnedTestPid = null;
6690
6917
  function stopTestInstance() {
6691
- let pid;
6918
+ let pid = null;
6692
6919
  try {
6693
- pid = parseInt(fs9.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
6920
+ const fromFile = parseInt(fs9.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
6921
+ if (fromFile > 0) pid = fromFile;
6694
6922
  } catch {
6695
- return;
6696
6923
  }
6924
+ if (pid === null) pid = spawnedTestPid;
6925
+ if (pid === null) return;
6697
6926
  if (pid > 0 && isProcessRunning(pid) && isProcessCommandMatching(pid, TEST_PATHS.configFile)) {
6698
6927
  process.kill(pid, "SIGKILL");
6699
6928
  for (let i = 0; i < 20; i++) {
@@ -6701,6 +6930,7 @@ function stopTestInstance() {
6701
6930
  sleepSync(100);
6702
6931
  }
6703
6932
  }
6933
+ spawnedTestPid = null;
6704
6934
  try {
6705
6935
  fs9.unlinkSync(TEST_PATHS.pidFile);
6706
6936
  } catch {
@@ -6824,11 +7054,12 @@ async function subAdd(args) {
6824
7054
  console.log(`\u6DFB\u52A0\u5408\u5E76\u8BA2\u9605: ${name} (${urls.length} \u4E2A\u6E90)`);
6825
7055
  addSubscription(normalizedUrl, name);
6826
7056
  try {
6827
- setDefaultSubscription(name);
6828
7057
  const info = await downloadMergedSubscription(urls, name);
7058
+ setDefaultSubscription(name);
6829
7059
  console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name}" (${formatProxySummary(info)}, \u5408\u5E76 ${urls.length} \u6E90)`);
6830
7060
  } catch (e) {
6831
7061
  removeSubscription(name);
7062
+ if (e instanceof CliError) throw new CliError(e.message, { label: "\u6DFB\u52A0\u5931\u8D25", hint: e.hint });
6832
7063
  throw new CliError(e.message, { label: "\u6DFB\u52A0\u5931\u8D25" });
6833
7064
  }
6834
7065
  } else {
@@ -6838,13 +7069,14 @@ async function subAdd(args) {
6838
7069
  console.log(`\u6DFB\u52A0\u8BA2\u9605: ${name}`);
6839
7070
  addSubscription(url, name);
6840
7071
  try {
6841
- setDefaultSubscription(name);
6842
7072
  const info = await downloadSubscription(url, name);
7073
+ setDefaultSubscription(name);
6843
7074
  const repoUrl = githubRepoUrl(url);
6844
7075
  if (repoUrl) saveSubscriptionCache(name, { web_page_url: repoUrl });
6845
7076
  console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name}" (${formatProxySummary(info)})`);
6846
7077
  } catch (e) {
6847
7078
  removeSubscription(name);
7079
+ if (e instanceof CliError) throw new CliError(e.message, { label: "\u6DFB\u52A0\u5931\u8D25", hint: e.hint });
6848
7080
  throw new CliError(e.message, { label: "\u6DFB\u52A0\u5931\u8D25" });
6849
7081
  }
6850
7082
  }
@@ -6941,8 +7173,8 @@ async function subWeb(args) {
6941
7173
  console.log("\u8BF7\u624B\u52A8\u8BBF\u95EE\u4E0A\u9762\u7684\u5730\u5740");
6942
7174
  }
6943
7175
  }
6944
- function subRemove(args) {
6945
- const name = args[2];
7176
+ async function subRemove(args) {
7177
+ const name = getNonFlagArg(args, 2);
6946
7178
  const subs = getSubscriptions();
6947
7179
  if (!name) {
6948
7180
  throw new CliError("\u8BF7\u6307\u5B9A\u8981\u5220\u9664\u7684\u8BA2\u9605\u540D\u79F0", {
@@ -6950,6 +7182,21 @@ function subRemove(args) {
6950
7182
  });
6951
7183
  }
6952
7184
  const target = resolveSubscription(subs, name);
7185
+ const isExact = target.name === name;
7186
+ const skipConfirm = hasFlag(args, "-y", "--yes");
7187
+ if (!isExact && !skipConfirm) {
7188
+ if (!process.stdin.isTTY) {
7189
+ throw new CliError(`\u6A21\u7CCA\u5339\u914D\u5230 "${target.name}"\uFF0C\u975E\u4EA4\u4E92\u73AF\u5883\u9700\u786E\u8BA4`, {
7190
+ label: "\u5DF2\u53D6\u6D88",
7191
+ hint: [`\u8BF7\u7528\u5B8C\u6574\u540D\u79F0: mihomo sub remove ${target.name}`, `\u6216\u8DF3\u8FC7\u786E\u8BA4: mihomo sub remove ${name} -y`]
7192
+ });
7193
+ }
7194
+ console.log(`\u5C06\u5220\u9664\u8BA2\u9605 "${target.name}" (\u6A21\u7CCA\u5339\u914D "${name}")`);
7195
+ if (!await confirmPrompt("\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\uFF0C\u786E\u8BA4?")) {
7196
+ console.log("\u5DF2\u53D6\u6D88");
7197
+ return;
7198
+ }
7199
+ }
6953
7200
  const switchedTo = removeSubscription(target.name);
6954
7201
  console.log(`\u5DF2\u5220\u9664\u8BA2\u9605 "${target.name}"`);
6955
7202
  if (switchedTo) {
@@ -7031,10 +7278,10 @@ async function cmdSubscription(args) {
7031
7278
 
7032
7279
  // src/commands/test.ts
7033
7280
  async function cmdTest(args) {
7034
- requireRunning();
7035
- const activeSub = requireActiveSubscription("\u6CA1\u6709\u6D3B\u8DC3\u8BA2\u9605");
7036
7281
  const timeout = parseIntArg(args, "-t", "--timeout", DEFAULT_TEST_TIMEOUT);
7037
7282
  const concurrency = parseIntArg(args, "-j", "--concurrency", DEFAULT_TEST_CONCURRENCY);
7283
+ requireRunning();
7284
+ const activeSub = requireActiveSubscription("\u6CA1\u6709\u6D3B\u8DC3\u8BA2\u9605");
7038
7285
  console.log(`\u6D4B\u8BD5 "${activeSub.name}" \u8282\u70B9\u8FDE\u901A\u6027...`);
7039
7286
  console.log(`\u8D85\u65F6: ${timeout}ms \u5E76\u53D1: ${concurrency}`);
7040
7287
  console.log("");
@@ -7048,11 +7295,11 @@ async function cmdTest(args) {
7048
7295
  console.log(formatTestSummary(summary));
7049
7296
  }
7050
7297
  async function cmdClean(args) {
7051
- requireRunning();
7052
- const activeSub = requireActiveSubscription("\u6CA1\u6709\u6D3B\u8DC3\u8BA2\u9605");
7053
7298
  const timeout = parseIntArg(args, "-t", "--timeout", DEFAULT_TEST_TIMEOUT);
7054
7299
  const concurrency = parseIntArg(args, "-j", "--concurrency", DEFAULT_TEST_CONCURRENCY);
7055
7300
  const rounds = parseIntArg(args, "-r", "--rounds", DEFAULT_CLEAN_ROUNDS);
7301
+ requireRunning();
7302
+ const activeSub = requireActiveSubscription("\u6CA1\u6709\u6D3B\u8DC3\u8BA2\u9605");
7056
7303
  console.log(`\u6E05\u7406 "${activeSub.name}" \u5931\u8D25\u8282\u70B9...`);
7057
7304
  console.log(`\u8D85\u65F6: ${timeout}ms \u5E76\u53D1: ${concurrency}`);
7058
7305
  console.log("");
@@ -7236,18 +7483,18 @@ var COMMANDS = [
7236
7483
  // === 订阅 ===
7237
7484
  {
7238
7485
  name: "subscription",
7239
- aliases: ["sub", "subscriptions"],
7486
+ aliases: ["sub", "subs", "subscriptions"],
7240
7487
  handler: cmdSubscription,
7241
7488
  group: "subscription",
7242
7489
  usage: [
7243
- "subscription \u5217\u51FA\u6240\u6709\u8BA2\u9605\uFF08\u522B\u540D sub\uFF09",
7490
+ "subscription \u5217\u51FA\u6240\u6709\u8BA2\u9605\uFF08\u522B\u540D sub/subs\uFF09",
7244
7491
  "subscription use <name> \u5207\u6362\u5F53\u524D\u8BA2\u9605",
7245
7492
  "subscription add <url> [name] \u6DFB\u52A0\u8BA2\u9605",
7246
7493
  "subscription update [name] \u66F4\u65B0\u8BA2\u9605\uFF08\u65E0\u53C2\u66F4\u65B0\u6240\u6709\uFF09",
7247
- "subscription remove <name> \u5220\u9664\u8BA2\u9605",
7494
+ "subscription remove <name> \u5220\u9664\u8BA2\u9605\uFF08\u6A21\u7CCA\u5339\u914D\u9700\u786E\u8BA4\uFF0C-y \u8DF3\u8FC7\uFF09",
7248
7495
  "subscription web [name] \u6253\u5F00\u8BA2\u9605\u9875\u9762",
7249
7496
  "subscription test [name] \u6D4B\u8BD5\u8282\u70B9\uFF08\u72EC\u7ACB\u5B9E\u4F8B\uFF0C\u65E0\u9700\u8FD0\u884C\uFF09",
7250
- "subscription clean [name] \u6D4B\u901F\u6E05\u7406\uFF08\u72EC\u7ACB\u5B9E\u4F8B\uFF0C\u4E0D\u52A8\u4E3B\u5B9E\u4F8B\uFF09"
7497
+ "subscription clean [name] \u6D4B\u901F\u6E05\u7406\uFF08\u72EC\u7ACB\u5B9E\u4F8B\uFF0C\u4E0D\u52A8\u4E3B\u5B9E\u4F8B\uFF09[-r N] [-t ms] [-j N]"
7251
7498
  ]
7252
7499
  },
7253
7500
  {
@@ -7284,7 +7531,7 @@ var COMMANDS = [
7284
7531
  name: "on",
7285
7532
  aliases: [],
7286
7533
  handler: cmdOverwrite,
7287
- rewrite: () => ["ow", "on"],
7534
+ rewrite: (args) => ["ow", "on", ...args.slice(1)],
7288
7535
  group: "config",
7289
7536
  usage: []
7290
7537
  },
@@ -7292,7 +7539,7 @@ var COMMANDS = [
7292
7539
  name: "off",
7293
7540
  aliases: [],
7294
7541
  handler: cmdOverwrite,
7295
- rewrite: () => ["ow", "off"],
7542
+ rewrite: (args) => ["ow", "off", ...args.slice(1)],
7296
7543
  group: "config",
7297
7544
  usage: []
7298
7545
  },
@@ -7411,11 +7658,26 @@ function clearProxyEnv() {
7411
7658
  delete process.env.all_proxy;
7412
7659
  delete process.env.ALL_PROXY;
7413
7660
  }
7661
+ var PLATFORM_FREE_COMMANDS = /* @__PURE__ */ new Set(["help", "version"]);
7662
+ function assertSupportedPlatform(commandName) {
7663
+ if (process.platform === "darwin") return;
7664
+ if (PLATFORM_FREE_COMMANDS.has(commandName)) return;
7665
+ if (process.env.MIHOMO_CLI_ALLOW_ANY_PLATFORM === "1") return;
7666
+ throw new CliError(`mihomo-cli \u76EE\u524D\u4EC5\u652F\u6301 macOS\uFF08\u5F53\u524D\u5E73\u53F0: ${process.platform}\uFF09`, {
7667
+ label: "\u5E73\u53F0\u4E0D\u652F\u6301",
7668
+ hint: [
7669
+ "\u8FDB\u7A0B\u4FDD\u6D3B\u4F9D\u8D56 launchd\u3001\u76EE\u5F55/UI \u6253\u5F00\u4F9D\u8D56 open\u3001\u63D0\u6743\u4F9D\u8D56 sudo\uFF0C\u5747\u65E0\u5176\u4ED6\u5E73\u53F0\u5B9E\u73B0\u3002",
7670
+ "Windows / Linux \u9002\u914D\u4ECD\u5728\u8FDB\u884C\u4E2D\u3002",
7671
+ "\u5982\u9700\u5728\u975E macOS \u4E0A\u5F00\u53D1\u8C03\u8BD5\uFF0C\u53EF\u8BBE MIHOMO_CLI_ALLOW_ANY_PLATFORM=1\uFF08\u529F\u80FD\u4E0D\u4FDD\u8BC1\u53EF\u7528\uFF09\u3002"
7672
+ ]
7673
+ });
7674
+ }
7414
7675
  async function main() {
7415
7676
  clearProxyEnv();
7416
- ensureDirs();
7417
7677
  const args = process.argv.slice(2);
7418
7678
  if (args.length === 0) {
7679
+ assertSupportedPlatform("status");
7680
+ ensureDirs();
7419
7681
  printStatus();
7420
7682
  printShortHelp();
7421
7683
  return;
@@ -7428,6 +7690,8 @@ async function main() {
7428
7690
  hint: [suggestion.length > 0 ? `\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?` : '\u4F7F\u7528 "mihomo help" \u67E5\u770B\u5E2E\u52A9']
7429
7691
  });
7430
7692
  }
7693
+ assertSupportedPlatform(command.name);
7694
+ ensureDirs();
7431
7695
  await command.handler(command.rewrite ? command.rewrite(args) : args);
7432
7696
  }
7433
7697
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihomo-cli",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "description": "A terminal-based mihomo (Clash.Meta) client for macOS",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "format": "biome format --write .",
22
22
  "typecheck": "tsc --noEmit",
23
23
  "test": "node --import tsx --test \"src/**/*.spec.ts\"",
24
+ "prepublishOnly": "npm run build",
24
25
  "prepare": "husky"
25
26
  },
26
27
  "keywords": [
@@ -41,6 +42,9 @@
41
42
  "engines": {
42
43
  "node": ">=22.22.1"
43
44
  },
45
+ "os": [
46
+ "darwin"
47
+ ],
44
48
  "dependencies": {
45
49
  "compare-versions": "^6.1.0",
46
50
  "js-yaml": "^5.2.1"