mihomo-cli 3.7.0 → 3.8.1

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/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"),
@@ -3229,6 +3233,55 @@ function atomicWriteFileSync(filePath, content, options) {
3229
3233
  function rmrf(dir) {
3230
3234
  fs.rmSync(dir, { recursive: true, force: true });
3231
3235
  }
3236
+ var LOCK_STALE_MS = 1e4;
3237
+ var LOCK_RETRY_MS = 20;
3238
+ function withFileLock(filePath, fn) {
3239
+ const lockPath = `${filePath}.lock`;
3240
+ const deadline = Date.now() + LOCK_STALE_MS;
3241
+ let fd = null;
3242
+ while (fd === null) {
3243
+ try {
3244
+ fd = fs.openSync(lockPath, "wx");
3245
+ } catch (e) {
3246
+ if (e.code !== "EEXIST") throw e;
3247
+ let stale = false;
3248
+ try {
3249
+ stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS;
3250
+ } catch {
3251
+ }
3252
+ if (stale) {
3253
+ try {
3254
+ fs.rmSync(lockPath, { force: true });
3255
+ } catch {
3256
+ }
3257
+ continue;
3258
+ }
3259
+ if (Date.now() > deadline) {
3260
+ try {
3261
+ fs.rmSync(lockPath, { force: true });
3262
+ } catch {
3263
+ }
3264
+ continue;
3265
+ }
3266
+ sleepSyncMs(LOCK_RETRY_MS);
3267
+ }
3268
+ }
3269
+ try {
3270
+ return fn();
3271
+ } finally {
3272
+ try {
3273
+ fs.closeSync(fd);
3274
+ } catch {
3275
+ }
3276
+ try {
3277
+ fs.rmSync(lockPath, { force: true });
3278
+ } catch {
3279
+ }
3280
+ }
3281
+ }
3282
+ function sleepSyncMs(ms) {
3283
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
3284
+ }
3232
3285
 
3233
3286
  // src/settings.ts
3234
3287
  import fs2 from "fs";
@@ -3265,6 +3318,10 @@ function recoverCorruptedSettings() {
3265
3318
  }
3266
3319
  function writeSettings(settings) {
3267
3320
  ensureDirs();
3321
+ return withFileLock(PATHS.settingsFile, () => writeSettingsUnlocked(settings));
3322
+ }
3323
+ function writeSettingsUnlocked(settings) {
3324
+ settingsCache = null;
3268
3325
  const existing = readSettings();
3269
3326
  const merged = { ...existing, ...settings };
3270
3327
  for (const key of Object.keys(settings)) {
@@ -3274,6 +3331,14 @@ function writeSettings(settings) {
3274
3331
  settingsCache = merged;
3275
3332
  return settingsCache;
3276
3333
  }
3334
+ function updateSettings(mutate) {
3335
+ ensureDirs();
3336
+ return withFileLock(PATHS.settingsFile, () => {
3337
+ settingsCache = null;
3338
+ const current = readSettings();
3339
+ return writeSettingsUnlocked(mutate(current));
3340
+ });
3341
+ }
3277
3342
  function invalidateSettingsCache() {
3278
3343
  settingsCache = null;
3279
3344
  }
@@ -3319,10 +3384,13 @@ function maskUrl(url) {
3319
3384
  }
3320
3385
  function readSubscriptionCache() {
3321
3386
  ensureDirs();
3387
+ const empty = () => /* @__PURE__ */ Object.create(null);
3322
3388
  if (fs2.existsSync(PATHS.subscriptionsCacheFile)) {
3323
3389
  try {
3324
3390
  const content = fs2.readFileSync(PATHS.subscriptionsCacheFile, "utf8");
3325
- return JSON.parse(content);
3391
+ const parsed = JSON.parse(content);
3392
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return empty();
3393
+ return Object.assign(empty(), parsed);
3326
3394
  } catch {
3327
3395
  try {
3328
3396
  fs2.copyFileSync(PATHS.subscriptionsCacheFile, `${PATHS.subscriptionsCacheFile}.bak`);
@@ -3330,10 +3398,10 @@ function readSubscriptionCache() {
3330
3398
  } catch {
3331
3399
  console.warn("\u8B66\u544A: \u8BA2\u9605\u7F13\u5B58\u683C\u5F0F\u635F\u574F\uFF0C\u5DF2\u5FFD\u7565");
3332
3400
  }
3333
- return {};
3401
+ return empty();
3334
3402
  }
3335
3403
  }
3336
- return {};
3404
+ return empty();
3337
3405
  }
3338
3406
  function writeSubscriptionCache(cache) {
3339
3407
  ensureDirs();
@@ -3371,31 +3439,41 @@ function validateSubscriptionName(name) {
3371
3439
  }
3372
3440
  function addSubscription(url, name = "default") {
3373
3441
  validateSubscriptionName(name);
3374
- const settings = readSettings();
3375
- const subs = [...getSubscriptions()];
3376
- if (subs.some((s) => s.name === name)) {
3442
+ let duplicate = false;
3443
+ updateSettings((settings) => {
3444
+ const subs = [...getSubscriptions()];
3445
+ if (subs.some((s) => s.name === name)) {
3446
+ duplicate = true;
3447
+ return {};
3448
+ }
3449
+ subs.push({ name, url });
3450
+ const updates = { subscriptions: subs };
3451
+ if (!settings.active_subscription && subs.length === 1) {
3452
+ updates.active_subscription = name;
3453
+ }
3454
+ return updates;
3455
+ });
3456
+ if (duplicate) {
3377
3457
  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`);
3378
3458
  }
3379
- subs.push({ name, url });
3380
- const updates = { subscriptions: subs };
3381
- if (!settings.active_subscription && subs.length === 1) {
3382
- updates.active_subscription = name;
3383
- }
3384
- writeSettings(updates);
3385
3459
  }
3386
3460
  function removeSubscription(name) {
3387
- const settings = readSettings();
3388
- const subs = [...getSubscriptions()];
3389
- const idx = subs.findIndex((s) => s.name === name);
3390
- if (idx < 0) return null;
3391
- subs.splice(idx, 1);
3392
- const updates = { subscriptions: subs };
3393
3461
  let switchedTo = null;
3394
- if (settings.active_subscription === name) {
3395
- switchedTo = subs.length > 0 ? subs[0].name : null;
3396
- updates.active_subscription = switchedTo ?? void 0;
3397
- }
3398
- writeSettings(updates);
3462
+ let found = false;
3463
+ updateSettings((settings) => {
3464
+ const subs = [...getSubscriptions()];
3465
+ const idx = subs.findIndex((s) => s.name === name);
3466
+ if (idx < 0) return {};
3467
+ found = true;
3468
+ subs.splice(idx, 1);
3469
+ const updates = { subscriptions: subs };
3470
+ if (settings.active_subscription === name) {
3471
+ switchedTo = subs.length > 0 ? subs[0].name : null;
3472
+ updates.active_subscription = switchedTo ?? void 0;
3473
+ }
3474
+ return updates;
3475
+ });
3476
+ if (!found) return null;
3399
3477
  const cache = readSubscriptionCache();
3400
3478
  if (cache[name]) {
3401
3479
  delete cache[name];
@@ -3764,10 +3842,40 @@ function parseIntArg(args, short, long, defaultValue) {
3764
3842
  }
3765
3843
  return defaultValue;
3766
3844
  }
3767
- var VALUE_FLAGS = /* @__PURE__ */ new Set(["-t", "--timeout", "-j", "--concurrency", "-r", "--rounds", "-n", "--lines", "-u", "--update-timeout"]);
3845
+ function parseStringArg(args, long, short) {
3846
+ if (!args) return null;
3847
+ for (let i = 0; i < args.length; i++) {
3848
+ if (args[i] === long || short !== void 0 && args[i] === short) {
3849
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
3850
+ return args[i + 1];
3851
+ }
3852
+ throw new CliError(`\u9009\u9879 ${args[i]} \u7F3A\u5C11\u503C`, { hint: [`\u4F8B\u5982: ${long} <\u503C>`] });
3853
+ }
3854
+ if (args[i].startsWith(`${long}=`)) {
3855
+ const value = args[i].slice(long.length + 1);
3856
+ if (!value) throw new CliError(`\u9009\u9879 ${long} \u7F3A\u5C11\u503C`, { hint: [`\u4F8B\u5982: ${long}=<\u503C>`] });
3857
+ return value;
3858
+ }
3859
+ }
3860
+ return null;
3861
+ }
3862
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3863
+ "-t",
3864
+ "--timeout",
3865
+ "-j",
3866
+ "--concurrency",
3867
+ "-r",
3868
+ "--rounds",
3869
+ "-n",
3870
+ "--lines",
3871
+ "-u",
3872
+ "--update-timeout",
3873
+ "--host",
3874
+ "--port"
3875
+ ]);
3768
3876
  function extractStartOptions(args) {
3769
3877
  if (!args) return [];
3770
- const BOOL_FLAGS = /* @__PURE__ */ new Set(["-s", "--no-update", "--no-clean"]);
3878
+ const BOOL_FLAGS = /* @__PURE__ */ new Set(["-s", "--no-update", "--no-clean", "--no-tunnel"]);
3771
3879
  const out = [];
3772
3880
  for (let i = 0; i < args.length; i++) {
3773
3881
  const a = args[i];
@@ -3825,14 +3933,24 @@ function suggestSimilar(input, candidates) {
3825
3933
  function normalizeMirrorUrl(val) {
3826
3934
  if (!val) return null;
3827
3935
  if (val === "direct" || val === "no" || val === "none") return null;
3828
- let url = val;
3829
- if (!url.startsWith("http")) {
3830
- url = `https://${url}`;
3936
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(val) ? val : `https://${val}`;
3937
+ let parsed;
3938
+ try {
3939
+ parsed = new URL(withScheme);
3940
+ } catch {
3941
+ throw new CliError(`\u955C\u50CF\u5730\u5740\u65E0\u6548: "${val}"`, {
3942
+ label: "\u53C2\u6570\u9519\u8BEF",
3943
+ hint: ["\u683C\u5F0F\u5982: --mirror https://gh-proxy.org/ \u6216 --mirror gh-proxy.org", "\u4E0D\u4F7F\u7528\u955C\u50CF: --no-mirror"]
3944
+ });
3831
3945
  }
3832
- if (!url.endsWith("/")) {
3833
- url += "/";
3946
+ if (parsed.protocol !== "https:") {
3947
+ throw new CliError(`\u955C\u50CF\u5730\u5740\u5FC5\u987B\u4F7F\u7528 https: "${val}"`, {
3948
+ label: "\u53C2\u6570\u9519\u8BEF",
3949
+ hint: ["\u955C\u50CF\u4F1A\u4E2D\u8F6C\u5185\u6838\u4E8C\u8FDB\u5236\uFF0C\u8BE5\u4EA7\u7269\u968F\u540E\u4EE5 root \u8FD0\u884C\uFF0C\u4E0D\u5141\u8BB8\u660E\u6587\u4F20\u8F93\u3002"]
3950
+ });
3834
3951
  }
3835
- return url;
3952
+ const url = parsed.toString();
3953
+ return url.endsWith("/") ? url : `${url}/`;
3836
3954
  }
3837
3955
  function parseMirrorArg(args) {
3838
3956
  if (!args || args.length < 2) {
@@ -4011,6 +4129,14 @@ function validateConfig(config) {
4011
4129
  warnings.push(`\u79FB\u9664\u4E86 ${groupDedup.duplicates.length} \u4E2A\u91CD\u540D\u5206\u7EC4: ${groupDedup.duplicates.map((n) => `"${n}"`).join(", ")}`);
4012
4130
  }
4013
4131
  const validNames = /* @__PURE__ */ new Set([...BUILTIN_PROXY_NAMES, ...proxyDedup.names, ...groupDedup.names]);
4132
+ for (const name of groupDedup.names) {
4133
+ if (proxyDedup.names.has(name)) {
4134
+ warnings.push(`\u540D\u79F0\u51B2\u7A81: "${name}" \u540C\u65F6\u662F\u8282\u70B9\u548C\u5206\u7EC4\u540D\uFF0Cmihomo \u4F1A\u62D2\u7EDD\u52A0\u8F7D\uFF08\u8BF7\u91CD\u547D\u540D\u5176\u4E00\uFF09`);
4135
+ }
4136
+ }
4137
+ const providerNames = new Set(
4138
+ config["proxy-providers"] && typeof config["proxy-providers"] === "object" && !Array.isArray(config["proxy-providers"]) ? Object.keys(config["proxy-providers"]) : []
4139
+ );
4014
4140
  const activeGroups = groupDedup.result;
4015
4141
  const removedGroups = /* @__PURE__ */ new Set();
4016
4142
  let changed = true;
@@ -4018,12 +4144,32 @@ function validateConfig(config) {
4018
4144
  changed = false;
4019
4145
  for (const group of activeGroups) {
4020
4146
  if (removedGroups.has(group.name)) continue;
4147
+ if (group.proxies !== void 0 && !Array.isArray(group.proxies)) {
4148
+ if (typeof group.proxies === "string") {
4149
+ warnings.push(`proxy-group "${group.name}": proxies \u5E94\u4E3A\u5217\u8868\uFF0C\u5DF2\u6309\u5355\u5143\u7D20\u5904\u7406\uFF08"${group.proxies}"\uFF09`);
4150
+ group.proxies = [group.proxies];
4151
+ } else {
4152
+ warnings.push(`proxy-group "${group.name}": proxies \u4E0D\u662F\u5217\u8868\uFF0C\u5DF2\u5FFD\u7565\u8BE5\u5B57\u6BB5`);
4153
+ group.proxies = [];
4154
+ }
4155
+ }
4021
4156
  if (!Array.isArray(group.proxies)) continue;
4157
+ if (Array.isArray(group.use)) {
4158
+ const ghosts = group.use.filter((u) => typeof u === "string" && !providerNames.has(u));
4159
+ if (ghosts.length > 0) {
4160
+ group.use = group.use.filter((u) => providerNames.has(u));
4161
+ warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684 provider \u5F15\u7528 ${ghosts.map((n) => `"${n}"`).join(", ")}`);
4162
+ }
4163
+ }
4022
4164
  const invalid = group.proxies.filter((name) => !validNames.has(name));
4023
- if (invalid.length === 0) continue;
4024
- group.proxies = group.proxies.filter((name) => validNames.has(name));
4025
- warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684\u5F15\u7528 ${invalid.map((n) => `"${n}"`).join(", ")}`);
4026
- const hasOtherSource = group.use || group["include-all"] || group["include-all-proxies"];
4165
+ if (invalid.length > 0) {
4166
+ group.proxies = group.proxies.filter((name) => validNames.has(name));
4167
+ warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684\u5F15\u7528 ${invalid.map((n) => `"${n}"`).join(", ")}`);
4168
+ }
4169
+ const hasUse = Array.isArray(group.use) ? group.use.length > 0 : Boolean(group.use);
4170
+ const includesAll = Boolean(group["include-all"] || group["include-all-proxies"]);
4171
+ const includeAllUsable = includesAll && proxyDedup.result.length > 0;
4172
+ const hasOtherSource = hasUse || includeAllUsable;
4027
4173
  if (group.proxies.length === 0 && !hasOtherSource) {
4028
4174
  removedGroups.add(group.name);
4029
4175
  validNames.delete(group.name);
@@ -4232,12 +4378,13 @@ ${colors.cyan(colors.bold(`mihomo-cli v${VERSION}`))}`, "", "\u547D\u4EE4\u522B\
4232
4378
  " mihomo start -s # \u8DF3\u8FC7\u81EA\u52A8\u66F4\u65B0\u8BA2\u9605",
4233
4379
  " mihomo start -u 30000 # \u81EA\u52A8\u66F4\u65B0\u8D85\u65F6 30 \u79D2 (\u9ED8\u8BA4 10s)",
4234
4380
  " mihomo daemon on # \u5F00\u542F\u4FDD\u6D3B\uFF08\u5F00\u673A\u81EA\u542F + \u5D29\u6E83\u91CD\u542F\uFF09",
4381
+ " mihomo tunnel add work --host m4 --port 1080 # \u52A0 ssh \u96A7\u9053\u51FA\u53E3",
4235
4382
  " mihomo sub add <url> # \u6DFB\u52A0\u8BA2\u9605 (sub \u662F subscription \u522B\u540D)",
4236
4383
  " mihomo ui # \u6253\u5F00 Web UI",
4237
4384
  "",
4238
4385
  `${colors.cyan("\u5FEB\u6377\u547D\u4EE4:")}`,
4239
4386
  " tun = start tun use = sub use on/off = ow on/off open = dir open",
4240
- " up = start down = stop upd/upgrade = update",
4387
+ " up = start down = stop upd/upgrade = update ssh = tunnel",
4241
4388
  "",
4242
4389
  `${colors.cyan("\u6A21\u5F0F\u8BF4\u660E:")}`,
4243
4390
  " mixed HTTP + SOCKS5 \u6DF7\u5408\u7AEF\u53E3 (\u9ED8\u8BA4)",
@@ -4516,6 +4663,7 @@ exit 2
4516
4663
  `;
4517
4664
  const scriptPath = path4.join(DIRS.runtime, "launch-tun.sh");
4518
4665
  fs5.writeFileSync(scriptPath, scriptContent, { mode: 448 });
4666
+ fs5.chmodSync(scriptPath, 448);
4519
4667
  return scriptPath;
4520
4668
  }
4521
4669
  function getProcessInfo(pid) {
@@ -4885,6 +5033,7 @@ function runSudoScript(scriptBody, opts) {
4885
5033
  ensureDirs();
4886
5034
  const scriptPath = path5.join(DIRS.runtime, opts.file);
4887
5035
  fs6.writeFileSync(scriptPath, scriptBody, { mode: 448 });
5036
+ fs6.chmodSync(scriptPath, 448);
4888
5037
  try {
4889
5038
  const result = spawnSync3("sudo", [scriptPath], { stdio: "inherit", timeout: SUDO_TIMEOUT_MS });
4890
5039
  if (result.error) throw result.error;
@@ -4933,10 +5082,15 @@ function enableDaemon() {
4933
5082
  const plistDest = shellQuote(PATHS.launchDaemonPlist);
4934
5083
  const stage = shellQuote(stagePath);
4935
5084
  const pattern = shellQuote(MAIN_INSTANCE_PATTERN);
5085
+ const pidFile = shellQuote(PATHS.pidFile);
4936
5086
  const script = [
4937
5087
  "#!/bin/bash",
4938
5088
  `launchctl bootout ${target} 2>/dev/null || true`,
4939
5089
  `pkill -9 -f ${pattern} 2>/dev/null || true`,
5090
+ // 一并清掉 pid 文件:`start tun` 留下的是 **root 属主**的 pid 文件,普通用户删不掉。
5091
+ // 不删会让后续 `daemon off` → `start` 撞上 hasRootResidue() 的拒绝启动,
5092
+ // 而这个死胡同完全由 CLI 自身的 on/off 循环造成
5093
+ `rm -f ${pidFile}`,
4940
5094
  "sleep 0.2",
4941
5095
  `install -m 644 -o root -g wheel ${stage} ${plistDest} || exit 2`,
4942
5096
  `launchctl bootstrap system ${plistDest} || { launchctl bootout ${target} 2>/dev/null; rm -f ${plistDest}; exit 3; }`,
@@ -4961,7 +5115,13 @@ function enableDaemon() {
4961
5115
  }
4962
5116
  function disableDaemon() {
4963
5117
  assertDaemonLabelSafe();
4964
- if (!isDaemonEnabled()) return;
5118
+ const plistExists = isDaemonEnabled();
5119
+ const rootKernelRunning = getMihomoPids().some(isProcessRoot);
5120
+ if (!plistExists && !rootKernelRunning) return;
5121
+ if (!plistExists) {
5122
+ console.log("\u672A\u627E\u5230 plist\uFF0C\u4F46\u68C0\u6D4B\u5230 root \u5185\u6838\u5728\u8FD0\u884C\uFF08\u53EF\u80FD plist \u88AB\u624B\u52A8\u5220\u9664\u800C\u4EFB\u52A1\u4ECD\u88C5\u8F7D\uFF09");
5123
+ console.log("\u5C06\u6267\u884C launchctl bootout \u5378\u8F7D\u6B8B\u7559\u4EFB\u52A1");
5124
+ }
4965
5125
  const target = shellQuote(SERVICE_TARGET);
4966
5126
  const plistDest = shellQuote(PATHS.launchDaemonPlist);
4967
5127
  const logFile = shellQuote(PATHS.logFile);
@@ -4984,12 +5144,17 @@ function disableDaemon() {
4984
5144
  }
4985
5145
  }
4986
5146
  async function tryHotReload() {
5147
+ if (!isDaemonRunning(getDaemonStatus())) return false;
4987
5148
  const controller = new AbortController();
4988
5149
  const timer = setTimeout(() => controller.abort(), HOT_RELOAD_TIMEOUT_MS);
4989
5150
  const secret = readSettings().controller_secret;
4990
5151
  const headers = { "Content-Type": "application/json" };
4991
5152
  if (secret) headers.Authorization = `Bearer ${secret}`;
4992
5153
  try {
5154
+ const probe = await fetch(`${CONTROLLER_BASE_URL}/version`, { headers, signal: controller.signal });
5155
+ if (!probe.ok) return false;
5156
+ const info = await probe.json();
5157
+ if (typeof info?.version !== "string") return false;
4993
5158
  const res = await fetch(`${CONTROLLER_BASE_URL}/configs?force=true`, {
4994
5159
  method: "PUT",
4995
5160
  headers,
@@ -5146,15 +5311,18 @@ function saveSubscriptionConfig(subName, parsed) {
5146
5311
  function parseUserInfo(header) {
5147
5312
  if (!header) return null;
5148
5313
  const info = {};
5149
- const parts = header.split(";").map((p) => p.trim());
5150
- for (const part of parts) {
5151
- const [key, val] = part.split("=").map((s) => s.trim());
5152
- if (key && val !== void 0) {
5153
- const numVal = parseFloat(val);
5154
- info[key] = Number.isNaN(numVal) ? 0 : numVal;
5155
- }
5156
- }
5157
- return info;
5314
+ let hasAny = false;
5315
+ for (const part of header.split(";")) {
5316
+ const [rawKey, rawVal] = part.split("=");
5317
+ const key = rawKey?.trim();
5318
+ const val = rawVal?.trim();
5319
+ if (!key || val === void 0 || val === "") continue;
5320
+ const numVal = Number(val);
5321
+ if (!Number.isFinite(numVal) || numVal < 0) continue;
5322
+ info[key] = numVal;
5323
+ hasAny = true;
5324
+ }
5325
+ return hasAny ? info : null;
5158
5326
  }
5159
5327
  function parsePositiveInterval(header) {
5160
5328
  if (!header) return null;
@@ -5180,10 +5348,11 @@ function extractSubscriptionMeta(headers) {
5180
5348
  function saveSubscriptionMeta(subName, meta) {
5181
5349
  const cacheData = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
5182
5350
  if (meta.userInfo) {
5183
- cacheData.upload = meta.userInfo.upload;
5184
- cacheData.download = meta.userInfo.download;
5185
- cacheData.total = meta.userInfo.total;
5186
- cacheData.expire = meta.userInfo.expire;
5351
+ const { upload, download, total, expire } = meta.userInfo;
5352
+ if (upload !== void 0) cacheData.upload = upload;
5353
+ if (download !== void 0) cacheData.download = download;
5354
+ if (total !== void 0) cacheData.total = total;
5355
+ if (expire !== void 0) cacheData.expire = expire;
5187
5356
  }
5188
5357
  if (meta.updateInterval) cacheData.update_interval = meta.updateInterval;
5189
5358
  if (meta.webPageUrl) cacheData.web_page_url = meta.webPageUrl;
@@ -5387,6 +5556,7 @@ function needsAutoUpdate(sub) {
5387
5556
  if (!sub.updated_at) return true;
5388
5557
  const lastUpdate = new Date(sub.updated_at).getTime();
5389
5558
  if (Number.isNaN(lastUpdate)) return true;
5559
+ if (lastUpdate > Date.now()) return true;
5390
5560
  const intervalHours = resolveUpdateInterval(sub.url, sub.update_interval);
5391
5561
  const intervalMs = intervalHours * 60 * 60 * 1e3;
5392
5562
  return Date.now() - lastUpdate > intervalMs;
@@ -5734,8 +5904,397 @@ function formatTestSummary(summary) {
5734
5904
  return `\u7ED3\u679C: ${colors.green(`${summary.alive} \u5B58\u6D3B`)} / ${colors.red(`${summary.dead} \u5931\u8D25`)} / ${summary.total} \u603B\u8BA1`;
5735
5905
  }
5736
5906
 
5907
+ // src/tunnel.ts
5908
+ import { spawn as spawn2 } from "child_process";
5909
+ import fs7 from "fs";
5910
+ import net from "net";
5911
+ import path6 from "path";
5912
+ var PORT_PROBE_TIMEOUT_MS = 300;
5913
+ var STOP_WAIT_ATTEMPTS = 20;
5914
+ var STOP_WAIT_INTERVAL = 100;
5915
+ var START_WAIT_ATTEMPTS = 40;
5916
+ var START_WAIT_INTERVAL = 500;
5917
+ function getReservedPorts() {
5918
+ const reserved = /* @__PURE__ */ new Map();
5919
+ const mixedPort = Number(BASE_CONFIG["mixed-port"]);
5920
+ if (Number.isInteger(mixedPort)) reserved.set(mixedPort, "mihomo \u6DF7\u5408\u4EE3\u7406\u7AEF\u53E3");
5921
+ reserved.set(CONTROLLER_PORT, "mihomo \u63A7\u5236\u5668\u7AEF\u53E3");
5922
+ const testPort = Number(TEST_CONFIG["mixed-port"]);
5923
+ if (Number.isInteger(testPort)) reserved.set(testPort, "\u6D4B\u901F\u5B9E\u4F8B\u4EE3\u7406\u7AEF\u53E3");
5924
+ const testController = Number(TEST_CONTROLLER_ADDR.split(":")[1]);
5925
+ if (Number.isInteger(testController)) reserved.set(testController, "\u6D4B\u901F\u5B9E\u4F8B\u63A7\u5236\u5668\u7AEF\u53E3");
5926
+ return reserved;
5927
+ }
5928
+ function validateTunnelName(name) {
5929
+ if (!name || !SAFE_NAME_RE.test(name)) {
5930
+ 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`);
5931
+ }
5932
+ }
5933
+ var SAFE_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;
5934
+ function validateTunnelHost(host) {
5935
+ if (!host) {
5936
+ throw new CliError("\u7F3A\u5C11 --host", { hint: ["\u4F8B\u5982: mihomo tunnel add work --host m4 --port 1080"] });
5937
+ }
5938
+ if (host.startsWith("-")) {
5939
+ throw new CliError(`\u4E3B\u673A\u540D\u65E0\u6548: "${host}"`, {
5940
+ label: "\u53C2\u6570\u9519\u8BEF",
5941
+ 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']
5942
+ });
5943
+ }
5944
+ if (!SAFE_HOST_RE.test(host)) {
5945
+ 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 @`, {
5946
+ label: "\u53C2\u6570\u9519\u8BEF",
5947
+ hint: ["\u53EF\u7528 ssh \u522B\u540D\uFF08~/.ssh/config \u91CC\u7684 Host\uFF09\u6216 user@hostname\u3002"]
5948
+ });
5949
+ }
5950
+ }
5951
+ function validateTunnelPort(port, exclude) {
5952
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5953
+ throw new CliError(`\u7AEF\u53E3\u65E0\u6548: ${port}\uFF0C\u9700\u4E3A 1-65535 \u7684\u6574\u6570`);
5954
+ }
5955
+ const reservedLabel = getReservedPorts().get(port);
5956
+ if (reservedLabel) {
5957
+ throw new CliError(`\u7AEF\u53E3 ${port} \u5DF2\u88AB ${reservedLabel} \u5360\u7528`, {
5958
+ label: "\u7AEF\u53E3\u51B2\u7A81",
5959
+ hint: ["\u8BF7\u6362\u4E00\u4E2A\u7AEF\u53E3\uFF0C\u4F8B\u5982 1080\u3002"]
5960
+ });
5961
+ }
5962
+ const conflict = getTunnels().find((t) => t.port === port && t.name !== exclude);
5963
+ if (conflict) {
5964
+ throw new CliError(`\u7AEF\u53E3 ${port} \u5DF2\u88AB\u96A7\u9053 "${conflict.name}" \u4F7F\u7528`, { label: "\u7AEF\u53E3\u51B2\u7A81" });
5965
+ }
5966
+ }
5967
+ function getTunnels() {
5968
+ const settings = readSettings();
5969
+ const tunnels = settings.tunnels;
5970
+ if (!Array.isArray(tunnels)) {
5971
+ if (tunnels !== void 0) {
5972
+ 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");
5973
+ }
5974
+ return [];
5975
+ }
5976
+ return tunnels.filter((t) => t != null && typeof t === "object" && typeof t.name === "string" && typeof t.host === "string" && Number.isInteger(t.port));
5977
+ }
5978
+ function findTunnel(name) {
5979
+ return getTunnels().find((t) => t.name === name);
5980
+ }
5981
+ function addTunnel(config) {
5982
+ validateTunnelName(config.name);
5983
+ validateTunnelHost(config.host);
5984
+ validateTunnelPort(config.port);
5985
+ let duplicate = false;
5986
+ updateSettings(() => {
5987
+ const tunnels = [...getTunnels()];
5988
+ if (tunnels.some((t) => t.name === config.name)) {
5989
+ duplicate = true;
5990
+ return {};
5991
+ }
5992
+ tunnels.push(config);
5993
+ return { tunnels };
5994
+ });
5995
+ if (duplicate) {
5996
+ 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`);
5997
+ }
5998
+ }
5999
+ function removeTunnel(name) {
6000
+ let found = false;
6001
+ updateSettings(() => {
6002
+ const tunnels = [...getTunnels()];
6003
+ const idx = tunnels.findIndex((t) => t.name === name);
6004
+ if (idx < 0) return {};
6005
+ found = true;
6006
+ tunnels.splice(idx, 1);
6007
+ return { tunnels };
6008
+ });
6009
+ if (!found) return false;
6010
+ clearTunnelRuntime(name);
6011
+ return true;
6012
+ }
6013
+ function getTunnelRuntimePath(name) {
6014
+ validateTunnelName(name);
6015
+ return path6.join(DIRS.tunnel, `${name}.json`);
6016
+ }
6017
+ function readTunnelRuntime(name) {
6018
+ try {
6019
+ const raw = fs7.readFileSync(getTunnelRuntimePath(name), "utf8");
6020
+ const parsed = JSON.parse(raw);
6021
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
6022
+ const r = parsed;
6023
+ if (!Number.isInteger(r.pid) || r.pid <= 0) return null;
6024
+ return {
6025
+ pid: r.pid,
6026
+ started_by: r.started_by === "manual" ? "manual" : "auto",
6027
+ started_at: typeof r.started_at === "string" ? r.started_at : "",
6028
+ port: Number.isInteger(r.port) ? r.port : 0
6029
+ };
6030
+ } catch {
6031
+ return null;
6032
+ }
6033
+ }
6034
+ function writeTunnelRuntime(name, runtime) {
6035
+ ensureDirs();
6036
+ atomicWriteFileSync(getTunnelRuntimePath(name), JSON.stringify(runtime, null, 2), { mode: 384 });
6037
+ }
6038
+ function clearTunnelRuntime(name) {
6039
+ try {
6040
+ fs7.rmSync(getTunnelRuntimePath(name), { force: true });
6041
+ } catch {
6042
+ }
6043
+ }
6044
+ function buildSshArgs(tunnel) {
6045
+ return [
6046
+ "-D",
6047
+ `127.0.0.1:${tunnel.port}`,
6048
+ "-N",
6049
+ "-o",
6050
+ "ExitOnForwardFailure=yes",
6051
+ "-o",
6052
+ "BatchMode=yes",
6053
+ "-o",
6054
+ "ServerAliveInterval=30",
6055
+ "-o",
6056
+ "ServerAliveCountMax=3",
6057
+ "-o",
6058
+ "ConnectTimeout=15",
6059
+ tunnel.host
6060
+ ];
6061
+ }
6062
+ function commandNeedle(port) {
6063
+ return `-D 127.0.0.1:${port}`;
6064
+ }
6065
+ function getTunnelLogPath(name) {
6066
+ validateTunnelName(name);
6067
+ return path6.join(DIRS.logs, `tunnel-${name}.log`);
6068
+ }
6069
+ function isTunnelProcessAlive(pid, port) {
6070
+ return isProcessRunning(pid) && isProcessCommandMatching(pid, commandNeedle(port));
6071
+ }
6072
+ function isPortListening(port, timeoutMs = PORT_PROBE_TIMEOUT_MS) {
6073
+ return new Promise((resolve) => {
6074
+ const socket = new net.Socket();
6075
+ let settled = false;
6076
+ const finish = (result) => {
6077
+ if (settled) return;
6078
+ settled = true;
6079
+ socket.destroy();
6080
+ resolve(result);
6081
+ };
6082
+ socket.setTimeout(timeoutMs);
6083
+ socket.once("connect", () => finish(true));
6084
+ socket.once("timeout", () => finish(false));
6085
+ socket.once("error", () => finish(false));
6086
+ socket.connect(port, "127.0.0.1");
6087
+ });
6088
+ }
6089
+ async function getTunnelStatus(config) {
6090
+ const runtime = readTunnelRuntime(config.name);
6091
+ const alive = runtime !== null && isTunnelProcessAlive(runtime.pid, runtime.port || config.port);
6092
+ if (!alive) {
6093
+ return { config, state: "stopped", pid: null, started_by: null, started_at: null };
6094
+ }
6095
+ const listening = await isPortListening(config.port);
6096
+ return {
6097
+ config,
6098
+ state: listening ? "running" : "dead-port",
6099
+ pid: runtime.pid,
6100
+ started_by: runtime.started_by,
6101
+ started_at: runtime.started_at || null
6102
+ };
6103
+ }
6104
+ async function getAllTunnelStatus() {
6105
+ return Promise.all(getTunnels().map(getTunnelStatus));
6106
+ }
6107
+ async function startTunnel(name, options) {
6108
+ const config = findTunnel(name);
6109
+ if (!config) {
6110
+ throw new CliError(`\u672A\u627E\u5230\u96A7\u9053 "${name}"`, { hint: ["\u67E5\u770B\u5168\u90E8\u96A7\u9053: mihomo tunnel"] });
6111
+ }
6112
+ const existing = readTunnelRuntime(name);
6113
+ if (existing && isTunnelProcessAlive(existing.pid, existing.port || config.port)) {
6114
+ if (options.startedBy === "manual" && existing.started_by === "auto") {
6115
+ writeTunnelRuntime(name, { ...existing, started_by: "manual" });
6116
+ }
6117
+ return { alreadyRunning: true, pid: existing.pid };
6118
+ }
6119
+ if (existing) clearTunnelRuntime(name);
6120
+ if (await isPortListening(config.port)) {
6121
+ throw new CliError(`\u7AEF\u53E3 ${config.port} \u5DF2\u88AB\u5360\u7528`, {
6122
+ label: "\u65E0\u6CD5\u542F\u52A8\u96A7\u9053",
6123
+ 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`]
6124
+ });
6125
+ }
6126
+ ensureDirs();
6127
+ const logPath = getTunnelLogPath(name);
6128
+ const logFd = fs7.openSync(logPath, "w");
6129
+ const child = spawn2("ssh", buildSshArgs(config), {
6130
+ detached: true,
6131
+ stdio: ["ignore", logFd, logFd]
6132
+ });
6133
+ child.on("error", () => {
6134
+ });
6135
+ fs7.closeSync(logFd);
6136
+ child.unref();
6137
+ const pid = child.pid;
6138
+ if (!pid) {
6139
+ 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"] });
6140
+ }
6141
+ writeTunnelRuntime(name, {
6142
+ pid,
6143
+ started_by: options.startedBy,
6144
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
6145
+ port: config.port
6146
+ });
6147
+ const unregisterCleanup = registerCleanup(() => {
6148
+ try {
6149
+ process.kill(pid, "SIGKILL");
6150
+ } catch {
6151
+ }
6152
+ clearTunnelRuntime(name);
6153
+ });
6154
+ try {
6155
+ for (let i = 0; i < START_WAIT_ATTEMPTS; i++) {
6156
+ if (!isProcessRunning(pid)) break;
6157
+ if (await isPortListening(config.port)) {
6158
+ unregisterCleanup();
6159
+ return { alreadyRunning: false, pid };
6160
+ }
6161
+ await new Promise((resolve) => setTimeout(resolve, START_WAIT_INTERVAL));
6162
+ }
6163
+ } finally {
6164
+ unregisterCleanup();
6165
+ }
6166
+ clearTunnelRuntime(name);
6167
+ if (isProcessRunning(pid)) {
6168
+ try {
6169
+ process.kill(pid, "SIGKILL");
6170
+ } catch {
6171
+ }
6172
+ }
6173
+ throw new CliError("ssh \u96A7\u9053\u672A\u80FD\u5EFA\u7ACB", {
6174
+ label: "\u542F\u52A8\u96A7\u9053\u5931\u8D25",
6175
+ hint: [...readLogTail(logPath), `\u5B8C\u6574\u65E5\u5FD7: ${logPath}`]
6176
+ });
6177
+ }
6178
+ function readLogTail(logPath, maxLines = 5) {
6179
+ try {
6180
+ const content = fs7.readFileSync(logPath, "utf8").trim();
6181
+ if (!content) return [];
6182
+ return content.split("\n").slice(-maxLines).map((line) => ` ${line.trim()}`);
6183
+ } catch {
6184
+ return [];
6185
+ }
6186
+ }
6187
+ function stopTunnel(name) {
6188
+ const config = findTunnel(name);
6189
+ const runtime = readTunnelRuntime(name);
6190
+ if (!runtime) {
6191
+ clearTunnelRuntime(name);
6192
+ return { notRunning: true, pid: null };
6193
+ }
6194
+ const port = runtime.port || config?.port || 0;
6195
+ const { pid } = runtime;
6196
+ if (!isTunnelProcessAlive(pid, port)) {
6197
+ clearTunnelRuntime(name);
6198
+ return { notRunning: true, pid: null };
6199
+ }
6200
+ try {
6201
+ process.kill(pid, "SIGTERM");
6202
+ } catch {
6203
+ }
6204
+ for (let i = 0; i < STOP_WAIT_ATTEMPTS; i++) {
6205
+ if (!isProcessRunning(pid)) break;
6206
+ sleepSync(STOP_WAIT_INTERVAL);
6207
+ }
6208
+ if (isProcessRunning(pid) && isProcessCommandMatching(pid, commandNeedle(port))) {
6209
+ try {
6210
+ process.kill(pid, "SIGKILL");
6211
+ } catch {
6212
+ }
6213
+ for (let i = 0; i < STOP_WAIT_ATTEMPTS; i++) {
6214
+ if (!isProcessRunning(pid)) break;
6215
+ sleepSync(STOP_WAIT_INTERVAL);
6216
+ }
6217
+ }
6218
+ clearTunnelRuntime(name);
6219
+ return { notRunning: false, pid };
6220
+ }
6221
+ async function startAutoTunnels() {
6222
+ const outcomes = [];
6223
+ for (const tunnel of getTunnels()) {
6224
+ if (!tunnel.auto) continue;
6225
+ try {
6226
+ const result = await startTunnel(tunnel.name, { startedBy: "auto" });
6227
+ outcomes.push({ name: tunnel.name, ok: true, alreadyRunning: result.alreadyRunning });
6228
+ } catch (e) {
6229
+ outcomes.push({
6230
+ name: tunnel.name,
6231
+ ok: false,
6232
+ error: e instanceof CliError ? e : new CliError(e.message)
6233
+ });
6234
+ }
6235
+ }
6236
+ return outcomes;
6237
+ }
6238
+ function stopAutoTunnels() {
6239
+ const stopped = [];
6240
+ for (const tunnel of getTunnels()) {
6241
+ const runtime = readTunnelRuntime(tunnel.name);
6242
+ if (runtime?.started_by !== "auto") continue;
6243
+ const result = stopTunnel(tunnel.name);
6244
+ if (!result.notRunning) stopped.push(tunnel.name);
6245
+ }
6246
+ return stopped;
6247
+ }
6248
+ function stopAllTunnels() {
6249
+ const stopped = [];
6250
+ for (const tunnel of getTunnels()) {
6251
+ const result = stopTunnel(tunnel.name);
6252
+ if (!result.notRunning) stopped.push(tunnel.name);
6253
+ }
6254
+ return stopped;
6255
+ }
6256
+ function getTunnelOverwritePath(name) {
6257
+ validateTunnelName(name);
6258
+ return path6.join(USER_DATA_DIR, `overwrite.tunnel-${name}.yaml`);
6259
+ }
6260
+ function renderTunnelOverwrite(tunnel) {
6261
+ const proxyName = `Tunnel-${tunnel.name}-Host`;
6262
+ const groupName = `Tunnel-${tunnel.name}`;
6263
+ return `# mihomo-cli \u96A7\u9053\u8986\u5199\uFF08tunnel: ${tunnel.name}\uFF09
6264
+ # \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
6265
+ #
6266
+ # ~ \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
6267
+ # \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
6268
+
6269
+ ~proxies:
6270
+ - name: ${proxyName}
6271
+ type: socks5
6272
+ server: 127.0.0.1
6273
+ port: ${tunnel.port}
6274
+
6275
+ ~proxy-groups:
6276
+ - name: ${groupName}
6277
+ type: select
6278
+ proxies:
6279
+ - ${proxyName}
6280
+ - DIRECT
6281
+
6282
+ # \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
6283
+ # +rules:
6284
+ # - DOMAIN-SUFFIX,example.internal,${groupName}
6285
+ # - IP-CIDR,10.0.0.0/8,${groupName}
6286
+ `;
6287
+ }
6288
+ function ensureTunnelOverwriteFile(tunnel) {
6289
+ const filePath = getTunnelOverwritePath(tunnel.name);
6290
+ if (fs7.existsSync(filePath)) return false;
6291
+ ensureDirs();
6292
+ atomicWriteFileSync(filePath, renderTunnelOverwrite(tunnel), { mode: 384 });
6293
+ return true;
6294
+ }
6295
+
5737
6296
  // src/commands/status.ts
5738
- function printStatus() {
6297
+ async function printStatus() {
5739
6298
  const status = getStatus();
5740
6299
  const state = getRunningState();
5741
6300
  const info = getConfigInfo();
@@ -5802,6 +6361,17 @@ function printStatus() {
5802
6361
  if (isDaemonEnabled()) {
5803
6362
  console.log(`${colors.gray("\u4FDD\u6D3B: ")}${colors.green("\u5DF2\u542F\u7528")} ${colors.gray("(\u5F00\u673A\u81EA\u542F + \u5D29\u6E83\u91CD\u542F)")}`);
5804
6363
  }
6364
+ const tunnels = getTunnels();
6365
+ if (tunnels.length > 0) {
6366
+ const statuses = await getAllTunnelStatus();
6367
+ const parts = statuses.map((s) => {
6368
+ const label = `${s.config.name}:${s.config.port}`;
6369
+ if (s.state === "running") return colors.green(label);
6370
+ if (s.state === "dead-port") return colors.yellow(`${label} \u5047\u6D3B`);
6371
+ return colors.gray(label);
6372
+ });
6373
+ console.log(`${colors.gray("\u96A7\u9053: ")}${parts.join(", ")}`);
6374
+ }
5805
6375
  console.log("");
5806
6376
  }
5807
6377
 
@@ -5811,23 +6381,51 @@ function handleStopResult(result) {
5811
6381
  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
6382
  }
5813
6383
  }
5814
- async function cmdStop() {
6384
+ function stopAutoTunnelsWithLog() {
6385
+ const stopped = stopAutoTunnels();
6386
+ if (stopped.length > 0) {
6387
+ console.log(`${colors.green("\u5DF2\u505C\u6B62\u96A7\u9053")}: ${stopped.join(", ")}`);
6388
+ }
6389
+ }
6390
+ async function cmdStop(args) {
5815
6391
  if (isDaemonEnabled()) {
5816
6392
  console.log(colors.yellow("\u4FDD\u6D3B\u5DF2\u542F\u7528\uFF0C\u4EE3\u7406\u7531 launchd \u6258\u7BA1"));
5817
6393
  console.log("\u76F4\u63A5\u505C\u6B62\u4F1A\u88AB\u81EA\u52A8\u91CD\u65B0\u62C9\u8D77\uFF0C\u8BF7\u7528: mihomo daemon off");
5818
6394
  return;
5819
6395
  }
6396
+ const skipTunnel = hasFlag(args, "--no-tunnel");
5820
6397
  const pids = getMihomoPids();
5821
6398
  if (pids.length === 0) {
5822
6399
  console.log(colors.yellow("\u4E0D\u5728\u8FD0\u884C"));
6400
+ if (!skipTunnel) stopAutoTunnelsWithLog();
5823
6401
  return;
5824
6402
  }
5825
6403
  console.log(`\u505C\u6B62 ${pids.length} \u4E2A\u8FDB\u7A0B...`);
5826
6404
  handleStopResult(stop());
5827
6405
  console.log(colors.green("\u5DF2\u505C\u6B62\u8FDB\u7A0B"));
6406
+ if (!skipTunnel) stopAutoTunnelsWithLog();
5828
6407
  }
5829
6408
 
5830
6409
  // src/commands/start.ts
6410
+ async function startAutoTunnelsWithWarning() {
6411
+ const outcomes = await startAutoTunnels();
6412
+ if (outcomes.length === 0) return;
6413
+ const started = outcomes.filter((o) => o.ok && !o.alreadyRunning);
6414
+ if (started.length > 0) {
6415
+ console.log(`${colors.green("\u5DF2\u542F\u52A8\u96A7\u9053")}: ${started.map((o) => o.name).join(", ")}`);
6416
+ }
6417
+ for (const failed of outcomes.filter((o) => !o.ok)) {
6418
+ console.log("");
6419
+ console.log(colors.yellow(`\u8B66\u544A: \u96A7\u9053 "${failed.name}" \u542F\u52A8\u5931\u8D25`));
6420
+ console.log(colors.gray(` ${failed.error?.message ?? "\u672A\u77E5\u9519\u8BEF"}`));
6421
+ for (const line of failed.error?.hint ?? []) {
6422
+ if (line.trim()) console.log(colors.gray(line.startsWith(" ") ? line : ` ${line}`));
6423
+ }
6424
+ console.log(colors.gray(" \u5185\u7F51\u5206\u6D41\u89C4\u5219\u5C06\u4E0D\u53EF\u7528\uFF0C\u5176\u4F59\u6D41\u91CF\u6B63\u5E38"));
6425
+ console.log(colors.gray(` \u6392\u67E5: mihomo tunnel status ${failed.name}`));
6426
+ console.log("");
6427
+ }
6428
+ }
5831
6429
  async function cmdStart(args) {
5832
6430
  const modeToken = args[1] && !args[1].startsWith("-") ? args[1].toLowerCase() : void 0;
5833
6431
  if (modeToken !== void 0 && modeToken !== "tun" && modeToken !== "mixed") {
@@ -5846,6 +6444,7 @@ async function cmdStart(args) {
5846
6444
  const concurrency = parseIntArg(args, "-j", "--concurrency", DEFAULT_TEST_CONCURRENCY);
5847
6445
  const skipUpdate = hasFlag(args, "-s", "--no-update");
5848
6446
  const skipClean = hasFlag(args, "--no-clean");
6447
+ const skipTunnel = hasFlag(args, "--no-tunnel");
5849
6448
  const updateTimeout = parseIntArg(args, "-u", "--update-timeout", DEFAULT_AUTO_UPDATE_TIMEOUT);
5850
6449
  const sub = requireActiveSubscription("\u6CA1\u6709\u8BA2\u9605\uFF0C\u8BF7\u5148\u6DFB\u52A0\u8BA2\u9605");
5851
6450
  if (!skipUpdate) {
@@ -5887,6 +6486,9 @@ async function cmdStart(args) {
5887
6486
  const lines = e.message.split("\n");
5888
6487
  throw new CliError(lines[0], { label: "\u542F\u52A8\u5931\u8D25", hint: lines.slice(1) });
5889
6488
  }
6489
+ if (!skipTunnel) {
6490
+ await startAutoTunnelsWithWarning();
6491
+ }
5890
6492
  const cleanThreshold = isGithubUrl(sub.url) ? AUTO_CLEAN_THRESHOLD_GITHUB : AUTO_CLEAN_THRESHOLD;
5891
6493
  if (!skipClean && configInfo.proxies > cleanThreshold) {
5892
6494
  const cache = readSubscriptionCache();
@@ -5926,7 +6528,7 @@ async function cmdStart(args) {
5926
6528
  saveSubscriptionCache(sub.name, { last_auto_clean_at: (/* @__PURE__ */ new Date()).toISOString() });
5927
6529
  }
5928
6530
  }
5929
- printStatus();
6531
+ await printStatus();
5930
6532
  }
5931
6533
 
5932
6534
  // src/commands/shared.ts
@@ -6010,7 +6612,8 @@ async function daemonOn() {
6010
6612
  printDaemonStatus();
6011
6613
  }
6012
6614
  function daemonOff() {
6013
- if (!isDaemonEnabled()) {
6615
+ const residualRootKernel = getMihomoPids().some(isProcessRoot);
6616
+ if (!isDaemonEnabled() && !residualRootKernel) {
6014
6617
  console.log("\u4FDD\u6D3B\u5DF2\u662F\u5173\u95ED\u72B6\u6001");
6015
6618
  console.log("");
6016
6619
  printDaemonStatus();
@@ -6133,8 +6736,8 @@ async function cmdDirectory(args) {
6133
6736
 
6134
6737
  // src/kernel.ts
6135
6738
  import { spawnSync as spawnSync4 } from "child_process";
6136
- import fs7 from "fs";
6137
- import path6 from "path";
6739
+ import fs8 from "fs";
6740
+ import path7 from "path";
6138
6741
 
6139
6742
  // node_modules/compare-versions/lib/esm/utils.js
6140
6743
  var semver = /^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i;
@@ -6202,6 +6805,23 @@ function withMirror(url, mirror) {
6202
6805
  }
6203
6806
  return url;
6204
6807
  }
6808
+ var ALLOWED_ASSET_HOSTS = /* @__PURE__ */ new Set(["github.com", "api.github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
6809
+ function assertTrustedAssetUrl(rawUrl) {
6810
+ let parsed;
6811
+ try {
6812
+ parsed = new URL(rawUrl);
6813
+ } catch {
6814
+ throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u65E0\u6CD5\u89E3\u6790: ${rawUrl}`);
6815
+ }
6816
+ if (parsed.protocol !== "https:") {
6817
+ throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u5FC5\u987B\u662F https: ${rawUrl}`);
6818
+ }
6819
+ const host = parsed.hostname.toLowerCase();
6820
+ if (!ALLOWED_ASSET_HOSTS.has(host)) {
6821
+ throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u7684\u4E3B\u673A\u4E0D\u5728\u767D\u540D\u5355\u5185: ${host}
6822
+ \u4EC5\u5141\u8BB8: ${[...ALLOWED_ASSET_HOSTS].join(", ")}`);
6823
+ }
6824
+ }
6205
6825
  function getArch() {
6206
6826
  const arch = process.arch;
6207
6827
  if (arch === "arm64") return "arm64";
@@ -6260,15 +6880,16 @@ async function checkUpdate(mirror) {
6260
6880
  }
6261
6881
  function findBinaryInDir(dir, maxDepth = 4) {
6262
6882
  if (maxDepth <= 0) return null;
6263
- const files = fs7.readdirSync(dir);
6883
+ const files = fs8.readdirSync(dir);
6264
6884
  for (const f of files) {
6265
- const fullPath = path6.join(dir, f);
6266
- const stat = fs7.statSync(fullPath);
6885
+ const fullPath = path7.join(dir, f);
6886
+ const stat = fs8.lstatSync(fullPath);
6267
6887
  if (stat.isDirectory()) {
6268
6888
  const found = findBinaryInDir(fullPath, maxDepth - 1);
6269
6889
  if (found) return found;
6270
6890
  continue;
6271
6891
  }
6892
+ if (!stat.isFile()) continue;
6272
6893
  if (f === "mihomo") return fullPath;
6273
6894
  if (f.includes("mihomo") && !f.endsWith(".gz")) return fullPath;
6274
6895
  }
@@ -6288,8 +6909,9 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6288
6909
  throw new Error(`\u672A\u627E\u5230\u5339\u914D\u7684\u5185\u6838\u6587\u4EF6
6289
6910
  \u5E73\u53F0: ${platform}, \u67B6\u6784: ${arch}${hint}`);
6290
6911
  }
6912
+ assertTrustedAssetUrl(asset.browser_download_url);
6291
6913
  const downloadUrl = withMirror(asset.browser_download_url, mirror);
6292
- const tempPath = path6.join(DIRS.kernel, path6.basename(asset.name));
6914
+ const tempPath = path7.join(DIRS.kernel, path7.basename(asset.name));
6293
6915
  const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
6294
6916
  if (mirror && progressCallback) {
6295
6917
  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");
@@ -6297,9 +6919,26 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6297
6919
  if (progressCallback) {
6298
6920
  progressCallback(`\u4E0B\u8F7D\u5185\u6838: ${asset.name} (${sizeMB} MB)`);
6299
6921
  }
6922
+ const maxBytes = Number.isFinite(asset.size) && asset.size > 0 ? Math.floor(asset.size * 2 + 1024 * 1024) : 512 * 1024 * 1024;
6300
6923
  const curlResult = spawnSync4(
6301
6924
  "curl",
6302
- ["-L", "--progress-bar", "--connect-timeout", "30", "--max-time", String(Math.floor(KERNEL_DOWNLOAD_TIMEOUT / 1e3)), "-o", tempPath, downloadUrl],
6925
+ [
6926
+ "-L",
6927
+ "--proto",
6928
+ "=https",
6929
+ "--proto-redir",
6930
+ "=https",
6931
+ "--max-filesize",
6932
+ String(maxBytes),
6933
+ "--progress-bar",
6934
+ "--connect-timeout",
6935
+ "30",
6936
+ "--max-time",
6937
+ String(Math.floor(KERNEL_DOWNLOAD_TIMEOUT / 1e3)),
6938
+ "-o",
6939
+ tempPath,
6940
+ downloadUrl
6941
+ ],
6303
6942
  { stdio: "inherit" }
6304
6943
  );
6305
6944
  if (curlResult.error) {
@@ -6310,14 +6949,27 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6310
6949
  }
6311
6950
  if (curlResult.status !== 0) {
6312
6951
  try {
6313
- fs7.unlinkSync(tempPath);
6952
+ fs8.unlinkSync(tempPath);
6314
6953
  } catch {
6315
6954
  }
6316
6955
  throw new Error(`\u4E0B\u8F7D\u5931\u8D25 (curl \u9000\u51FA\u7801 ${curlResult.status})`);
6317
6956
  }
6318
- if (!fs7.existsSync(tempPath)) {
6957
+ if (!fs8.existsSync(tempPath)) {
6319
6958
  throw new Error("\u4E0B\u8F7D\u5931\u8D25: \u6587\u4EF6\u672A\u751F\u6210");
6320
6959
  }
6960
+ if (Number.isFinite(asset.size) && asset.size > 0) {
6961
+ const actual = fs8.statSync(tempPath).size;
6962
+ if (actual !== asset.size) {
6963
+ try {
6964
+ fs8.unlinkSync(tempPath);
6965
+ } catch {
6966
+ }
6967
+ throw new Error(
6968
+ `\u4E0B\u8F7D\u7684\u6587\u4EF6\u5927\u5C0F\u4E0E release \u5143\u6570\u636E\u4E0D\u7B26\uFF08\u671F\u671B ${asset.size} \u5B57\u8282\uFF0C\u5B9E\u9645 ${actual} \u5B57\u8282\uFF09
6969
+ \u53EF\u80FD\u662F\u4E0B\u8F7D\u88AB\u622A\u65AD\u6216\u5185\u5BB9\u88AB\u66FF\u6362\uFF0C\u8BF7\u91CD\u8BD5\u6216\u6539\u7528 --no-mirror \u76F4\u8FDE`
6970
+ );
6971
+ }
6972
+ }
6321
6973
  if (progressCallback) {
6322
6974
  progressCallback("\u89E3\u538B\u5185\u6838...");
6323
6975
  }
@@ -6328,27 +6980,38 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6328
6980
  const listResult = spawnSync4("tar", ["-tzf", tempPath], { encoding: "utf8", timeout: 6e4 });
6329
6981
  if (listResult.error) throw listResult.error;
6330
6982
  if (listResult.status !== 0) throw new Error(`tar \u5217\u8868\u9000\u51FA\u7801 ${listResult.status}`);
6331
- const entries = (listResult.stdout || "").split("\n").filter(Boolean);
6332
- for (const entry of entries) {
6983
+ for (const entry of (listResult.stdout || "").split("\n").filter(Boolean)) {
6333
6984
  if (entry.startsWith("/") || entry.split("/").includes("..")) {
6334
6985
  throw new Error(`\u5F52\u6863\u542B\u975E\u6CD5\u8DEF\u5F84\u6761\u76EE: ${entry}`);
6335
6986
  }
6336
6987
  }
6337
- const tarResult = spawnSync4("tar", ["-xzf", tempPath, "-C", extractPath], { stdio: ["ignore", "ignore", "inherit"], timeout: 6e4 });
6988
+ const typeResult = spawnSync4("tar", ["-tvzf", tempPath], { encoding: "utf8", timeout: 6e4 });
6989
+ if (typeResult.error) throw typeResult.error;
6990
+ if (typeResult.status !== 0) throw new Error(`tar \u5217\u8868\u9000\u51FA\u7801 ${typeResult.status}`);
6991
+ for (const line of (typeResult.stdout || "").split("\n").filter(Boolean)) {
6992
+ const typeChar = line[0];
6993
+ if (typeChar !== "-" && typeChar !== "d") {
6994
+ throw new Error(`\u5F52\u6863\u542B\u975E\u666E\u901A\u6587\u4EF6\u6761\u76EE\uFF08\u7C7B\u578B "${typeChar}"\uFF09: ${line}`);
6995
+ }
6996
+ }
6997
+ const tarResult = spawnSync4("tar", ["--no-same-owner", "-xzf", tempPath, "-C", extractPath], {
6998
+ stdio: ["ignore", "ignore", "inherit"],
6999
+ timeout: 6e4
7000
+ });
6338
7001
  if (tarResult.error) throw tarResult.error;
6339
7002
  if (tarResult.status !== 0) throw new Error(`tar \u9000\u51FA\u7801 ${tarResult.status}`);
6340
7003
  } else if (tempPath.endsWith(".gz")) {
6341
- const baseName = path6.basename(tempPath, ".gz");
6342
- const outputPath = path6.join(extractPath, baseName);
7004
+ const baseName = path7.basename(tempPath, ".gz");
7005
+ const outputPath = path7.join(extractPath, baseName);
6343
7006
  const gzipResult = spawnSync4("gzip", ["-dc", tempPath], { maxBuffer: 256 * 1024 * 1024, timeout: 6e4 });
6344
7007
  if (gzipResult.error) throw gzipResult.error;
6345
7008
  if (gzipResult.status !== 0) throw new Error(`gzip \u9000\u51FA\u7801 ${gzipResult.status}`);
6346
- fs7.writeFileSync(outputPath, gzipResult.stdout, { mode: 493 });
7009
+ fs8.writeFileSync(outputPath, gzipResult.stdout, { mode: 493 });
6347
7010
  extractedBinary = outputPath;
6348
7011
  }
6349
7012
  } catch (e) {
6350
7013
  try {
6351
- fs7.unlinkSync(tempPath);
7014
+ fs8.unlinkSync(tempPath);
6352
7015
  } catch {
6353
7016
  }
6354
7017
  throw new Error(`\u89E3\u538B\u5931\u8D25: ${e.message}`);
@@ -6356,23 +7019,23 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6356
7019
  const foundBinary = extractedBinary || findBinaryInDir(extractPath);
6357
7020
  if (!foundBinary) {
6358
7021
  try {
6359
- fs7.unlinkSync(tempPath);
7022
+ fs8.unlinkSync(tempPath);
6360
7023
  } catch {
6361
7024
  }
6362
7025
  throw new Error("\u89E3\u538B\u540E\u672A\u627E\u5230\u53EF\u6267\u884C\u6587\u4EF6");
6363
7026
  }
6364
7027
  const targetPath = PATHS.mihomoBinary;
6365
7028
  if (foundBinary !== targetPath) {
6366
- if (fs7.existsSync(targetPath)) {
6367
- fs7.chmodSync(targetPath, 493);
7029
+ if (fs8.existsSync(targetPath)) {
7030
+ fs8.chmodSync(targetPath, 493);
6368
7031
  try {
6369
- fs7.unlinkSync(targetPath);
7032
+ fs8.unlinkSync(targetPath);
6370
7033
  } catch {
6371
7034
  }
6372
7035
  }
6373
- fs7.renameSync(foundBinary, targetPath);
7036
+ fs8.renameSync(foundBinary, targetPath);
6374
7037
  }
6375
- fs7.chmodSync(targetPath, 493);
7038
+ fs8.chmodSync(targetPath, 493);
6376
7039
  if (progressCallback) {
6377
7040
  progressCallback("\u6821\u9A8C\u5185\u6838...");
6378
7041
  }
@@ -6380,11 +7043,11 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6380
7043
  const checkOutput = `${check.stdout || ""}${check.stderr || ""}`.trim();
6381
7044
  if (check.error || check.status !== 0 || !/v?\d+\.\d+\.\d+/.test(checkOutput)) {
6382
7045
  try {
6383
- fs7.unlinkSync(targetPath);
7046
+ fs8.unlinkSync(targetPath);
6384
7047
  } catch {
6385
7048
  }
6386
7049
  try {
6387
- fs7.unlinkSync(tempPath);
7050
+ fs8.unlinkSync(tempPath);
6388
7051
  } catch {
6389
7052
  }
6390
7053
  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 +7055,7 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
6392
7055
  \u8F93\u51FA: ${checkOutput || "(\u7A7A)"}`);
6393
7056
  }
6394
7057
  try {
6395
- fs7.unlinkSync(tempPath);
7058
+ fs8.unlinkSync(tempPath);
6396
7059
  } catch {
6397
7060
  }
6398
7061
  clearKernelVersionCache();
@@ -6527,7 +7190,7 @@ function cmdLogs(args) {
6527
7190
  }
6528
7191
 
6529
7192
  // src/commands/overwrite.ts
6530
- import path7 from "path";
7193
+ import path8 from "path";
6531
7194
  function printOverwriteList() {
6532
7195
  const info = listOverwriteFile();
6533
7196
  const statusText = info.enabled ? colors.green("\u5DF2\u542F\u7528") : colors.yellow("\u5DF2\u7981\u7528");
@@ -6537,8 +7200,8 @@ function printOverwriteList() {
6537
7200
  if (info.files.length === 0) {
6538
7201
  console.log("\u6682\u65E0\u8986\u5199\u6587\u4EF6");
6539
7202
  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")}`);
7203
+ console.log(`\u7528\u6CD5\u793A\u4F8B: \u521B\u5EFA\u6587\u4EF6 ${path8.join(info.dir, "overwrite.yaml")}`);
7204
+ console.log(` \u6216 ${path8.join(info.dir, "overwrite.dns.yaml")}`);
6542
7205
  console.log("");
6543
7206
  } else {
6544
7207
  console.log(`${colors.cyan("\u8986\u5199\u6587\u4EF6")} (${info.files.length} \u4E2A\uFF0C\u6309\u987A\u5E8F\u52A0\u8F7D):`);
@@ -6602,7 +7265,7 @@ async function cmdOverwrite(args) {
6602
7265
  }
6603
7266
 
6604
7267
  // src/commands/reset.ts
6605
- import fs8 from "fs";
7268
+ import fs9 from "fs";
6606
7269
  var RESET_TARGETS = [
6607
7270
  {
6608
7271
  id: "subs",
@@ -6635,6 +7298,21 @@ var RESET_TARGETS = [
6635
7298
  paths: () => [DIRS.runtime],
6636
7299
  needsStop: true
6637
7300
  },
7301
+ {
7302
+ // 隧道要在删 pid 文件之前先停进程(onBefore):文件一删就再也找不到那些 ssh 进程,
7303
+ // 它们会继续占着端口跑下去,且 CLI 无任何路径能停掉。
7304
+ // **必须排在 settings 之前**:onAfter 会 writeSettings 重建 settings.json,
7305
+ // 排在 settings 之后会让 `reset --full` 留下一个 {}(同 subs 的处理,见执行顺序注释)
7306
+ id: "tunnel",
7307
+ aliases: ["tunnel", "tunnels", "ssh"],
7308
+ label: "\u96A7\u9053",
7309
+ paths: () => [DIRS.tunnel],
7310
+ needsStop: false,
7311
+ onBefore: () => stopAllTunnels(),
7312
+ // 同步清空 settings 里的隧道列表:只删运行态会留下「列表在但状态没了」的半重置状态。
7313
+ // 覆写文件不动——那是用户维护的资产,由 overwrites target 负责
7314
+ onAfter: () => writeSettings({ tunnels: void 0 })
7315
+ },
6638
7316
  {
6639
7317
  id: "settings",
6640
7318
  aliases: ["setting", "settings", "config"],
@@ -6662,8 +7340,8 @@ var RESET_TARGETS = [
6662
7340
  label: "\u8986\u5199",
6663
7341
  paths: () => {
6664
7342
  const dir = USER_DATA_DIR;
6665
- if (!fs8.existsSync(dir)) return [];
6666
- return fs8.readdirSync(dir).filter(isOverwriteFilename).map((f) => `${dir}/${f}`);
7343
+ if (!fs9.existsSync(dir)) return [];
7344
+ return fs9.readdirSync(dir).filter(isOverwriteFilename).map((f) => `${dir}/${f}`);
6667
7345
  },
6668
7346
  needsStop: false
6669
7347
  },
@@ -6726,7 +7404,7 @@ async function cmdReset(args) {
6726
7404
  }
6727
7405
  targets = matched;
6728
7406
  } else {
6729
- targets = RESET_TARGETS.filter((t) => !["settings", "kernel", "overwrites", "daemon"].includes(t.id));
7407
+ targets = RESET_TARGETS.filter((t) => !["settings", "kernel", "overwrites", "daemon", "tunnel"].includes(t.id));
6730
7408
  }
6731
7409
  for (const t of targets) {
6732
7410
  if (t.checkEmpty?.()) {
@@ -6786,8 +7464,9 @@ async function cmdReset(args) {
6786
7464
  }
6787
7465
  }
6788
7466
  for (const t of targets) {
7467
+ t.onBefore?.();
6789
7468
  for (const p of t.paths()) {
6790
- if (fs8.existsSync(p)) {
7469
+ if (fs9.existsSync(p)) {
6791
7470
  try {
6792
7471
  rmrf(p);
6793
7472
  } catch (e) {
@@ -6805,9 +7484,9 @@ async function cmdReset(args) {
6805
7484
  }
6806
7485
 
6807
7486
  // src/test-instance.ts
6808
- import { spawn as spawn2 } from "child_process";
6809
- import fs9 from "fs";
6810
- import path8 from "path";
7487
+ import { spawn as spawn3 } from "child_process";
7488
+ import fs10 from "fs";
7489
+ import path9 from "path";
6811
7490
  function isProxyValid(proxy) {
6812
7491
  if (proxy === null || typeof proxy !== "object") return false;
6813
7492
  if (!proxy.name || !proxy.server || !proxy.port) return false;
@@ -6818,20 +7497,20 @@ function isProxyValid(proxy) {
6818
7497
  }
6819
7498
  return true;
6820
7499
  }
6821
- var TEST_DIR = path8.join(USER_DATA_DIR, "test");
7500
+ var TEST_DIR = path9.join(USER_DATA_DIR, "test");
6822
7501
  var TEST_DIRS = {
6823
- data: path8.join(TEST_DIR, "data"),
6824
- runtime: path8.join(TEST_DIR, "runtime")
7502
+ data: path9.join(TEST_DIR, "data"),
7503
+ runtime: path9.join(TEST_DIR, "runtime")
6825
7504
  };
6826
7505
  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")
7506
+ configFile: path9.join(TEST_DIRS.runtime, "config.yaml"),
7507
+ pidFile: path9.join(TEST_DIRS.runtime, "pid"),
7508
+ logFile: path9.join(TEST_DIR, "test.log")
6830
7509
  };
6831
7510
  var TEST_API = `http://${TEST_CONFIG["external-controller"]}`;
6832
7511
  function ensureTestDirs() {
6833
7512
  for (const dir of Object.values(TEST_DIRS)) {
6834
- fs9.mkdirSync(dir, { recursive: true, mode: 448 });
7513
+ fs10.mkdirSync(dir, { recursive: true, mode: 448 });
6835
7514
  }
6836
7515
  }
6837
7516
  function cleanupTestDir() {
@@ -6869,25 +7548,25 @@ function buildTestConfig(subName) {
6869
7548
  rules: ["MATCH,PROXY"]
6870
7549
  };
6871
7550
  const content = dumpYaml(config);
6872
- fs9.writeFileSync(TEST_PATHS.configFile, content, { mode: 384 });
7551
+ fs10.writeFileSync(TEST_PATHS.configFile, content, { mode: 384 });
6873
7552
  }
6874
7553
  async function startTestInstance() {
6875
7554
  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');
7555
+ if (!fs10.existsSync(binary)) throw new CliError('\u672A\u627E\u5230 mihomo \u5185\u6838\uFF0C\u8BF7\u5148\u8FD0\u884C "mihomo kernel" \u4E0B\u8F7D');
6877
7556
  stopTestInstance();
6878
- const logFd = fs9.openSync(TEST_PATHS.logFile, "a");
6879
- const child = spawn2(binary, ["-d", TEST_DIRS.data, "-f", TEST_PATHS.configFile], {
7557
+ const logFd = fs10.openSync(TEST_PATHS.logFile, "a");
7558
+ const child = spawn3(binary, ["-d", TEST_DIRS.data, "-f", TEST_PATHS.configFile], {
6880
7559
  detached: true,
6881
7560
  stdio: ["ignore", logFd, logFd]
6882
7561
  });
6883
7562
  child.on("error", () => {
6884
7563
  });
6885
- fs9.closeSync(logFd);
7564
+ fs10.closeSync(logFd);
6886
7565
  child.unref();
6887
7566
  const pid = child.pid;
6888
7567
  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
7568
  spawnedTestPid = pid;
6890
- fs9.writeFileSync(TEST_PATHS.pidFile, pid.toString(), { mode: 384 });
7569
+ fs10.writeFileSync(TEST_PATHS.pidFile, pid.toString(), { mode: 384 });
6891
7570
  const client = createHttpClient({ timeout: 2e3 });
6892
7571
  let ready = false;
6893
7572
  for (let i = 0; i < 60; i++) {
@@ -6903,7 +7582,7 @@ async function startTestInstance() {
6903
7582
  if (!isProcessRunning(pid)) {
6904
7583
  let errorDetail = "";
6905
7584
  try {
6906
- errorDetail = fs9.readFileSync(TEST_PATHS.logFile, "utf8").slice(-1e3);
7585
+ errorDetail = fs10.readFileSync(TEST_PATHS.logFile, "utf8").slice(-1e3);
6907
7586
  } catch {
6908
7587
  }
6909
7588
  throw new CliError(`\u6D4B\u8BD5\u5B9E\u4F8B\u542F\u52A8\u5931\u8D25${errorDetail ? `
@@ -6917,7 +7596,7 @@ var spawnedTestPid = null;
6917
7596
  function stopTestInstance() {
6918
7597
  let pid = null;
6919
7598
  try {
6920
- const fromFile = parseInt(fs9.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
7599
+ const fromFile = parseInt(fs10.readFileSync(TEST_PATHS.pidFile, "utf8").trim(), 10);
6921
7600
  if (fromFile > 0) pid = fromFile;
6922
7601
  } catch {
6923
7602
  }
@@ -6932,7 +7611,7 @@ function stopTestInstance() {
6932
7611
  }
6933
7612
  spawnedTestPid = null;
6934
7613
  try {
6935
- fs9.unlinkSync(TEST_PATHS.pidFile);
7614
+ fs10.unlinkSync(TEST_PATHS.pidFile);
6936
7615
  } catch {
6937
7616
  }
6938
7617
  }
@@ -7342,6 +8021,224 @@ async function cmdClean(args) {
7342
8021
  }
7343
8022
  }
7344
8023
 
8024
+ // src/commands/tunnel.ts
8025
+ function formatState(status) {
8026
+ switch (status.state) {
8027
+ case "running":
8028
+ return colors.green(`\u8FD0\u884C\u4E2D (PID ${status.pid})`);
8029
+ case "dead-port":
8030
+ return colors.yellow(`\u5047\u6D3B (PID ${status.pid}\uFF0C\u7AEF\u53E3 ${status.config.port} \u4E0D\u901A)`);
8031
+ default:
8032
+ return colors.yellow("\u672A\u8FD0\u884C");
8033
+ }
8034
+ }
8035
+ function getTunnelNameArg(args) {
8036
+ return getNonFlagArg(args, 2);
8037
+ }
8038
+ function requireTunnelNameArg(args, usage) {
8039
+ const name = getTunnelNameArg(args);
8040
+ if (!name) {
8041
+ const tunnels = getTunnels();
8042
+ throw new CliError("\u8BF7\u6307\u5B9A\u96A7\u9053\u540D\u79F0", {
8043
+ hint: [usage, ...tunnels.length > 0 ? ["", "\u53EF\u7528\u96A7\u9053:", ...tunnels.map((t) => ` ${t.name}`)] : []]
8044
+ });
8045
+ }
8046
+ return name;
8047
+ }
8048
+ async function printTunnelList() {
8049
+ const tunnels = getTunnels();
8050
+ console.log("");
8051
+ if (tunnels.length === 0) {
8052
+ console.log("\u6CA1\u6709\u914D\u7F6E\u96A7\u9053");
8053
+ console.log("");
8054
+ console.log("\u6DFB\u52A0\u96A7\u9053: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>");
8055
+ console.log(colors.gray(" \u4F8B\u5982: mihomo tunnel add work --host m4 --port 1080"));
8056
+ 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"));
8057
+ console.log("");
8058
+ return;
8059
+ }
8060
+ const statuses = await getAllTunnelStatus();
8061
+ console.log(colors.cyan("\u96A7\u9053\u5217\u8868:"));
8062
+ console.log("");
8063
+ for (const status of statuses) {
8064
+ const { config } = status;
8065
+ const autoLabel = config.auto ? colors.gray(" [auto]") : "";
8066
+ console.log(` ${colors.bold(config.name)}${autoLabel}`);
8067
+ console.log(` ${colors.gray("\u51FA\u53E3: ")}${config.host} \u2192 127.0.0.1:${config.port}`);
8068
+ console.log(` ${colors.gray("\u72B6\u6001: ")}${formatState(status)}`);
8069
+ if (status.started_by) {
8070
+ console.log(` ${colors.gray("\u6765\u6E90: ")}${status.started_by === "auto" ? "\u968F start \u62C9\u8D77" : "\u624B\u52A8\u542F\u52A8"}`);
8071
+ }
8072
+ }
8073
+ console.log("");
8074
+ console.log("\u542F\u52A8: mihomo tunnel up [\u540D\u5B57] \u505C\u6B62: mihomo tunnel down [\u540D\u5B57]");
8075
+ console.log("\u72B6\u6001: mihomo tunnel status \u5220\u9664: mihomo tunnel rm <\u540D\u5B57>");
8076
+ console.log("");
8077
+ }
8078
+ async function tunnelAdd(args) {
8079
+ const name = requireTunnelNameArg(args, "\u7528\u6CD5: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3> [--no-auto]");
8080
+ const host = parseStringArg(args, "--host");
8081
+ if (!host) {
8082
+ throw new CliError("\u7F3A\u5C11 --host", {
8083
+ 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"]
8084
+ });
8085
+ }
8086
+ const portRaw = parseStringArg(args, "--port");
8087
+ if (!portRaw) {
8088
+ throw new CliError("\u7F3A\u5C11 --port", { hint: ["\u7528\u6CD5: mihomo tunnel add <\u540D\u5B57> --host <ssh\u4E3B\u673A> --port <\u7AEF\u53E3>"] });
8089
+ }
8090
+ if (!/^\d+$/.test(portRaw.trim())) {
8091
+ throw new CliError(`\u7AEF\u53E3\u65E0\u6548: "${portRaw}"\uFF0C\u9700\u4E3A 1-65535 \u7684\u6574\u6570`);
8092
+ }
8093
+ const port = Number(portRaw.trim());
8094
+ const auto = !hasFlag(args, "--no-auto");
8095
+ const config = { name, host, port, auto };
8096
+ addTunnel(config);
8097
+ const created = ensureTunnelOverwriteFile(config);
8098
+ const overwritePath = getTunnelOverwritePath(name);
8099
+ console.log(`${colors.green("\u5DF2\u6DFB\u52A0\u96A7\u9053")} ${name} \xB7 ${host} \u2192 127.0.0.1:${port}${auto ? " \xB7 auto" : ""}`);
8100
+ console.log("");
8101
+ if (created) {
8102
+ console.log(`\u5DF2\u751F\u6210\u8986\u5199\u6A21\u677F: ${overwritePath}`);
8103
+ 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"));
8104
+ console.log(colors.gray(" \u7F16\u8F91\u540E\u6267\u884C mihomo start \u751F\u6548"));
8105
+ } else {
8106
+ console.log(`\u8986\u5199\u6587\u4EF6\u5DF2\u5B58\u5728\uFF0C\u672A\u6539\u52A8: ${overwritePath}`);
8107
+ }
8108
+ console.log("");
8109
+ console.log(`\u542F\u52A8\u96A7\u9053: mihomo tunnel up ${name}`);
8110
+ if (created) {
8111
+ await restartToApply(args);
8112
+ }
8113
+ }
8114
+ async function tunnelUp(args) {
8115
+ const name = getTunnelNameArg(args);
8116
+ const targets = name ? [resolveTunnel(name)] : getTunnels();
8117
+ if (targets.length === 0) {
8118
+ 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>"] });
8119
+ }
8120
+ for (const config of targets) {
8121
+ if (ensureTunnelOverwriteFile(config)) {
8122
+ console.log(colors.gray(`\u5DF2\u8865\u5EFA\u8986\u5199\u6A21\u677F: ${getTunnelOverwritePath(config.name)}`));
8123
+ }
8124
+ const result = await startTunnel(config.name, { startedBy: "manual" });
8125
+ if (result.alreadyRunning) {
8126
+ console.log(`${colors.gray("\u5DF2\u5728\u8FD0\u884C")} ${config.name} (PID ${result.pid})`);
8127
+ } else {
8128
+ console.log(`${colors.green("\u5DF2\u542F\u52A8\u96A7\u9053")} ${config.name} \xB7 ${config.host} \u2192 127.0.0.1:${config.port} (PID ${result.pid})`);
8129
+ }
8130
+ }
8131
+ }
8132
+ function tunnelDown(args) {
8133
+ const name = getTunnelNameArg(args);
8134
+ const targets = name ? [resolveTunnel(name)] : getTunnels();
8135
+ if (targets.length === 0) {
8136
+ throw new CliError("\u6CA1\u6709\u914D\u7F6E\u96A7\u9053");
8137
+ }
8138
+ let stopped = 0;
8139
+ for (const config of targets) {
8140
+ const result = stopTunnel(config.name);
8141
+ if (result.notRunning) {
8142
+ console.log(`${colors.yellow("\u4E0D\u5728\u8FD0\u884C")} ${config.name}`);
8143
+ } else {
8144
+ console.log(`${colors.green("\u5DF2\u505C\u6B62\u96A7\u9053")} ${config.name} (PID ${result.pid})`);
8145
+ stopped++;
8146
+ }
8147
+ }
8148
+ if (stopped > 0) {
8149
+ console.log("");
8150
+ 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"));
8151
+ }
8152
+ }
8153
+ async function tunnelStatus(args) {
8154
+ const name = getTunnelNameArg(args);
8155
+ if (!name) {
8156
+ await printTunnelList();
8157
+ return;
8158
+ }
8159
+ const config = resolveTunnel(name);
8160
+ const status = await getTunnelStatus(config);
8161
+ console.log("");
8162
+ console.log(`${colors.gray("\u96A7\u9053: ")}${config.name}${config.auto ? colors.gray(" [auto]") : ""}`);
8163
+ console.log(`${colors.gray("\u51FA\u53E3: ")}${config.host} \u2192 127.0.0.1:${config.port}`);
8164
+ console.log(`${colors.gray("\u72B6\u6001: ")}${formatState(status)}`);
8165
+ if (status.started_by) {
8166
+ console.log(`${colors.gray("\u6765\u6E90: ")}${status.started_by === "auto" ? "\u968F start \u62C9\u8D77" : "\u624B\u52A8\u542F\u52A8"}`);
8167
+ }
8168
+ console.log("");
8169
+ if (status.state === "dead-port") {
8170
+ 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"));
8171
+ console.log(`\u91CD\u542F\u96A7\u9053: mihomo tunnel down ${config.name} && mihomo tunnel up ${config.name}`);
8172
+ console.log(colors.gray(` \u65E5\u5FD7: ${getTunnelLogPath(config.name)}`));
8173
+ console.log("");
8174
+ } else if (status.state === "stopped") {
8175
+ console.log(`\u542F\u52A8\u96A7\u9053: mihomo tunnel up ${config.name}`);
8176
+ console.log("");
8177
+ }
8178
+ }
8179
+ async function tunnelRemove(args) {
8180
+ const name = requireTunnelNameArg(args, "\u7528\u6CD5: mihomo tunnel rm <\u540D\u5B57> [-y]");
8181
+ const config = resolveTunnel(name);
8182
+ const skipConfirm = hasFlag(args, "-y", "--yes");
8183
+ if (!skipConfirm) {
8184
+ if (!process.stdin.isTTY) {
8185
+ throw new CliError("\u5220\u9664\u96A7\u9053\u9700\u8981\u786E\u8BA4", {
8186
+ label: "\u5DF2\u53D6\u6D88",
8187
+ hint: [`\u8DF3\u8FC7\u786E\u8BA4: mihomo tunnel rm ${config.name} -y`]
8188
+ });
8189
+ }
8190
+ const confirmed = await confirmPrompt(`\u786E\u8BA4\u5220\u9664\u96A7\u9053 "${config.name}"?`);
8191
+ if (!confirmed) {
8192
+ console.log("\u5DF2\u53D6\u6D88");
8193
+ return;
8194
+ }
8195
+ }
8196
+ stopTunnel(config.name);
8197
+ removeTunnel(config.name);
8198
+ console.log(`${colors.green("\u5DF2\u5220\u9664\u96A7\u9053")} ${config.name}`);
8199
+ console.log("");
8200
+ console.log(`\u8986\u5199\u6587\u4EF6\u672A\u5220\u9664: ${getTunnelOverwritePath(config.name)}`);
8201
+ 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"));
8202
+ }
8203
+ function resolveTunnel(name) {
8204
+ const config = findTunnel(name);
8205
+ if (!config) {
8206
+ const tunnels = getTunnels();
8207
+ const suggestion = suggestSimilar(
8208
+ name,
8209
+ tunnels.map((t) => t.name)
8210
+ );
8211
+ throw new CliError(`\u672A\u627E\u5230\u96A7\u9053 "${name}"`, {
8212
+ hint: [
8213
+ ...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [],
8214
+ ...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>"]
8215
+ ]
8216
+ });
8217
+ }
8218
+ return config;
8219
+ }
8220
+ var SUBCOMMANDS5 = [
8221
+ { name: "list", handler: printTunnelList },
8222
+ { name: "add", handler: tunnelAdd },
8223
+ { name: "up", aliases: ["start"], handler: tunnelUp },
8224
+ { name: "down", aliases: ["stop"], handler: tunnelDown },
8225
+ { name: "status", handler: tunnelStatus },
8226
+ { name: "remove", aliases: ["rm", "delete"], handler: tunnelRemove }
8227
+ ];
8228
+ async function cmdTunnel(args) {
8229
+ await dispatchSubcommand(args, SUBCOMMANDS5, {
8230
+ // 无 action → 列表;未知 action → 报错(不静默回落,否则 `tunnel upp` 会看似成功)
8231
+ fallback: printTunnelList,
8232
+ onUnknown: (action) => {
8233
+ const names = SUBCOMMANDS5.flatMap((c) => [c.name, ...c.aliases ?? []]);
8234
+ const suggestion = suggestSimilar(action, names);
8235
+ throw new CliError(`\u672A\u77E5\u7684 tunnel \u5B50\u547D\u4EE4: ${action}`, {
8236
+ hint: [...suggestion.length > 0 ? [`\u662F\u5426\u60F3\u8F93\u5165: ${suggestion.join(" / ")}?`] : [], "", "\u53EF\u7528\u5B50\u547D\u4EE4: list, add, up, down, status, rm"]
8237
+ });
8238
+ }
8239
+ });
8240
+ }
8241
+
7345
8242
  // src/commands/ui.ts
7346
8243
  function cmdUI(args) {
7347
8244
  const uiName = args[1] || "zash";
@@ -7366,7 +8263,7 @@ function cmdUI(args) {
7366
8263
  }
7367
8264
 
7368
8265
  // src/commands/update.ts
7369
- import { execFile, spawn as spawn3 } from "child_process";
8266
+ import { execFile, spawn as spawn4 } from "child_process";
7370
8267
  import { promisify } from "util";
7371
8268
  var execFileAsync = promisify(execFile);
7372
8269
  var NPM_VIEW_TIMEOUT_MS = 15e3;
@@ -7397,7 +8294,7 @@ async function cmdUpdate() {
7397
8294
  console.log("\u6B63\u5728\u66F4\u65B0 mihomo-cli...");
7398
8295
  console.log("");
7399
8296
  await new Promise((resolve, reject) => {
7400
- const npm = spawn3("npm", ["install", "-g", PKG_NAME], { stdio: "inherit" });
8297
+ const npm = spawn4("npm", ["install", "-g", PKG_NAME], { stdio: "inherit" });
7401
8298
  npm.on("close", (code) => {
7402
8299
  if (code === 0) {
7403
8300
  resolve();
@@ -7573,6 +8470,20 @@ var COMMANDS = [
7573
8470
  group: "system",
7574
8471
  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
8472
  },
8473
+ {
8474
+ // 别名不能用 tun——已被 TUN 模式快捷命令占用(注册表重复 token 会在模块加载期直接抛错)
8475
+ name: "tunnel",
8476
+ aliases: ["ssh", "tunnels"],
8477
+ handler: cmdTunnel,
8478
+ group: "system",
8479
+ usage: [
8480
+ "tunnel \u5217\u51FA ssh \u96A7\u9053\uFF08\u522B\u540D ssh\uFF09",
8481
+ "tunnel add <\u540D\u5B57> --host <\u4E3B\u673A> --port <\u7AEF\u53E3> [--no-auto]",
8482
+ "tunnel up|down [\u540D\u5B57] \u542F\u52A8/\u505C\u6B62\u96A7\u9053\uFF08\u65E0\u53C2\u5373\u5168\u90E8\uFF09",
8483
+ "tunnel status [\u540D\u5B57] \u67E5\u770B\u96A7\u9053\u72B6\u6001\uFF08\u771F\u5B9E\u63A2\u6D4B\u7AEF\u53E3\uFF09",
8484
+ "tunnel rm <\u540D\u5B57> [-y] \u5220\u9664\u96A7\u9053\uFF08\u4E0D\u5220\u8986\u5199\u6587\u4EF6\uFF09"
8485
+ ]
8486
+ },
7576
8487
  {
7577
8488
  name: "update",
7578
8489
  aliases: ["upd", "upgrade"],
@@ -7678,7 +8589,7 @@ async function main() {
7678
8589
  if (args.length === 0) {
7679
8590
  assertSupportedPlatform("status");
7680
8591
  ensureDirs();
7681
- printStatus();
8592
+ await printStatus();
7682
8593
  printShortHelp();
7683
8594
  return;
7684
8595
  }