mihomo-cli 3.7.0 → 3.8.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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.8.0] - 2026-09-01
4
+
5
+ ### 新增
6
+
7
+ - **ssh 隧道出口(`tunnel` 子命令,别名 `ssh`)** - 管理 `ssh -D` 动态转发进程的生命周期,把可 ssh 登录的机器变成本地 SOCKS5 出口。分流仍交给覆写机制,本功能补的是「隧道断了 mihomo 不知情、会一直往死端口送流量」这一环
8
+
9
+ ```bash
10
+ mihomo tunnel add work --host m4 --port 1080
11
+ mihomo tunnel up|down|status [名字]
12
+ mihomo tunnel rm <名字>
13
+ ```
14
+
15
+ - **随 `start` 一并拉起**:默认带 `auto` 标记,`start` 顺带启动、`stop` 连带停止(`--no-tunnel` 跳过,`add --no-auto` 不参与)
16
+ - **隧道失败不影响内核启动**:只影响内网分流那部分规则,故仅打印显眼的黄色警告并附上 ssh 给出的原因,其余流量照常
17
+ - **`stop` 只停自己起的**:手动 `tunnel up` 起的隧道带 `manual` 标记,不会被 `mihomo stop` 带走,避免下次 start 又起一个而累积僵尸进程
18
+ - **`status` 真实探测端口**:能识别「进程还在但转发已死」的假活——那正是最误导的形态,此时 mihomo 仍在往死端口送流量
19
+ - **起之前先检测端口占用**,不盲启后失败
20
+ - **`add` 生成覆写模板** `overwrite.tunnel-<名字>.yaml`(已建好 socks5 节点与 select 分组,分流规则留白待填),生成后完全由用户维护,CLI 不再改写,`tunnel rm` 也不删它
21
+ - 新增 `reset tunnel` 目标(先停进程再删运行态,反序会导致 ssh 进程失联且再也停不掉)
22
+
23
+ 安全边界:`-D` 恒绑 `127.0.0.1` 且不提供绑定地址开关(绑 `0.0.0.0` 会让同一 WiFi 下任何设备经本机进内网);`--host` 拒绝 `-` 开头的值(`-oProxyCommand=...` 等同任意命令执行);ssh 参数固定带 `ExitOnForwardFailure`/`BatchMode`/`ConnectTimeout`/`ServerAlive*`
24
+
25
+ 暂不做自动重连保活:断线依靠 `ServerAliveInterval` 让进程自退,再用 `tunnel status` 查出来
26
+
27
+ ### 修复
28
+
29
+ - **`reset --full` 会留下空的 `settings.json`** - 新增的 `tunnel` 目标其 `onAfter` 会 `writeSettings` 清空隧道列表,若排在 `settings` 之后就会把刚删掉的文件重建成 `{}`,与「已重置: 设置」矛盾。现移到 `settings` 之前(与 `subs` 同理),并加单测锁定该顺序约束——这是 v3.7.0 已修过一次的同类问题(当时是参数顺序),换个目标又复发了
30
+
3
31
  ## [3.7.0] - 2026-08-22
4
32
 
5
33
  ### 修复
package/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - 🔄 **智能重启** - `sub use` 切换订阅、`ow on/off` 切换覆写后自动重启
15
15
  - 🚀 **进程管理** - 启动/停止/切换模式,自动清理残留进程
16
16
  - 🛡️ **进程保活** - 基于 launchd(root),崩溃/开机自动拉起,代理后台常驻(`daemon on`)
17
+ - 🔌 **ssh 隧道出口** - 管理 `ssh -D` 进程生命周期,把内网机器变成本地 SOCKS5 出口,随 `start` 一并拉起
17
18
  - 🔄 **双模式支持** - Mixed 模式和 TUN 透明代理模式
18
19
  - 📊 **状态监控** - 查看运行状态、内存占用、订阅流量与到期时间
19
20
  - 📝 **日志管理** - 实时日志 + 历史日志归档(自动轮转,保留7天)
@@ -126,11 +127,16 @@ mihomo ui yacd # YACD
126
127
  | --------------------------------- | ------------------------------------------------------------------- |
127
128
  | `mihomo kernel [--mirror [镜像]]` | 更新内核(默认直连,`--mirror` 使用镜像;更新后运行中实例需重启生效) |
128
129
  | `mihomo daemon [on\|off\|status]` | 进程保活:开机自启 + 崩溃自动重启(仅 Mixed 模式,on/off 需管理员密码) |
130
+ | `mihomo tunnel` | 列出 ssh 隧道及真实状态(别名 `ssh`/`tunnels`) |
131
+ | `mihomo tunnel add <名字> --host <主机> --port <端口> [--no-auto]` | 添加隧道并生成覆写模板(默认随 start 拉起) |
132
+ | `mihomo tunnel up\|down [名字]` | 启动/停止隧道(无参即全部) |
133
+ | `mihomo tunnel status [名字]` | 查看隧道状态(真实探测端口,能识别「假活」) |
134
+ | `mihomo tunnel rm <名字> [-y]` | 删除隧道(不删覆写文件) |
129
135
  | `mihomo update` | 更新 mihomo-cli(先查 npm 最新版,已是最新则跳过重装) |
130
136
  | `mihomo ui [zash\|dash\|yacd]` | 打开 Web UI |
131
137
  | `mihomo dir` | 显示数据目录位置 |
132
138
  | `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` 跳过确认) |
139
+ | `mihomo reset [目标...] [--full] [-y]` | 重置用户数据(可用目标:`subs`, `logs`, `data`, `runtime`, `settings`, `kernel`, `overwrites`, `daemon`, `tunnel`;`--full` 删全部,`-y` 跳过确认) |
134
140
  | `mihomo version` | 显示版本信息 |
135
141
  | `mihomo help` | 显示帮助信息 |
136
142
 
@@ -143,7 +149,7 @@ mihomo ui yacd # YACD
143
149
  - `mhm`
144
150
  - `mh`
145
151
 
146
- 子命令组亦有别名:`subscription` = `sub`/`subs`/`subscriptions`,`directory` = `dir`/`dirs`/`directories`,`overwrite` = `ow`
152
+ 子命令组亦有别名:`subscription` = `sub`/`subs`/`subscriptions`,`directory` = `dir`/`dirs`/`directories`,`overwrite` = `ow`,`tunnel` = `ssh`/`tunnels`(`tun` 已被 TUN 模式占用)
147
153
 
148
154
  ### 快捷命令
149
155
 
@@ -173,6 +179,54 @@ mihomo ui yacd # YACD
173
179
  - 需要 sudo / 管理员权限
174
180
  - 首次使用会自动配置 DNS 和路由
175
181
 
182
+ ## ssh 隧道出口
183
+
184
+ 把一台可 ssh 登录的机器(如公司内网的机器)变成本地 SOCKS5 出口,配合覆写规则即可只让内网域名走它,其余流量照常走订阅节点。本功能负责的是 **ssh 进程的生命周期管理**——隧道断了 mihomo 并不知情,会一直往死端口送流量。
185
+
186
+ ```bash
187
+ mihomo tunnel add work --host m4 --port 1080 # m4 是 ~/.ssh/config 里的别名
188
+ mihomo tunnel up work # 启动
189
+ mihomo tunnel status # 查看状态(真实探测端口)
190
+ mihomo tunnel down work # 停止
191
+ ```
192
+
193
+ `add` 会生成一份覆写模板 `overwrite.tunnel-<名字>.yaml`,其中已建好 socks5 节点与分组,
194
+ **分流规则留白待你填写**(CLI 无从知道你的内网域名):
195
+
196
+ ```yaml
197
+ # 取消注释并改成你的内网域名/网段
198
+ +rules:
199
+ - DOMAIN-SUFFIX,example.internal,Tunnel-work
200
+ - IP-CIDR,10.0.0.0/8,Tunnel-work
201
+ ```
202
+
203
+ 该文件生成后**完全由你维护**,CLI 不会再改写;`tunnel rm` 也不会删它。改完执行 `mihomo start` 生效。
204
+
205
+ ### 与 start / stop 的联动
206
+
207
+ 隧道默认带 `auto` 标记,`mihomo start` 会顺带拉起、`mihomo stop` 会连带停止(`--no-tunnel` 可跳过;`add` 时加 `--no-auto` 则不参与)。
208
+
209
+ - **隧道起不来不会让 `start` 失败**——它只影响内网分流那部分规则,其余流量正常,但会打印显眼的黄色警告并附上 ssh 给出的原因
210
+ - **`stop` 只停自己起的**:手动 `tunnel up` 起的隧道不会被 `mihomo stop` 带走,避免下次 start 又起一个而累积僵尸进程
211
+
212
+ ### 状态的三种形态
213
+
214
+ `tunnel status` 会**真实探测端口是否在监听**,而不是只看进程在不在:
215
+
216
+ | 状态 | 含义 |
217
+ | --- | --- |
218
+ | 运行中 | 进程在且端口在监听,可正常使用 |
219
+ | **假活** | 进程还在但端口不通——最需要警惕的形态,此时 mihomo 仍在往死端口送流量 |
220
+ | 未运行 | 进程不在 |
221
+
222
+ ### 安全边界
223
+
224
+ - `-D` **恒绑 `127.0.0.1`**,不提供绑定地址开关:绑 `0.0.0.0` 会让同一 WiFi 下任何设备都能经本机进入内网
225
+ - ssh 参数固定带 `ExitOnForwardFailure`/`BatchMode`/`ConnectTimeout`/`ServerAlive*`,分别防「假活」、无 TTY 挂死、久等、断线后端口成僵尸
226
+ - `--host` 拒绝以 `-` 开头的值(`-oProxyCommand=...` 会被 ssh 当选项解析,等同任意命令执行)
227
+
228
+ > 暂不做自动重连保活。断线依靠 `ServerAliveInterval` 让 ssh 进程自行退出,再用 `tunnel status` 查出来。
229
+
176
230
  ## 进程保活
177
231
 
178
232
  默认情况下,mihomo 内核在后台独立运行,但如果内核崩溃、被系统 kill(如内存不足)、或重启/重新登录后,代理就会失效且不会自动恢复。进程保活用 macOS 原生的 **launchd** 解决这个问题。
@@ -279,6 +333,7 @@ mihomo test --timeout=3000 # 长选项 + 等号
279
333
  ├── settings.json # 用户设置(订阅列表等)
280
334
  ├── overwrite.yaml # 覆写配置(主文件,可选)
281
335
  ├── overwrite.*.yaml # 覆写配置(扩展文件,如 overwrite.dns.yaml)
336
+ ├── overwrite.tunnel-*.yaml # 隧道覆写(首次由 tunnel add 生成,此后由你维护)
282
337
  ├── subscriptions/
283
338
  │ ├── cache.json # 订阅动态缓存(更新时间、流量、到期时间等)
284
339
  │ └── <name>.yaml # 订阅原始配置
@@ -286,8 +341,11 @@ mihomo test --timeout=3000 # 长选项 + 等号
286
341
  │ └── mihomo # mihomo 内核二进制
287
342
  ├── logs/
288
343
  │ ├── mihomo.log # 当前日志
289
- └── mihomo.YYYY-MM-DD_HH-MM-SS.log # 归档日志
344
+ ├── mihomo.YYYY-MM-DD_HH-MM-SS.log # 归档日志
345
+ │ └── tunnel-<name>.log # 隧道 ssh 输出(每次启动覆写)
290
346
  ├── data/ # mihomo 运行数据(GeoIP 等,由内核自行管理)
347
+ ├── tunnel/ # 隧道运行态(stop 不清除,故不放在 runtime/)
348
+ │ └── <name>.json # PID、谁启动的、启动时间
291
349
  └── runtime/ # 运行时临时文件(stop 自动清除)
292
350
  ├── pid # 进程 PID
293
351
  ├── config.yaml # 运行时生成的配置
package/dist/index.js CHANGED
@@ -3182,7 +3182,11 @@ var DIRS = {
3182
3182
  subscriptions: path.join(USER_DATA_DIR, "subscriptions"),
3183
3183
  logs: path.join(USER_DATA_DIR, "logs"),
3184
3184
  data: path.join(USER_DATA_DIR, "data"),
3185
- runtime: path.join(USER_DATA_DIR, "runtime")
3185
+ runtime: path.join(USER_DATA_DIR, "runtime"),
3186
+ // 隧道运行态。刻意独立于 runtime/:clearRuntime() 会在 stop() 成功路径 rmrf 整个
3187
+ // runtime 目录,隧道状态放那里会被 `mihomo stop` 连同 config.yaml 一起抹掉,
3188
+ // 于是「谁起的」标记丢失、手动起的隧道再也无法被识别
3189
+ tunnel: path.join(USER_DATA_DIR, "tunnel")
3186
3190
  };
3187
3191
  var PATHS = {
3188
3192
  mihomoBinary: path.join(DIRS.kernel, "mihomo"),
@@ -3764,10 +3768,40 @@ function parseIntArg(args, short, long, defaultValue) {
3764
3768
  }
3765
3769
  return defaultValue;
3766
3770
  }
3767
- var VALUE_FLAGS = /* @__PURE__ */ new Set(["-t", "--timeout", "-j", "--concurrency", "-r", "--rounds", "-n", "--lines", "-u", "--update-timeout"]);
3771
+ function parseStringArg(args, long, short) {
3772
+ if (!args) return null;
3773
+ for (let i = 0; i < args.length; i++) {
3774
+ if (args[i] === long || short !== void 0 && args[i] === short) {
3775
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
3776
+ return args[i + 1];
3777
+ }
3778
+ throw new CliError(`\u9009\u9879 ${args[i]} \u7F3A\u5C11\u503C`, { hint: [`\u4F8B\u5982: ${long} <\u503C>`] });
3779
+ }
3780
+ if (args[i].startsWith(`${long}=`)) {
3781
+ const value = args[i].slice(long.length + 1);
3782
+ if (!value) throw new CliError(`\u9009\u9879 ${long} \u7F3A\u5C11\u503C`, { hint: [`\u4F8B\u5982: ${long}=<\u503C>`] });
3783
+ return value;
3784
+ }
3785
+ }
3786
+ return null;
3787
+ }
3788
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3789
+ "-t",
3790
+ "--timeout",
3791
+ "-j",
3792
+ "--concurrency",
3793
+ "-r",
3794
+ "--rounds",
3795
+ "-n",
3796
+ "--lines",
3797
+ "-u",
3798
+ "--update-timeout",
3799
+ "--host",
3800
+ "--port"
3801
+ ]);
3768
3802
  function extractStartOptions(args) {
3769
3803
  if (!args) return [];
3770
- const BOOL_FLAGS = /* @__PURE__ */ new Set(["-s", "--no-update", "--no-clean"]);
3804
+ const BOOL_FLAGS = /* @__PURE__ */ new Set(["-s", "--no-update", "--no-clean", "--no-tunnel"]);
3771
3805
  const out = [];
3772
3806
  for (let i = 0; i < args.length; i++) {
3773
3807
  const a = args[i];
@@ -4232,12 +4266,13 @@ ${colors.cyan(colors.bold(`mihomo-cli v${VERSION}`))}`, "", "\u547D\u4EE4\u522B\
4232
4266
  " mihomo start -s # \u8DF3\u8FC7\u81EA\u52A8\u66F4\u65B0\u8BA2\u9605",
4233
4267
  " mihomo start -u 30000 # \u81EA\u52A8\u66F4\u65B0\u8D85\u65F6 30 \u79D2 (\u9ED8\u8BA4 10s)",
4234
4268
  " mihomo daemon on # \u5F00\u542F\u4FDD\u6D3B\uFF08\u5F00\u673A\u81EA\u542F + \u5D29\u6E83\u91CD\u542F\uFF09",
4269
+ " mihomo tunnel add work --host m4 --port 1080 # \u52A0 ssh \u96A7\u9053\u51FA\u53E3",
4235
4270
  " mihomo sub add <url> # \u6DFB\u52A0\u8BA2\u9605 (sub \u662F subscription \u522B\u540D)",
4236
4271
  " mihomo ui # \u6253\u5F00 Web UI",
4237
4272
  "",
4238
4273
  `${colors.cyan("\u5FEB\u6377\u547D\u4EE4:")}`,
4239
4274
  " tun = start tun use = sub use on/off = ow on/off open = dir open",
4240
- " up = start down = stop upd/upgrade = update",
4275
+ " up = start down = stop upd/upgrade = update ssh = tunnel",
4241
4276
  "",
4242
4277
  `${colors.cyan("\u6A21\u5F0F\u8BF4\u660E:")}`,
4243
4278
  " mixed HTTP + SOCKS5 \u6DF7\u5408\u7AEF\u53E3 (\u9ED8\u8BA4)",
@@ -5734,8 +5769,373 @@ function formatTestSummary(summary) {
5734
5769
  return `\u7ED3\u679C: ${colors.green(`${summary.alive} \u5B58\u6D3B`)} / ${colors.red(`${summary.dead} \u5931\u8D25`)} / ${summary.total} \u603B\u8BA1`;
5735
5770
  }
5736
5771
 
5772
+ // src/tunnel.ts
5773
+ import { spawn as spawn2 } from "child_process";
5774
+ import fs7 from "fs";
5775
+ import net from "net";
5776
+ import path6 from "path";
5777
+ var PORT_PROBE_TIMEOUT_MS = 300;
5778
+ var STOP_WAIT_ATTEMPTS = 20;
5779
+ var STOP_WAIT_INTERVAL = 100;
5780
+ var START_WAIT_ATTEMPTS = 40;
5781
+ var START_WAIT_INTERVAL = 500;
5782
+ function getReservedPorts() {
5783
+ const reserved = /* @__PURE__ */ new Map();
5784
+ const mixedPort = Number(BASE_CONFIG["mixed-port"]);
5785
+ if (Number.isInteger(mixedPort)) reserved.set(mixedPort, "mihomo \u6DF7\u5408\u4EE3\u7406\u7AEF\u53E3");
5786
+ reserved.set(CONTROLLER_PORT, "mihomo \u63A7\u5236\u5668\u7AEF\u53E3");
5787
+ const testPort = Number(TEST_CONFIG["mixed-port"]);
5788
+ if (Number.isInteger(testPort)) reserved.set(testPort, "\u6D4B\u901F\u5B9E\u4F8B\u4EE3\u7406\u7AEF\u53E3");
5789
+ const testController = Number(TEST_CONTROLLER_ADDR.split(":")[1]);
5790
+ if (Number.isInteger(testController)) reserved.set(testController, "\u6D4B\u901F\u5B9E\u4F8B\u63A7\u5236\u5668\u7AEF\u53E3");
5791
+ return reserved;
5792
+ }
5793
+ function validateTunnelName(name) {
5794
+ if (!name || !SAFE_NAME_RE.test(name)) {
5795
+ throw new CliError(`\u96A7\u9053\u540D\u79F0\u65E0\u6548: "${name}"\uFF0C\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u3001\u77ED\u6A2A\u7EBF\u548C\u4E2D\u6587\uFF08\u6700\u957F 64 \u5B57\u7B26\uFF09`);
5796
+ }
5797
+ }
5798
+ var SAFE_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;
5799
+ function validateTunnelHost(host) {
5800
+ if (!host) {
5801
+ throw new CliError("\u7F3A\u5C11 --host", { hint: ["\u4F8B\u5982: mihomo tunnel add work --host m4 --port 1080"] });
5802
+ }
5803
+ if (host.startsWith("-")) {
5804
+ throw new CliError(`\u4E3B\u673A\u540D\u65E0\u6548: "${host}"`, {
5805
+ label: "\u53C2\u6570\u9519\u8BEF",
5806
+ hint: ['\u4E3B\u673A\u540D\u4E0D\u80FD\u4EE5 "-" \u5F00\u5934\u2014\u2014\u5B83\u4F1A\u88AB ssh \u5F53\u4F5C\u9009\u9879\u89E3\u6790\uFF08\u5982 -oProxyCommand=... \u53EF\u6267\u884C\u4EFB\u610F\u547D\u4EE4\uFF09\u3002']
5807
+ });
5808
+ }
5809
+ if (!SAFE_HOST_RE.test(host)) {
5810
+ throw new CliError(`\u4E3B\u673A\u540D\u65E0\u6548: "${host}"\uFF0C\u53EA\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u4E0B\u5212\u7EBF\u3001\u77ED\u6A2A\u7EBF\u548C @`, {
5811
+ label: "\u53C2\u6570\u9519\u8BEF",
5812
+ hint: ["\u53EF\u7528 ssh \u522B\u540D\uFF08~/.ssh/config \u91CC\u7684 Host\uFF09\u6216 user@hostname\u3002"]
5813
+ });
5814
+ }
5815
+ }
5816
+ function validateTunnelPort(port, exclude) {
5817
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5818
+ throw new CliError(`\u7AEF\u53E3\u65E0\u6548: ${port}\uFF0C\u9700\u4E3A 1-65535 \u7684\u6574\u6570`);
5819
+ }
5820
+ const reservedLabel = getReservedPorts().get(port);
5821
+ if (reservedLabel) {
5822
+ throw new CliError(`\u7AEF\u53E3 ${port} \u5DF2\u88AB ${reservedLabel} \u5360\u7528`, {
5823
+ label: "\u7AEF\u53E3\u51B2\u7A81",
5824
+ hint: ["\u8BF7\u6362\u4E00\u4E2A\u7AEF\u53E3\uFF0C\u4F8B\u5982 1080\u3002"]
5825
+ });
5826
+ }
5827
+ const conflict = getTunnels().find((t) => t.port === port && t.name !== exclude);
5828
+ if (conflict) {
5829
+ throw new CliError(`\u7AEF\u53E3 ${port} \u5DF2\u88AB\u96A7\u9053 "${conflict.name}" \u4F7F\u7528`, { label: "\u7AEF\u53E3\u51B2\u7A81" });
5830
+ }
5831
+ }
5832
+ function getTunnels() {
5833
+ const settings = readSettings();
5834
+ const tunnels = settings.tunnels;
5835
+ if (!Array.isArray(tunnels)) {
5836
+ if (tunnels !== void 0) {
5837
+ console.warn("\u8B66\u544A: settings.json \u7684 tunnels \u4E0D\u662F\u5217\u8868\uFF0C\u5DF2\u5FFD\u7565\uFF08\u53EF\u7528 mihomo tunnel add \u91CD\u65B0\u6DFB\u52A0\uFF09");
5838
+ }
5839
+ return [];
5840
+ }
5841
+ return tunnels.filter((t) => t != null && typeof t === "object" && typeof t.name === "string" && typeof t.host === "string" && Number.isInteger(t.port));
5842
+ }
5843
+ function findTunnel(name) {
5844
+ return getTunnels().find((t) => t.name === name);
5845
+ }
5846
+ function addTunnel(config) {
5847
+ validateTunnelName(config.name);
5848
+ validateTunnelHost(config.host);
5849
+ validateTunnelPort(config.port);
5850
+ const tunnels = [...getTunnels()];
5851
+ if (tunnels.some((t) => t.name === config.name)) {
5852
+ throw new CliError(`\u96A7\u9053 "${config.name}" \u5DF2\u5B58\u5728\uFF0C\u8BF7\u6362\u4E2A\u540D\u79F0\uFF0C\u6216\u5148\u5220\u9664\uFF08mihomo tunnel rm ${config.name}\uFF09`);
5853
+ }
5854
+ tunnels.push(config);
5855
+ writeSettings({ tunnels });
5856
+ }
5857
+ function removeTunnel(name) {
5858
+ const tunnels = [...getTunnels()];
5859
+ const idx = tunnels.findIndex((t) => t.name === name);
5860
+ if (idx < 0) return false;
5861
+ tunnels.splice(idx, 1);
5862
+ writeSettings({ tunnels });
5863
+ clearTunnelRuntime(name);
5864
+ return true;
5865
+ }
5866
+ function getTunnelRuntimePath(name) {
5867
+ validateTunnelName(name);
5868
+ return path6.join(DIRS.tunnel, `${name}.json`);
5869
+ }
5870
+ function readTunnelRuntime(name) {
5871
+ try {
5872
+ const raw = fs7.readFileSync(getTunnelRuntimePath(name), "utf8");
5873
+ const parsed = JSON.parse(raw);
5874
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
5875
+ const r = parsed;
5876
+ if (!Number.isInteger(r.pid) || r.pid <= 0) return null;
5877
+ return {
5878
+ pid: r.pid,
5879
+ started_by: r.started_by === "manual" ? "manual" : "auto",
5880
+ started_at: typeof r.started_at === "string" ? r.started_at : "",
5881
+ port: Number.isInteger(r.port) ? r.port : 0
5882
+ };
5883
+ } catch {
5884
+ return null;
5885
+ }
5886
+ }
5887
+ function writeTunnelRuntime(name, runtime) {
5888
+ ensureDirs();
5889
+ atomicWriteFileSync(getTunnelRuntimePath(name), JSON.stringify(runtime, null, 2), { mode: 384 });
5890
+ }
5891
+ function clearTunnelRuntime(name) {
5892
+ try {
5893
+ fs7.rmSync(getTunnelRuntimePath(name), { force: true });
5894
+ } catch {
5895
+ }
5896
+ }
5897
+ function buildSshArgs(tunnel) {
5898
+ return [
5899
+ "-D",
5900
+ `127.0.0.1:${tunnel.port}`,
5901
+ "-N",
5902
+ "-o",
5903
+ "ExitOnForwardFailure=yes",
5904
+ "-o",
5905
+ "BatchMode=yes",
5906
+ "-o",
5907
+ "ServerAliveInterval=30",
5908
+ "-o",
5909
+ "ServerAliveCountMax=3",
5910
+ "-o",
5911
+ "ConnectTimeout=15",
5912
+ tunnel.host
5913
+ ];
5914
+ }
5915
+ function commandNeedle(port) {
5916
+ return `-D 127.0.0.1:${port}`;
5917
+ }
5918
+ function getTunnelLogPath(name) {
5919
+ validateTunnelName(name);
5920
+ return path6.join(DIRS.logs, `tunnel-${name}.log`);
5921
+ }
5922
+ function isTunnelProcessAlive(pid, port) {
5923
+ return isProcessRunning(pid) && isProcessCommandMatching(pid, commandNeedle(port));
5924
+ }
5925
+ function isPortListening(port, timeoutMs = PORT_PROBE_TIMEOUT_MS) {
5926
+ return new Promise((resolve) => {
5927
+ const socket = new net.Socket();
5928
+ let settled = false;
5929
+ const finish = (result) => {
5930
+ if (settled) return;
5931
+ settled = true;
5932
+ socket.destroy();
5933
+ resolve(result);
5934
+ };
5935
+ socket.setTimeout(timeoutMs);
5936
+ socket.once("connect", () => finish(true));
5937
+ socket.once("timeout", () => finish(false));
5938
+ socket.once("error", () => finish(false));
5939
+ socket.connect(port, "127.0.0.1");
5940
+ });
5941
+ }
5942
+ async function getTunnelStatus(config) {
5943
+ const runtime = readTunnelRuntime(config.name);
5944
+ const alive = runtime !== null && isTunnelProcessAlive(runtime.pid, runtime.port || config.port);
5945
+ if (!alive) {
5946
+ return { config, state: "stopped", pid: null, started_by: null, started_at: null };
5947
+ }
5948
+ const listening = await isPortListening(config.port);
5949
+ return {
5950
+ config,
5951
+ state: listening ? "running" : "dead-port",
5952
+ pid: runtime.pid,
5953
+ started_by: runtime.started_by,
5954
+ started_at: runtime.started_at || null
5955
+ };
5956
+ }
5957
+ async function getAllTunnelStatus() {
5958
+ return Promise.all(getTunnels().map(getTunnelStatus));
5959
+ }
5960
+ async function startTunnel(name, options) {
5961
+ const config = findTunnel(name);
5962
+ if (!config) {
5963
+ throw new CliError(`\u672A\u627E\u5230\u96A7\u9053 "${name}"`, { hint: ["\u67E5\u770B\u5168\u90E8\u96A7\u9053: mihomo tunnel"] });
5964
+ }
5965
+ const existing = readTunnelRuntime(name);
5966
+ if (existing && isTunnelProcessAlive(existing.pid, existing.port || config.port)) {
5967
+ if (options.startedBy === "manual" && existing.started_by === "auto") {
5968
+ writeTunnelRuntime(name, { ...existing, started_by: "manual" });
5969
+ }
5970
+ return { alreadyRunning: true, pid: existing.pid };
5971
+ }
5972
+ if (existing) clearTunnelRuntime(name);
5973
+ if (await isPortListening(config.port)) {
5974
+ throw new CliError(`\u7AEF\u53E3 ${config.port} \u5DF2\u88AB\u5360\u7528`, {
5975
+ label: "\u65E0\u6CD5\u542F\u52A8\u96A7\u9053",
5976
+ hint: [`\u53EF\u80FD\u5DF2\u6709\u96A7\u9053\u6216\u5176\u4ED6\u7A0B\u5E8F\u5728\u76D1\u542C 127.0.0.1:${config.port}\u3002`, `\u67E5\u770B\u5360\u7528: lsof -nP -iTCP:${config.port} -sTCP:LISTEN`]
5977
+ });
5978
+ }
5979
+ ensureDirs();
5980
+ const logPath = getTunnelLogPath(name);
5981
+ const logFd = fs7.openSync(logPath, "w");
5982
+ const child = spawn2("ssh", buildSshArgs(config), {
5983
+ detached: true,
5984
+ stdio: ["ignore", logFd, logFd]
5985
+ });
5986
+ child.on("error", () => {
5987
+ });
5988
+ fs7.closeSync(logFd);
5989
+ child.unref();
5990
+ const pid = child.pid;
5991
+ if (!pid) {
5992
+ throw new CliError("\u65E0\u6CD5\u521B\u5EFA ssh \u8FDB\u7A0B", { label: "\u542F\u52A8\u96A7\u9053\u5931\u8D25", hint: ["\u8BF7\u786E\u8BA4\u5DF2\u5B89\u88C5 ssh \u5BA2\u6237\u7AEF\u3002"] });
5993
+ }
5994
+ writeTunnelRuntime(name, {
5995
+ pid,
5996
+ started_by: options.startedBy,
5997
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
5998
+ port: config.port
5999
+ });
6000
+ for (let i = 0; i < START_WAIT_ATTEMPTS; i++) {
6001
+ if (!isProcessRunning(pid)) break;
6002
+ if (await isPortListening(config.port)) {
6003
+ return { alreadyRunning: false, pid };
6004
+ }
6005
+ await new Promise((resolve) => setTimeout(resolve, START_WAIT_INTERVAL));
6006
+ }
6007
+ clearTunnelRuntime(name);
6008
+ if (isProcessRunning(pid)) {
6009
+ try {
6010
+ process.kill(pid, "SIGKILL");
6011
+ } catch {
6012
+ }
6013
+ }
6014
+ throw new CliError("ssh \u96A7\u9053\u672A\u80FD\u5EFA\u7ACB", {
6015
+ label: "\u542F\u52A8\u96A7\u9053\u5931\u8D25",
6016
+ hint: [...readLogTail(logPath), `\u5B8C\u6574\u65E5\u5FD7: ${logPath}`]
6017
+ });
6018
+ }
6019
+ function readLogTail(logPath, maxLines = 5) {
6020
+ try {
6021
+ const content = fs7.readFileSync(logPath, "utf8").trim();
6022
+ if (!content) return [];
6023
+ return content.split("\n").slice(-maxLines).map((line) => ` ${line.trim()}`);
6024
+ } catch {
6025
+ return [];
6026
+ }
6027
+ }
6028
+ function stopTunnel(name) {
6029
+ const config = findTunnel(name);
6030
+ const runtime = readTunnelRuntime(name);
6031
+ if (!runtime) {
6032
+ clearTunnelRuntime(name);
6033
+ return { notRunning: true, pid: null };
6034
+ }
6035
+ const port = runtime.port || config?.port || 0;
6036
+ const { pid } = runtime;
6037
+ if (!isTunnelProcessAlive(pid, port)) {
6038
+ clearTunnelRuntime(name);
6039
+ return { notRunning: true, pid: null };
6040
+ }
6041
+ try {
6042
+ process.kill(pid, "SIGTERM");
6043
+ } catch {
6044
+ }
6045
+ for (let i = 0; i < STOP_WAIT_ATTEMPTS; i++) {
6046
+ if (!isProcessRunning(pid)) break;
6047
+ sleepSync(STOP_WAIT_INTERVAL);
6048
+ }
6049
+ if (isProcessRunning(pid) && isProcessCommandMatching(pid, commandNeedle(port))) {
6050
+ try {
6051
+ process.kill(pid, "SIGKILL");
6052
+ } catch {
6053
+ }
6054
+ for (let i = 0; i < STOP_WAIT_ATTEMPTS; i++) {
6055
+ if (!isProcessRunning(pid)) break;
6056
+ sleepSync(STOP_WAIT_INTERVAL);
6057
+ }
6058
+ }
6059
+ clearTunnelRuntime(name);
6060
+ return { notRunning: false, pid };
6061
+ }
6062
+ async function startAutoTunnels() {
6063
+ const outcomes = [];
6064
+ for (const tunnel of getTunnels()) {
6065
+ if (!tunnel.auto) continue;
6066
+ try {
6067
+ const result = await startTunnel(tunnel.name, { startedBy: "auto" });
6068
+ outcomes.push({ name: tunnel.name, ok: true, alreadyRunning: result.alreadyRunning });
6069
+ } catch (e) {
6070
+ outcomes.push({
6071
+ name: tunnel.name,
6072
+ ok: false,
6073
+ error: e instanceof CliError ? e : new CliError(e.message)
6074
+ });
6075
+ }
6076
+ }
6077
+ return outcomes;
6078
+ }
6079
+ function stopAutoTunnels() {
6080
+ const stopped = [];
6081
+ for (const tunnel of getTunnels()) {
6082
+ const runtime = readTunnelRuntime(tunnel.name);
6083
+ if (runtime?.started_by !== "auto") continue;
6084
+ const result = stopTunnel(tunnel.name);
6085
+ if (!result.notRunning) stopped.push(tunnel.name);
6086
+ }
6087
+ return stopped;
6088
+ }
6089
+ function stopAllTunnels() {
6090
+ const stopped = [];
6091
+ for (const tunnel of getTunnels()) {
6092
+ const result = stopTunnel(tunnel.name);
6093
+ if (!result.notRunning) stopped.push(tunnel.name);
6094
+ }
6095
+ return stopped;
6096
+ }
6097
+ function getTunnelOverwritePath(name) {
6098
+ validateTunnelName(name);
6099
+ return path6.join(USER_DATA_DIR, `overwrite.tunnel-${name}.yaml`);
6100
+ }
6101
+ function renderTunnelOverwrite(tunnel) {
6102
+ const proxyName = `Tunnel-${tunnel.name}-Host`;
6103
+ const groupName = `Tunnel-${tunnel.name}`;
6104
+ return `# mihomo-cli \u96A7\u9053\u8986\u5199\uFF08tunnel: ${tunnel.name}\uFF09
6105
+ # \u672C\u6587\u4EF6\u7531 mihomo-cli \u9996\u6B21\u521B\u5EFA\uFF0C\u4E4B\u540E\u5B8C\u5168\u7531\u4F60\u7EF4\u62A4\u2014\u2014CLI \u4E0D\u4F1A\u518D\u6539\u5199\u6216\u5220\u9664\u5B83\u3002
6106
+ #
6107
+ # ~ \u662F\u300C\u6309 name \u5C31\u5730\u5408\u5E76\u300D\u8BED\u4E49\uFF1A\u4E0E\u5176\u4ED6\u8986\u5199\u6587\u4EF6\u4E2D\u7684\u540C\u540D\u8282\u70B9/\u5206\u7EC4\u5408\u5E76\uFF0C
6108
+ # \u4E0D\u4F9D\u8D56\u6587\u4EF6\u52A0\u8F7D\u987A\u5E8F\uFF08+proxies \u5219\u4F9D\u8D56\u5B57\u6BCD\u5E8F\uFF0C\u4F1A\u88AB\u540E\u6765\u7684\u6587\u4EF6\u538B\u8FC7\u53BB\uFF09\u3002
6109
+
6110
+ ~proxies:
6111
+ - name: ${proxyName}
6112
+ type: socks5
6113
+ server: 127.0.0.1
6114
+ port: ${tunnel.port}
6115
+
6116
+ ~proxy-groups:
6117
+ - name: ${groupName}
6118
+ type: select
6119
+ proxies:
6120
+ - ${proxyName}
6121
+ - DIRECT
6122
+
6123
+ # \u53D6\u6D88\u6CE8\u91CA\u5E76\u586B\u5165\u9700\u8981\u8D70\u96A7\u9053\u7684\u5185\u7F51\u57DF\u540D/\u7F51\u6BB5\uFF08CLI \u65E0\u4ECE\u77E5\u9053\u4F60\u7684\u5185\u7F51\u5730\u5740\uFF09\uFF1A
6124
+ # +rules:
6125
+ # - DOMAIN-SUFFIX,example.internal,${groupName}
6126
+ # - IP-CIDR,10.0.0.0/8,${groupName}
6127
+ `;
6128
+ }
6129
+ function ensureTunnelOverwriteFile(tunnel) {
6130
+ const filePath = getTunnelOverwritePath(tunnel.name);
6131
+ if (fs7.existsSync(filePath)) return false;
6132
+ ensureDirs();
6133
+ atomicWriteFileSync(filePath, renderTunnelOverwrite(tunnel), { mode: 384 });
6134
+ return true;
6135
+ }
6136
+
5737
6137
  // src/commands/status.ts
5738
- function printStatus() {
6138
+ async function printStatus() {
5739
6139
  const status = getStatus();
5740
6140
  const state = getRunningState();
5741
6141
  const info = getConfigInfo();
@@ -5802,6 +6202,17 @@ function printStatus() {
5802
6202
  if (isDaemonEnabled()) {
5803
6203
  console.log(`${colors.gray("\u4FDD\u6D3B: ")}${colors.green("\u5DF2\u542F\u7528")} ${colors.gray("(\u5F00\u673A\u81EA\u542F + \u5D29\u6E83\u91CD\u542F)")}`);
5804
6204
  }
6205
+ const tunnels = getTunnels();
6206
+ if (tunnels.length > 0) {
6207
+ const statuses = await getAllTunnelStatus();
6208
+ const parts = statuses.map((s) => {
6209
+ const label = `${s.config.name}:${s.config.port}`;
6210
+ if (s.state === "running") return colors.green(label);
6211
+ if (s.state === "dead-port") return colors.yellow(`${label} \u5047\u6D3B`);
6212
+ return colors.gray(label);
6213
+ });
6214
+ console.log(`${colors.gray("\u96A7\u9053: ")}${parts.join(", ")}`);
6215
+ }
5805
6216
  console.log("");
5806
6217
  }
5807
6218
 
@@ -5811,23 +6222,51 @@ function handleStopResult(result) {
5811
6222
  throw new CliError(result.remaining.join(", "), { label: "\u90E8\u5206\u8FDB\u7A0B\u672A\u7EC8\u6B62", hint: "\u8BF7\u624B\u52A8\u8FD0\u884C: sudo pkill -9 mihomo" });
5812
6223
  }
5813
6224
  }
5814
- async function cmdStop() {
6225
+ function stopAutoTunnelsWithLog() {
6226
+ const stopped = stopAutoTunnels();
6227
+ if (stopped.length > 0) {
6228
+ console.log(`${colors.green("\u5DF2\u505C\u6B62\u96A7\u9053")}: ${stopped.join(", ")}`);
6229
+ }
6230
+ }
6231
+ async function cmdStop(args) {
5815
6232
  if (isDaemonEnabled()) {
5816
6233
  console.log(colors.yellow("\u4FDD\u6D3B\u5DF2\u542F\u7528\uFF0C\u4EE3\u7406\u7531 launchd \u6258\u7BA1"));
5817
6234
  console.log("\u76F4\u63A5\u505C\u6B62\u4F1A\u88AB\u81EA\u52A8\u91CD\u65B0\u62C9\u8D77\uFF0C\u8BF7\u7528: mihomo daemon off");
5818
6235
  return;
5819
6236
  }
6237
+ const skipTunnel = hasFlag(args, "--no-tunnel");
5820
6238
  const pids = getMihomoPids();
5821
6239
  if (pids.length === 0) {
5822
6240
  console.log(colors.yellow("\u4E0D\u5728\u8FD0\u884C"));
6241
+ if (!skipTunnel) stopAutoTunnelsWithLog();
5823
6242
  return;
5824
6243
  }
5825
6244
  console.log(`\u505C\u6B62 ${pids.length} \u4E2A\u8FDB\u7A0B...`);
5826
6245
  handleStopResult(stop());
5827
6246
  console.log(colors.green("\u5DF2\u505C\u6B62\u8FDB\u7A0B"));
6247
+ if (!skipTunnel) stopAutoTunnelsWithLog();
5828
6248
  }
5829
6249
 
5830
6250
  // src/commands/start.ts
6251
+ async function startAutoTunnelsWithWarning() {
6252
+ const outcomes = await startAutoTunnels();
6253
+ if (outcomes.length === 0) return;
6254
+ const started = outcomes.filter((o) => o.ok && !o.alreadyRunning);
6255
+ if (started.length > 0) {
6256
+ console.log(`${colors.green("\u5DF2\u542F\u52A8\u96A7\u9053")}: ${started.map((o) => o.name).join(", ")}`);
6257
+ }
6258
+ for (const failed of outcomes.filter((o) => !o.ok)) {
6259
+ console.log("");
6260
+ console.log(colors.yellow(`\u8B66\u544A: \u96A7\u9053 "${failed.name}" \u542F\u52A8\u5931\u8D25`));
6261
+ console.log(colors.gray(` ${failed.error?.message ?? "\u672A\u77E5\u9519\u8BEF"}`));
6262
+ for (const line of failed.error?.hint ?? []) {
6263
+ if (line.trim()) console.log(colors.gray(line.startsWith(" ") ? line : ` ${line}`));
6264
+ }
6265
+ console.log(colors.gray(" \u5185\u7F51\u5206\u6D41\u89C4\u5219\u5C06\u4E0D\u53EF\u7528\uFF0C\u5176\u4F59\u6D41\u91CF\u6B63\u5E38"));
6266
+ console.log(colors.gray(` \u6392\u67E5: mihomo tunnel status ${failed.name}`));
6267
+ console.log("");
6268
+ }
6269
+ }
5831
6270
  async function cmdStart(args) {
5832
6271
  const modeToken = args[1] && !args[1].startsWith("-") ? args[1].toLowerCase() : void 0;
5833
6272
  if (modeToken !== void 0 && modeToken !== "tun" && modeToken !== "mixed") {
@@ -5846,6 +6285,7 @@ async function cmdStart(args) {
5846
6285
  const concurrency = parseIntArg(args, "-j", "--concurrency", DEFAULT_TEST_CONCURRENCY);
5847
6286
  const skipUpdate = hasFlag(args, "-s", "--no-update");
5848
6287
  const skipClean = hasFlag(args, "--no-clean");
6288
+ const skipTunnel = hasFlag(args, "--no-tunnel");
5849
6289
  const updateTimeout = parseIntArg(args, "-u", "--update-timeout", DEFAULT_AUTO_UPDATE_TIMEOUT);
5850
6290
  const sub = requireActiveSubscription("\u6CA1\u6709\u8BA2\u9605\uFF0C\u8BF7\u5148\u6DFB\u52A0\u8BA2\u9605");
5851
6291
  if (!skipUpdate) {
@@ -5887,6 +6327,9 @@ async function cmdStart(args) {
5887
6327
  const lines = e.message.split("\n");
5888
6328
  throw new CliError(lines[0], { label: "\u542F\u52A8\u5931\u8D25", hint: lines.slice(1) });
5889
6329
  }
6330
+ if (!skipTunnel) {
6331
+ await startAutoTunnelsWithWarning();
6332
+ }
5890
6333
  const cleanThreshold = isGithubUrl(sub.url) ? AUTO_CLEAN_THRESHOLD_GITHUB : AUTO_CLEAN_THRESHOLD;
5891
6334
  if (!skipClean && configInfo.proxies > cleanThreshold) {
5892
6335
  const cache = readSubscriptionCache();
@@ -5926,7 +6369,7 @@ async function cmdStart(args) {
5926
6369
  saveSubscriptionCache(sub.name, { last_auto_clean_at: (/* @__PURE__ */ new Date()).toISOString() });
5927
6370
  }
5928
6371
  }
5929
- printStatus();
6372
+ await printStatus();
5930
6373
  }
5931
6374
 
5932
6375
  // src/commands/shared.ts
@@ -6133,8 +6576,8 @@ async function cmdDirectory(args) {
6133
6576
 
6134
6577
  // src/kernel.ts
6135
6578
  import { spawnSync as spawnSync4 } from "child_process";
6136
- import fs7 from "fs";
6137
- import path6 from "path";
6579
+ import fs8 from "fs";
6580
+ import path7 from "path";
6138
6581
 
6139
6582
  // node_modules/compare-versions/lib/esm/utils.js
6140
6583
  var semver = /^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;
@@ -6260,10 +6703,10 @@ async function checkUpdate(mirror) {
6260
6703
  }
6261
6704
  function findBinaryInDir(dir, maxDepth = 4) {
6262
6705
  if (maxDepth <= 0) return null;
6263
- const files = fs7.readdirSync(dir);
6706
+ const files = fs8.readdirSync(dir);
6264
6707
  for (const f of files) {
6265
- const fullPath = path6.join(dir, f);
6266
- const stat = fs7.statSync(fullPath);
6708
+ const fullPath = path7.join(dir, f);
6709
+ const stat = fs8.statSync(fullPath);
6267
6710
  if (stat.isDirectory()) {
6268
6711
  const found = findBinaryInDir(fullPath, maxDepth - 1);
6269
6712
  if (found) return found;
@@ -6289,7 +6732,7 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6289
6732
  \u5E73\u53F0: ${platform}, \u67B6\u6784: ${arch}${hint}`);
6290
6733
  }
6291
6734
  const downloadUrl = withMirror(asset.browser_download_url, mirror);
6292
- const tempPath = path6.join(DIRS.kernel, path6.basename(asset.name));
6735
+ const tempPath = path7.join(DIRS.kernel, path7.basename(asset.name));
6293
6736
  const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
6294
6737
  if (mirror && progressCallback) {
6295
6738
  progressCallback("\u63D0\u793A: \u7ECF\u7B2C\u4E09\u65B9\u955C\u50CF\u4E2D\u8F6C\u4E0B\u8F7D\uFF0C\u65E0\u6CD5\u9A8C\u8BC1\u6765\u6E90\u5B8C\u6574\u6027\uFF0C\u5EFA\u8BAE\u76F4\u8FDE\u6216\u81EA\u884C\u6821\u9A8C\u4EA7\u7269");
@@ -6310,12 +6753,12 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6310
6753
  }
6311
6754
  if (curlResult.status !== 0) {
6312
6755
  try {
6313
- fs7.unlinkSync(tempPath);
6756
+ fs8.unlinkSync(tempPath);
6314
6757
  } catch {
6315
6758
  }
6316
6759
  throw new Error(`\u4E0B\u8F7D\u5931\u8D25 (curl \u9000\u51FA\u7801 ${curlResult.status})`);
6317
6760
  }
6318
- if (!fs7.existsSync(tempPath)) {
6761
+ if (!fs8.existsSync(tempPath)) {
6319
6762
  throw new Error("\u4E0B\u8F7D\u5931\u8D25: \u6587\u4EF6\u672A\u751F\u6210");
6320
6763
  }
6321
6764
  if (progressCallback) {
@@ -6338,17 +6781,17 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6338
6781
  if (tarResult.error) throw tarResult.error;
6339
6782
  if (tarResult.status !== 0) throw new Error(`tar \u9000\u51FA\u7801 ${tarResult.status}`);
6340
6783
  } else if (tempPath.endsWith(".gz")) {
6341
- const baseName = path6.basename(tempPath, ".gz");
6342
- const outputPath = path6.join(extractPath, baseName);
6784
+ const baseName = path7.basename(tempPath, ".gz");
6785
+ const outputPath = path7.join(extractPath, baseName);
6343
6786
  const gzipResult = spawnSync4("gzip", ["-dc", tempPath], { maxBuffer: 256 * 1024 * 1024, timeout: 6e4 });
6344
6787
  if (gzipResult.error) throw gzipResult.error;
6345
6788
  if (gzipResult.status !== 0) throw new Error(`gzip \u9000\u51FA\u7801 ${gzipResult.status}`);
6346
- fs7.writeFileSync(outputPath, gzipResult.stdout, { mode: 493 });
6789
+ fs8.writeFileSync(outputPath, gzipResult.stdout, { mode: 493 });
6347
6790
  extractedBinary = outputPath;
6348
6791
  }
6349
6792
  } catch (e) {
6350
6793
  try {
6351
- fs7.unlinkSync(tempPath);
6794
+ fs8.unlinkSync(tempPath);
6352
6795
  } catch {
6353
6796
  }
6354
6797
  throw new Error(`\u89E3\u538B\u5931\u8D25: ${e.message}`);
@@ -6356,23 +6799,23 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6356
6799
  const foundBinary = extractedBinary || findBinaryInDir(extractPath);
6357
6800
  if (!foundBinary) {
6358
6801
  try {
6359
- fs7.unlinkSync(tempPath);
6802
+ fs8.unlinkSync(tempPath);
6360
6803
  } catch {
6361
6804
  }
6362
6805
  throw new Error("\u89E3\u538B\u540E\u672A\u627E\u5230\u53EF\u6267\u884C\u6587\u4EF6");
6363
6806
  }
6364
6807
  const targetPath = PATHS.mihomoBinary;
6365
6808
  if (foundBinary !== targetPath) {
6366
- if (fs7.existsSync(targetPath)) {
6367
- fs7.chmodSync(targetPath, 493);
6809
+ if (fs8.existsSync(targetPath)) {
6810
+ fs8.chmodSync(targetPath, 493);
6368
6811
  try {
6369
- fs7.unlinkSync(targetPath);
6812
+ fs8.unlinkSync(targetPath);
6370
6813
  } catch {
6371
6814
  }
6372
6815
  }
6373
- fs7.renameSync(foundBinary, targetPath);
6816
+ fs8.renameSync(foundBinary, targetPath);
6374
6817
  }
6375
- fs7.chmodSync(targetPath, 493);
6818
+ fs8.chmodSync(targetPath, 493);
6376
6819
  if (progressCallback) {
6377
6820
  progressCallback("\u6821\u9A8C\u5185\u6838...");
6378
6821
  }
@@ -6380,11 +6823,11 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6380
6823
  const checkOutput = `${check.stdout || ""}${check.stderr || ""}`.trim();
6381
6824
  if (check.error || check.status !== 0 || !/v?\d+\.\d+\.\d+/.test(checkOutput)) {
6382
6825
  try {
6383
- fs7.unlinkSync(targetPath);
6826
+ fs8.unlinkSync(targetPath);
6384
6827
  } catch {
6385
6828
  }
6386
6829
  try {
6387
- fs7.unlinkSync(tempPath);
6830
+ fs8.unlinkSync(tempPath);
6388
6831
  } catch {
6389
6832
  }
6390
6833
  throw new Error(`\u5185\u6838\u81EA\u68C0\u5931\u8D25\uFF08\u53EF\u80FD\u4E0B\u8F7D\u635F\u574F\u6216\u67B6\u6784\u4E0D\u5339\u914D\uFF09\uFF0C\u5DF2\u5220\u9664
@@ -6392,7 +6835,7 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6392
6835
  \u8F93\u51FA: ${checkOutput || "(\u7A7A)"}`);
6393
6836
  }
6394
6837
  try {
6395
- fs7.unlinkSync(tempPath);
6838
+ fs8.unlinkSync(tempPath);
6396
6839
  } catch {
6397
6840
  }
6398
6841
  clearKernelVersionCache();
@@ -6527,7 +6970,7 @@ function cmdLogs(args) {
6527
6970
  }
6528
6971
 
6529
6972
  // src/commands/overwrite.ts
6530
- import path7 from "path";
6973
+ import path8 from "path";
6531
6974
  function printOverwriteList() {
6532
6975
  const info = listOverwriteFile();
6533
6976
  const statusText = info.enabled ? colors.green("\u5DF2\u542F\u7528") : colors.yellow("\u5DF2\u7981\u7528");
@@ -6537,8 +6980,8 @@ function printOverwriteList() {
6537
6980
  if (info.files.length === 0) {
6538
6981
  console.log("\u6682\u65E0\u8986\u5199\u6587\u4EF6");
6539
6982
  console.log("");
6540
- console.log(`\u7528\u6CD5\u793A\u4F8B: \u521B\u5EFA\u6587\u4EF6 ${path7.join(info.dir, "overwrite.yaml")}`);
6541
- console.log(` \u6216 ${path7.join(info.dir, "overwrite.dns.yaml")}`);
6983
+ console.log(`\u7528\u6CD5\u793A\u4F8B: \u521B\u5EFA\u6587\u4EF6 ${path8.join(info.dir, "overwrite.yaml")}`);
6984
+ console.log(` \u6216 ${path8.join(info.dir, "overwrite.dns.yaml")}`);
6542
6985
  console.log("");
6543
6986
  } else {
6544
6987
  console.log(`${colors.cyan("\u8986\u5199\u6587\u4EF6")} (${info.files.length} \u4E2A\uFF0C\u6309\u987A\u5E8F\u52A0\u8F7D):`);
@@ -6602,7 +7045,7 @@ async function cmdOverwrite(args) {
6602
7045
  }
6603
7046
 
6604
7047
  // src/commands/reset.ts
6605
- import fs8 from "fs";
7048
+ import fs9 from "fs";
6606
7049
  var RESET_TARGETS = [
6607
7050
  {
6608
7051
  id: "subs",
@@ -6635,6 +7078,21 @@ var RESET_TARGETS = [
6635
7078
  paths: () => [DIRS.runtime],
6636
7079
  needsStop: true
6637
7080
  },
7081
+ {
7082
+ // 隧道要在删 pid 文件之前先停进程(onBefore):文件一删就再也找不到那些 ssh 进程,
7083
+ // 它们会继续占着端口跑下去,且 CLI 无任何路径能停掉。
7084
+ // **必须排在 settings 之前**:onAfter 会 writeSettings 重建 settings.json,
7085
+ // 排在 settings 之后会让 `reset --full` 留下一个 {}(同 subs 的处理,见执行顺序注释)
7086
+ id: "tunnel",
7087
+ aliases: ["tunnel", "tunnels", "ssh"],
7088
+ label: "\u96A7\u9053",
7089
+ paths: () => [DIRS.tunnel],
7090
+ needsStop: false,
7091
+ onBefore: () => stopAllTunnels(),
7092
+ // 同步清空 settings 里的隧道列表:只删运行态会留下「列表在但状态没了」的半重置状态。
7093
+ // 覆写文件不动——那是用户维护的资产,由 overwrites target 负责
7094
+ onAfter: () => writeSettings({ tunnels: void 0 })
7095
+ },
6638
7096
  {
6639
7097
  id: "settings",
6640
7098
  aliases: ["setting", "settings", "config"],
@@ -6662,8 +7120,8 @@ var RESET_TARGETS = [
6662
7120
  label: "\u8986\u5199",
6663
7121
  paths: () => {
6664
7122
  const dir = USER_DATA_DIR;
6665
- if (!fs8.existsSync(dir)) return [];
6666
- return fs8.readdirSync(dir).filter(isOverwriteFilename).map((f) => `${dir}/${f}`);
7123
+ if (!fs9.existsSync(dir)) return [];
7124
+ return fs9.readdirSync(dir).filter(isOverwriteFilename).map((f) => `${dir}/${f}`);
6667
7125
  },
6668
7126
  needsStop: false
6669
7127
  },
@@ -6726,7 +7184,7 @@ async function cmdReset(args) {
6726
7184
  }
6727
7185
  targets = matched;
6728
7186
  } else {
6729
- targets = RESET_TARGETS.filter((t) => !["settings", "kernel", "overwrites", "daemon"].includes(t.id));
7187
+ targets = RESET_TARGETS.filter((t) => !["settings", "kernel", "overwrites", "daemon", "tunnel"].includes(t.id));
6730
7188
  }
6731
7189
  for (const t of targets) {
6732
7190
  if (t.checkEmpty?.()) {
@@ -6786,8 +7244,9 @@ async function cmdReset(args) {
6786
7244
  }
6787
7245
  }
6788
7246
  for (const t of targets) {
7247
+ t.onBefore?.();
6789
7248
  for (const p of t.paths()) {
6790
- if (fs8.existsSync(p)) {
7249
+ if (fs9.existsSync(p)) {
6791
7250
  try {
6792
7251
  rmrf(p);
6793
7252
  } catch (e) {
@@ -6805,9 +7264,9 @@ async function cmdReset(args) {
6805
7264
  }
6806
7265
 
6807
7266
  // src/test-instance.ts
6808
- import { spawn as spawn2 } from "child_process";
6809
- import fs9 from "fs";
6810
- import path8 from "path";
7267
+ import { spawn as spawn3 } from "child_process";
7268
+ import fs10 from "fs";
7269
+ import path9 from "path";
6811
7270
  function isProxyValid(proxy) {
6812
7271
  if (proxy === null || typeof proxy !== "object") return false;
6813
7272
  if (!proxy.name || !proxy.server || !proxy.port) return false;
@@ -6818,20 +7277,20 @@ function isProxyValid(proxy) {
6818
7277
  }
6819
7278
  return true;
6820
7279
  }
6821
- var TEST_DIR = path8.join(USER_DATA_DIR, "test");
7280
+ var TEST_DIR = path9.join(USER_DATA_DIR, "test");
6822
7281
  var TEST_DIRS = {
6823
- data: path8.join(TEST_DIR, "data"),
6824
- runtime: path8.join(TEST_DIR, "runtime")
7282
+ data: path9.join(TEST_DIR, "data"),
7283
+ runtime: path9.join(TEST_DIR, "runtime")
6825
7284
  };
6826
7285
  var TEST_PATHS = {
6827
- configFile: path8.join(TEST_DIRS.runtime, "config.yaml"),
6828
- pidFile: path8.join(TEST_DIRS.runtime, "pid"),
6829
- logFile: path8.join(TEST_DIR, "test.log")
7286
+ configFile: path9.join(TEST_DIRS.runtime, "config.yaml"),
7287
+ pidFile: path9.join(TEST_DIRS.runtime, "pid"),
7288
+ logFile: path9.join(TEST_DIR, "test.log")
6830
7289
  };
6831
7290
  var TEST_API = `http://${TEST_CONFIG["external-controller"]}`;
6832
7291
  function ensureTestDirs() {
6833
7292
  for (const dir of Object.values(TEST_DIRS)) {
6834
- fs9.mkdirSync(dir, { recursive: true, mode: 448 });
7293
+ fs10.mkdirSync(dir, { recursive: true, mode: 448 });
6835
7294
  }
6836
7295
  }
6837
7296
  function cleanupTestDir() {
@@ -6869,25 +7328,25 @@ function buildTestConfig(subName) {
6869
7328
  rules: ["MATCH,PROXY"]
6870
7329
  };
6871
7330
  const content = dumpYaml(config);
6872
- fs9.writeFileSync(TEST_PATHS.configFile, content, { mode: 384 });
7331
+ fs10.writeFileSync(TEST_PATHS.configFile, content, { mode: 384 });
6873
7332
  }
6874
7333
  async function startTestInstance() {
6875
7334
  const binary = PATHS.mihomoBinary;
6876
- if (!fs9.existsSync(binary)) throw new CliError('\u672A\u627E\u5230 mihomo \u5185\u6838\uFF0C\u8BF7\u5148\u8FD0\u884C "mihomo kernel" \u4E0B\u8F7D');
7335
+ if (!fs10.existsSync(binary)) throw new CliError('\u672A\u627E\u5230 mihomo \u5185\u6838\uFF0C\u8BF7\u5148\u8FD0\u884C "mihomo kernel" \u4E0B\u8F7D');
6877
7336
  stopTestInstance();
6878
- const logFd = fs9.openSync(TEST_PATHS.logFile, "a");
6879
- const child = spawn2(binary, ["-d", TEST_DIRS.data, "-f", TEST_PATHS.configFile], {
7337
+ const logFd = fs10.openSync(TEST_PATHS.logFile, "a");
7338
+ const child = spawn3(binary, ["-d", TEST_DIRS.data, "-f", TEST_PATHS.configFile], {
6880
7339
  detached: true,
6881
7340
  stdio: ["ignore", logFd, logFd]
6882
7341
  });
6883
7342
  child.on("error", () => {
6884
7343
  });
6885
- fs9.closeSync(logFd);
7344
+ fs10.closeSync(logFd);
6886
7345
  child.unref();
6887
7346
  const pid = child.pid;
6888
7347
  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
7348
  spawnedTestPid = pid;
6890
- fs9.writeFileSync(TEST_PATHS.pidFile, pid.toString(), { mode: 384 });
7349
+ fs10.writeFileSync(TEST_PATHS.pidFile, pid.toString(), { mode: 384 });
6891
7350
  const client = createHttpClient({ timeout: 2e3 });
6892
7351
  let ready = false;
6893
7352
  for (let i = 0; i < 60; i++) {
@@ -6903,7 +7362,7 @@ async function startTestInstance() {
6903
7362
  if (!isProcessRunning(pid)) {
6904
7363
  let errorDetail = "";
6905
7364
  try {
6906
- errorDetail = fs9.readFileSync(TEST_PATHS.logFile, "utf8").slice(-1e3);
7365
+ errorDetail = fs10.readFileSync(TEST_PATHS.logFile, "utf8").slice(-1e3);
6907
7366
  } catch {
6908
7367
  }
6909
7368
  throw new CliError(`\u6D4B\u8BD5\u5B9E\u4F8B\u542F\u52A8\u5931\u8D25${errorDetail ? `
@@ -6917,7 +7376,7 @@ var spawnedTestPid = null;
6917
7376
  function stopTestInstance() {
6918
7377
  let pid = null;
6919
7378
  try {
6920
- const fromFile = parseInt(fs9.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
7379
+ const fromFile = parseInt(fs10.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
6921
7380
  if (fromFile > 0) pid = fromFile;
6922
7381
  } catch {
6923
7382
  }
@@ -6932,7 +7391,7 @@ function stopTestInstance() {
6932
7391
  }
6933
7392
  spawnedTestPid = null;
6934
7393
  try {
6935
- fs9.unlinkSync(TEST_PATHS.pidFile);
7394
+ fs10.unlinkSync(TEST_PATHS.pidFile);
6936
7395
  } catch {
6937
7396
  }
6938
7397
  }
@@ -7342,6 +7801,224 @@ async function cmdClean(args) {
7342
7801
  }
7343
7802
  }
7344
7803
 
7804
+ // src/commands/tunnel.ts
7805
+ function formatState(status) {
7806
+ switch (status.state) {
7807
+ case "running":
7808
+ return colors.green(`\u8FD0\u884C\u4E2D (PID ${status.pid})`);
7809
+ case "dead-port":
7810
+ return colors.yellow(`\u5047\u6D3B (PID ${status.pid}\uFF0C\u7AEF\u53E3 ${status.config.port} \u4E0D\u901A)`);
7811
+ default:
7812
+ return colors.yellow("\u672A\u8FD0\u884C");
7813
+ }
7814
+ }
7815
+ function getTunnelNameArg(args) {
7816
+ return getNonFlagArg(args, 2);
7817
+ }
7818
+ function requireTunnelNameArg(args, usage) {
7819
+ const name = getTunnelNameArg(args);
7820
+ if (!name) {
7821
+ const tunnels = getTunnels();
7822
+ throw new CliError("\u8BF7\u6307\u5B9A\u96A7\u9053\u540D\u79F0", {
7823
+ hint: [usage, ...tunnels.length > 0 ? ["", "\u53EF\u7528\u96A7\u9053:", ...tunnels.map((t) => ` ${t.name}`)] : []]
7824
+ });
7825
+ }
7826
+ return name;
7827
+ }
7828
+ async function printTunnelList() {
7829
+ const tunnels = getTunnels();
7830
+ console.log("");
7831
+ if (tunnels.length === 0) {
7832
+ console.log("\u6CA1\u6709\u914D\u7F6E\u96A7\u9053");
7833
+ console.log("");
7834
+ console.log("\u6DFB\u52A0\u96A7\u9053: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>");
7835
+ console.log(colors.gray(" \u4F8B\u5982: mihomo tunnel add work --host m4 --port 1080"));
7836
+ console.log(colors.gray(" \u96A7\u9053\u628A\u5185\u7F51\u51FA\u53E3\u66B4\u9732\u4E3A\u672C\u5730 SOCKS5\uFF0C\u914D\u5408\u8986\u5199\u6587\u4EF6\u5206\u6D41\u5185\u7F51\u57DF\u540D"));
7837
+ console.log("");
7838
+ return;
7839
+ }
7840
+ const statuses = await getAllTunnelStatus();
7841
+ console.log(colors.cyan("\u96A7\u9053\u5217\u8868:"));
7842
+ console.log("");
7843
+ for (const status of statuses) {
7844
+ const { config } = status;
7845
+ const autoLabel = config.auto ? colors.gray(" [auto]") : "";
7846
+ console.log(` ${colors.bold(config.name)}${autoLabel}`);
7847
+ console.log(` ${colors.gray("\u51FA\u53E3: ")}${config.host} \u2192 127.0.0.1:${config.port}`);
7848
+ console.log(` ${colors.gray("\u72B6\u6001: ")}${formatState(status)}`);
7849
+ if (status.started_by) {
7850
+ console.log(` ${colors.gray("\u6765\u6E90: ")}${status.started_by === "auto" ? "\u968F start \u62C9\u8D77" : "\u624B\u52A8\u542F\u52A8"}`);
7851
+ }
7852
+ }
7853
+ console.log("");
7854
+ console.log("\u542F\u52A8: mihomo tunnel up [\u540D\u5B57] \u505C\u6B62: mihomo tunnel down [\u540D\u5B57]");
7855
+ console.log("\u72B6\u6001: mihomo tunnel status \u5220\u9664: mihomo tunnel rm <\u540D\u5B57>");
7856
+ console.log("");
7857
+ }
7858
+ async function tunnelAdd(args) {
7859
+ const name = requireTunnelNameArg(args, "\u7528\u6CD5: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3> [--no-auto]");
7860
+ const host = parseStringArg(args, "--host");
7861
+ if (!host) {
7862
+ throw new CliError("\u7F3A\u5C11 --host", {
7863
+ hint: ["\u7528\u6CD5: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>", "", "\u4E3B\u673A\u53EF\u7528 ~/.ssh/config \u91CC\u7684\u522B\u540D\uFF0C\u4F8B\u5982 --host m4"]
7864
+ });
7865
+ }
7866
+ const portRaw = parseStringArg(args, "--port");
7867
+ if (!portRaw) {
7868
+ throw new CliError("\u7F3A\u5C11 --port", { hint: ["\u7528\u6CD5: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>"] });
7869
+ }
7870
+ if (!/^\d+$/.test(portRaw.trim())) {
7871
+ throw new CliError(`\u7AEF\u53E3\u65E0\u6548: "${portRaw}"\uFF0C\u9700\u4E3A 1-65535 \u7684\u6574\u6570`);
7872
+ }
7873
+ const port = Number(portRaw.trim());
7874
+ const auto = !hasFlag(args, "--no-auto");
7875
+ const config = { name, host, port, auto };
7876
+ addTunnel(config);
7877
+ const created = ensureTunnelOverwriteFile(config);
7878
+ const overwritePath = getTunnelOverwritePath(name);
7879
+ console.log(`${colors.green("\u5DF2\u6DFB\u52A0\u96A7\u9053")} ${name} \xB7 ${host} \u2192 127.0.0.1:${port}${auto ? " \xB7 auto" : ""}`);
7880
+ console.log("");
7881
+ if (created) {
7882
+ console.log(`\u5DF2\u751F\u6210\u8986\u5199\u6A21\u677F: ${overwritePath}`);
7883
+ console.log(colors.gray(" \u6A21\u677F\u53EA\u5EFA\u597D socks5 \u8282\u70B9\u4E0E\u5206\u7EC4\uFF0C\u5206\u6D41\u89C4\u5219\u9700\u4F60\u586B\u5199\uFF08CLI \u65E0\u4ECE\u77E5\u9053\u4F60\u7684\u5185\u7F51\u57DF\u540D\uFF09"));
7884
+ console.log(colors.gray(" \u7F16\u8F91\u540E\u6267\u884C mihomo start \u751F\u6548"));
7885
+ } else {
7886
+ console.log(`\u8986\u5199\u6587\u4EF6\u5DF2\u5B58\u5728\uFF0C\u672A\u6539\u52A8: ${overwritePath}`);
7887
+ }
7888
+ console.log("");
7889
+ console.log(`\u542F\u52A8\u96A7\u9053: mihomo tunnel up ${name}`);
7890
+ if (created) {
7891
+ await restartToApply(args);
7892
+ }
7893
+ }
7894
+ async function tunnelUp(args) {
7895
+ const name = getTunnelNameArg(args);
7896
+ const targets = name ? [resolveTunnel(name)] : getTunnels();
7897
+ if (targets.length === 0) {
7898
+ throw new CliError("\u6CA1\u6709\u914D\u7F6E\u96A7\u9053", { hint: ["\u6DFB\u52A0\u96A7\u9053: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>"] });
7899
+ }
7900
+ for (const config of targets) {
7901
+ if (ensureTunnelOverwriteFile(config)) {
7902
+ console.log(colors.gray(`\u5DF2\u8865\u5EFA\u8986\u5199\u6A21\u677F: ${getTunnelOverwritePath(config.name)}`));
7903
+ }
7904
+ const result = await startTunnel(config.name, { startedBy: "manual" });
7905
+ if (result.alreadyRunning) {
7906
+ console.log(`${colors.gray("\u5DF2\u5728\u8FD0\u884C")} ${config.name} (PID ${result.pid})`);
7907
+ } else {
7908
+ console.log(`${colors.green("\u5DF2\u542F\u52A8\u96A7\u9053")} ${config.name} \xB7 ${config.host} \u2192 127.0.0.1:${config.port} (PID ${result.pid})`);
7909
+ }
7910
+ }
7911
+ }
7912
+ function tunnelDown(args) {
7913
+ const name = getTunnelNameArg(args);
7914
+ const targets = name ? [resolveTunnel(name)] : getTunnels();
7915
+ if (targets.length === 0) {
7916
+ throw new CliError("\u6CA1\u6709\u914D\u7F6E\u96A7\u9053");
7917
+ }
7918
+ let stopped = 0;
7919
+ for (const config of targets) {
7920
+ const result = stopTunnel(config.name);
7921
+ if (result.notRunning) {
7922
+ console.log(`${colors.yellow("\u4E0D\u5728\u8FD0\u884C")} ${config.name}`);
7923
+ } else {
7924
+ console.log(`${colors.green("\u5DF2\u505C\u6B62\u96A7\u9053")} ${config.name} (PID ${result.pid})`);
7925
+ stopped++;
7926
+ }
7927
+ }
7928
+ if (stopped > 0) {
7929
+ console.log("");
7930
+ console.log(colors.gray("\u6CE8\u610F: \u8986\u5199\u6587\u4EF6\u4ECD\u5728\uFF0C\u914D\u7F6E\u91CC\u7684\u96A7\u9053\u8282\u70B9\u73B0\u5728\u6307\u5411\u672A\u76D1\u542C\u7684\u7AEF\u53E3"));
7931
+ }
7932
+ }
7933
+ async function tunnelStatus(args) {
7934
+ const name = getTunnelNameArg(args);
7935
+ if (!name) {
7936
+ await printTunnelList();
7937
+ return;
7938
+ }
7939
+ const config = resolveTunnel(name);
7940
+ const status = await getTunnelStatus(config);
7941
+ console.log("");
7942
+ console.log(`${colors.gray("\u96A7\u9053: ")}${config.name}${config.auto ? colors.gray(" [auto]") : ""}`);
7943
+ console.log(`${colors.gray("\u51FA\u53E3: ")}${config.host} \u2192 127.0.0.1:${config.port}`);
7944
+ console.log(`${colors.gray("\u72B6\u6001: ")}${formatState(status)}`);
7945
+ if (status.started_by) {
7946
+ console.log(`${colors.gray("\u6765\u6E90: ")}${status.started_by === "auto" ? "\u968F start \u62C9\u8D77" : "\u624B\u52A8\u542F\u52A8"}`);
7947
+ }
7948
+ console.log("");
7949
+ if (status.state === "dead-port") {
7950
+ console.log(colors.yellow("ssh \u8FDB\u7A0B\u8FD8\u5728\uFF0C\u4F46\u7AEF\u53E3\u6CA1\u6709\u76D1\u542C\u2014\u2014\u5185\u7F51\u5206\u6D41\u5F53\u524D\u4E0D\u53EF\u7528"));
7951
+ console.log(`\u91CD\u542F\u96A7\u9053: mihomo tunnel down ${config.name} && mihomo tunnel up ${config.name}`);
7952
+ console.log(colors.gray(` \u65E5\u5FD7: ${getTunnelLogPath(config.name)}`));
7953
+ console.log("");
7954
+ } else if (status.state === "stopped") {
7955
+ console.log(`\u542F\u52A8\u96A7\u9053: mihomo tunnel up ${config.name}`);
7956
+ console.log("");
7957
+ }
7958
+ }
7959
+ async function tunnelRemove(args) {
7960
+ const name = requireTunnelNameArg(args, "\u7528\u6CD5: mihomo tunnel rm <\u540D\u5B57> [-y]");
7961
+ const config = resolveTunnel(name);
7962
+ const skipConfirm = hasFlag(args, "-y", "--yes");
7963
+ if (!skipConfirm) {
7964
+ if (!process.stdin.isTTY) {
7965
+ throw new CliError("\u5220\u9664\u96A7\u9053\u9700\u8981\u786E\u8BA4", {
7966
+ label: "\u5DF2\u53D6\u6D88",
7967
+ hint: [`\u8DF3\u8FC7\u786E\u8BA4: mihomo tunnel rm ${config.name} -y`]
7968
+ });
7969
+ }
7970
+ const confirmed = await confirmPrompt(`\u786E\u8BA4\u5220\u9664\u96A7\u9053 "${config.name}"?`);
7971
+ if (!confirmed) {
7972
+ console.log("\u5DF2\u53D6\u6D88");
7973
+ return;
7974
+ }
7975
+ }
7976
+ stopTunnel(config.name);
7977
+ removeTunnel(config.name);
7978
+ console.log(`${colors.green("\u5DF2\u5220\u9664\u96A7\u9053")} ${config.name}`);
7979
+ console.log("");
7980
+ console.log(`\u8986\u5199\u6587\u4EF6\u672A\u5220\u9664: ${getTunnelOverwritePath(config.name)}`);
7981
+ console.log(colors.gray(" \u5B83\u4ECD\u4F1A\u5411\u914D\u7F6E\u6CE8\u5165\u6307\u5411\u8BE5\u7AEF\u53E3\u7684\u8282\u70B9\uFF0C\u5982\u4E0D\u518D\u9700\u8981\u8BF7\u81EA\u884C\u5220\u9664"));
7982
+ }
7983
+ function resolveTunnel(name) {
7984
+ const config = findTunnel(name);
7985
+ if (!config) {
7986
+ const tunnels = getTunnels();
7987
+ const suggestion = suggestSimilar(
7988
+ name,
7989
+ tunnels.map((t) => t.name)
7990
+ );
7991
+ throw new CliError(`\u672A\u627E\u5230\u96A7\u9053 "${name}"`, {
7992
+ hint: [
7993
+ ...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [],
7994
+ ...tunnels.length > 0 ? ["", "\u53EF\u7528\u96A7\u9053:", ...tunnels.map((t) => ` ${t.name}`)] : ["", "\u6DFB\u52A0\u96A7\u9053: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>"]
7995
+ ]
7996
+ });
7997
+ }
7998
+ return config;
7999
+ }
8000
+ var SUBCOMMANDS5 = [
8001
+ { name: "list", handler: printTunnelList },
8002
+ { name: "add", handler: tunnelAdd },
8003
+ { name: "up", aliases: ["start"], handler: tunnelUp },
8004
+ { name: "down", aliases: ["stop"], handler: tunnelDown },
8005
+ { name: "status", handler: tunnelStatus },
8006
+ { name: "remove", aliases: ["rm", "delete"], handler: tunnelRemove }
8007
+ ];
8008
+ async function cmdTunnel(args) {
8009
+ await dispatchSubcommand(args, SUBCOMMANDS5, {
8010
+ // 无 action → 列表;未知 action → 报错(不静默回落,否则 `tunnel upp` 会看似成功)
8011
+ fallback: printTunnelList,
8012
+ onUnknown: (action) => {
8013
+ const names = SUBCOMMANDS5.flatMap((c) => [c.name, ...c.aliases ?? []]);
8014
+ const suggestion = suggestSimilar(action, names);
8015
+ throw new CliError(`\u672A\u77E5\u7684 tunnel \u5B50\u547D\u4EE4: ${action}`, {
8016
+ hint: [...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [], "", "\u53EF\u7528\u5B50\u547D\u4EE4: list, add, up, down, status, rm"]
8017
+ });
8018
+ }
8019
+ });
8020
+ }
8021
+
7345
8022
  // src/commands/ui.ts
7346
8023
  function cmdUI(args) {
7347
8024
  const uiName = args[1] || "zash";
@@ -7366,7 +8043,7 @@ function cmdUI(args) {
7366
8043
  }
7367
8044
 
7368
8045
  // src/commands/update.ts
7369
- import { execFile, spawn as spawn3 } from "child_process";
8046
+ import { execFile, spawn as spawn4 } from "child_process";
7370
8047
  import { promisify } from "util";
7371
8048
  var execFileAsync = promisify(execFile);
7372
8049
  var NPM_VIEW_TIMEOUT_MS = 15e3;
@@ -7397,7 +8074,7 @@ async function cmdUpdate() {
7397
8074
  console.log("\u6B63\u5728\u66F4\u65B0 mihomo-cli...");
7398
8075
  console.log("");
7399
8076
  await new Promise((resolve, reject) => {
7400
- const npm = spawn3("npm", ["install", "-g", PKG_NAME], { stdio: "inherit" });
8077
+ const npm = spawn4("npm", ["install", "-g", PKG_NAME], { stdio: "inherit" });
7401
8078
  npm.on("close", (code) => {
7402
8079
  if (code === 0) {
7403
8080
  resolve();
@@ -7573,6 +8250,20 @@ var COMMANDS = [
7573
8250
  group: "system",
7574
8251
  usage: ["daemon on|off \u5F00\u673A\u81EA\u542F + \u5D29\u6E83\u91CD\u542F\uFF08\u4EC5 Mixed\uFF0C\u9700\u7BA1\u7406\u5458\u5BC6\u7801\uFF09", "daemon status \u67E5\u770B\u4FDD\u6D3B\u72B6\u6001"]
7575
8252
  },
8253
+ {
8254
+ // 别名不能用 tun——已被 TUN 模式快捷命令占用(注册表重复 token 会在模块加载期直接抛错)
8255
+ name: "tunnel",
8256
+ aliases: ["ssh", "tunnels"],
8257
+ handler: cmdTunnel,
8258
+ group: "system",
8259
+ usage: [
8260
+ "tunnel \u5217\u51FA ssh \u96A7\u9053\uFF08\u522B\u540D ssh\uFF09",
8261
+ "tunnel add <\u540D\u5B57> --host <\u4E3B\u673A> --port <\u7AEF\u53E3> [--no-auto]",
8262
+ "tunnel up|down [\u540D\u5B57] \u542F\u52A8/\u505C\u6B62\u96A7\u9053\uFF08\u65E0\u53C2\u5373\u5168\u90E8\uFF09",
8263
+ "tunnel status [\u540D\u5B57] \u67E5\u770B\u96A7\u9053\u72B6\u6001\uFF08\u771F\u5B9E\u63A2\u6D4B\u7AEF\u53E3\uFF09",
8264
+ "tunnel rm <\u540D\u5B57> [-y] \u5220\u9664\u96A7\u9053\uFF08\u4E0D\u5220\u8986\u5199\u6587\u4EF6\uFF09"
8265
+ ]
8266
+ },
7576
8267
  {
7577
8268
  name: "update",
7578
8269
  aliases: ["upd", "upgrade"],
@@ -7678,7 +8369,7 @@ async function main() {
7678
8369
  if (args.length === 0) {
7679
8370
  assertSupportedPlatform("status");
7680
8371
  ensureDirs();
7681
- printStatus();
8372
+ await printStatus();
7682
8373
  printShortHelp();
7683
8374
  return;
7684
8375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihomo-cli",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "description": "A terminal-based mihomo (Clash.Meta) client for macOS",
6
6
  "bin": {