mihomo-cli 2.3.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.1] - 2026-05-02
4
+
5
+ ### 新功能
6
+
7
+ - **合并订阅** - `sub add url1,url2 name` 支持逗号分隔多 URL,合并节点(按名去重),分组/规则取第一个源
8
+ - **sub free 0** - 特殊 ID `0` 自动合并免费源 #1 + #2(节点更多,配置相同)
9
+
10
+ ### 改进
11
+
12
+ - 合并订阅在列表中显示 `[合并 N 源]` 标记
13
+ - `sub update` 自动识别合并订阅并重新下载合并
14
+ - URL 脱敏支持逗号分隔多 URL
15
+
16
+ ---
17
+
3
18
  ## [2.3.0] - 2026-05-02
4
19
 
5
20
  ### 新功能
package/README.md CHANGED
@@ -98,8 +98,8 @@ mihomo ui yacd # YACD
98
98
  | ----------------------------- | -------------------------------------- |
99
99
  | `mihomo sub` | 列出所有订阅(含流量、到期时间) |
100
100
  | `mihomo sub use <name>` | 切换当前订阅(支持模糊匹配,自动重启) |
101
- | `mihomo sub add <url> [name]` | 添加订阅并自动切换 |
102
- | `mihomo sub free <id>` | 添加内置免费订阅(`sub free` 列出可用源)|
101
+ | `mihomo sub add <url> [name]` | 添加订阅并自动切换(支持逗号分隔多 URL 合并) |
102
+ | `mihomo sub free <id>` | 添加内置免费订阅(`0`=合并 #1+#2,`sub free` 列出可用源)|
103
103
  | `mihomo sub update` | 更新所有订阅 |
104
104
  | `mihomo sub update <name>` | 更新指定订阅(支持模糊匹配) |
105
105
  | `mihomo sub remove <name>` | 删除订阅(支持模糊匹配) |
package/dist/index.js CHANGED
@@ -2795,6 +2795,9 @@ function invalidateSettingsCache() {
2795
2795
  }
2796
2796
  function maskUrl(url) {
2797
2797
  if (!url) return url;
2798
+ if (url.includes(",")) {
2799
+ return url.split(",").map((u) => maskUrl(u.trim())).join(", ");
2800
+ }
2798
2801
  try {
2799
2802
  const parsed = new URL(url);
2800
2803
  const tokenKeys = ["token", "key", "secret", "pass", "password", "auth", "access_token", "api_key"];
@@ -4207,6 +4210,12 @@ function viewLogWithTail(logPath, options) {
4207
4210
  var DEFAULT_UPDATE_INTERVAL_HOURS = 12;
4208
4211
  var YAML_DUMP_OPTS = { indent: 2, lineWidth: -1, noCompatMode: true };
4209
4212
  var HTTP_CLIENT = createHttpClient({ timeout: 6e4 });
4213
+ function isMultiUrl(url) {
4214
+ return url.includes(",");
4215
+ }
4216
+ function splitUrls(url) {
4217
+ return url.split(",").map((u) => u.trim()).filter(Boolean);
4218
+ }
4210
4219
  function loadSubscriptionConfig(subName) {
4211
4220
  const rawContent = readSubscriptionRawConfig(subName);
4212
4221
  if (!rawContent) {
@@ -4343,6 +4352,71 @@ async function downloadSubscription(url, subName = "default") {
4343
4352
  username
4344
4353
  };
4345
4354
  }
4355
+ async function downloadMergedSubscription(urls, subName) {
4356
+ const responses = await Promise.all(
4357
+ urls.map(async (url, index) => {
4358
+ try {
4359
+ const response = await HTTP_CLIENT.get(url, { responseType: "text" });
4360
+ return { url, index, response, error: null };
4361
+ } catch (e) {
4362
+ return { url, index, response: null, error: e };
4363
+ }
4364
+ })
4365
+ );
4366
+ for (const r of responses) {
4367
+ if (r.error) {
4368
+ const maskedUrl = maskUrl(r.url);
4369
+ throw new Error(`\u5408\u5E76\u8BA2\u9605\u7B2C ${r.index + 1} \u4E2A URL \u83B7\u53D6\u5931\u8D25: ${r.error.message}
4370
+ URL: ${maskedUrl}`);
4371
+ }
4372
+ }
4373
+ const parsed = responses.map((r, i) => {
4374
+ const content = r.response?.data;
4375
+ if (!content?.trim()) throw new Error(`\u5408\u5E76\u8BA2\u9605\u7B2C ${i + 1} \u4E2A URL \u5185\u5BB9\u4E3A\u7A7A`);
4376
+ return parseYamlOrJson(content, `\u5408\u5E76\u8BA2\u9605\u7B2C ${i + 1} \u4E2A`);
4377
+ });
4378
+ const base = parsed[0];
4379
+ const baseProxies = base.proxies || [];
4380
+ const seenNames = new Set(baseProxies.map((p) => p.name));
4381
+ for (let i = 1; i < parsed.length; i++) {
4382
+ const extraProxies = parsed[i].proxies || [];
4383
+ for (const proxy of extraProxies) {
4384
+ if (!seenNames.has(proxy.name)) {
4385
+ baseProxies.push(proxy);
4386
+ seenNames.add(proxy.name);
4387
+ }
4388
+ }
4389
+ }
4390
+ base.proxies = baseProxies;
4391
+ const mergedContent = jsYaml.dump(base, YAML_DUMP_OPTS);
4392
+ saveSubscriptionRawConfig(subName, mergedContent);
4393
+ const firstHeaders = responses[0].response?.headers;
4394
+ const userInfo = parseUserInfo(firstHeaders?.get("subscription-userinfo") ?? null);
4395
+ const updateIntervalHeader = firstHeaders?.get("profile-update-interval");
4396
+ const updateInterval = updateIntervalHeader ? parseInt(updateIntervalHeader, 10) : null;
4397
+ const webPageUrl = firstHeaders?.get("profile-web-page-url") || null;
4398
+ const username = parseUsernameFromContentDisposition(firstHeaders?.get("content-disposition") ?? null);
4399
+ const cacheData = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
4400
+ if (userInfo) {
4401
+ cacheData.upload = userInfo.upload;
4402
+ cacheData.download = userInfo.download;
4403
+ cacheData.total = userInfo.total;
4404
+ cacheData.expire = userInfo.expire;
4405
+ }
4406
+ if (updateInterval) cacheData.update_interval = updateInterval;
4407
+ if (webPageUrl) cacheData.web_page_url = webPageUrl;
4408
+ if (username) cacheData.username = username;
4409
+ saveSubscriptionCache(subName, cacheData);
4410
+ const proxyGroups = base["proxy-groups"];
4411
+ return {
4412
+ proxies: baseProxies.length,
4413
+ proxyGroups: proxyGroups ? proxyGroups.length : 0,
4414
+ userInfo,
4415
+ updateInterval,
4416
+ webPageUrl,
4417
+ username
4418
+ };
4419
+ }
4346
4420
  function prepareConfigForStart(mode, subName = "default") {
4347
4421
  const rawContent = readSubscriptionRawConfig(subName);
4348
4422
  if (!rawContent) {
@@ -4368,7 +4442,12 @@ function needsAutoUpdate(sub) {
4368
4442
  }
4369
4443
  async function tryUpdateOne(sub) {
4370
4444
  try {
4371
- const info = await downloadSubscription(sub.url, sub.name);
4445
+ let info;
4446
+ if (isMultiUrl(sub.url)) {
4447
+ info = await downloadMergedSubscription(splitUrls(sub.url), sub.name);
4448
+ } else {
4449
+ info = await downloadSubscription(sub.url, sub.name);
4450
+ }
4372
4451
  return { name: sub.name, success: true, proxies: info.proxies, proxyGroups: info.proxyGroups };
4373
4452
  } catch (e) {
4374
4453
  return { name: sub.name, success: false, error: e.message };
@@ -4730,8 +4809,9 @@ async function printSubscriptionList(options) {
4730
4809
  subs.forEach((s, i) => {
4731
4810
  const time = formatDate(s.updated_at);
4732
4811
  const defaultMark = activeSub && s.name === activeSub.name ? colors.green(" [\u4F7F\u7528\u4E2D]") : "";
4812
+ const mergeBadge = isMultiUrl(s.url) ? colors.cyan(` [\u5408\u5E76 ${splitUrls(s.url).length} \u6E90]`) : "";
4733
4813
  const interval = s.update_interval || DEFAULT_UPDATE_INTERVAL_HOURS;
4734
- console.log(` ${i + 1}. ${s.name}${defaultMark}`);
4814
+ console.log(` ${i + 1}. ${s.name}${defaultMark}${mergeBadge}`);
4735
4815
  console.log(` ${colors.gray("\u66F4\u65B0: ")}${time} (\u95F4\u9694: ${interval}h)`);
4736
4816
  if (s.username) {
4737
4817
  console.log(` ${colors.gray("\u7528\u6237: ")}${s.username}`);
@@ -4764,14 +4844,40 @@ async function printSubscriptionList(options) {
4764
4844
  console.log("\u6253\u5F00\u9875\u9762: mihomo sub web [name]");
4765
4845
  console.log("");
4766
4846
  }
4847
+ function printFreeSourceList() {
4848
+ const freeSources = getFreeSubscriptionSources();
4849
+ console.log(` 00 \u5408\u5E76 #1 + #2 (\u8282\u70B9\u66F4\u591A)`);
4850
+ for (let i = 0; i < freeSources.length; i++) {
4851
+ console.log(` ${String(i + 1).padStart(2, "0")} ${freeSources[i].name}`);
4852
+ }
4853
+ }
4767
4854
  async function addFreeSubscription(freeId) {
4768
4855
  const freeSources = getFreeSubscriptionSources();
4856
+ if (freeId === 0) {
4857
+ const sources = [freeSources[0], freeSources[1]];
4858
+ const urls = sources.map((s) => s.url);
4859
+ const mergedUrl = urls.join(",");
4860
+ const name2 = "free0";
4861
+ console.log(`\u6DFB\u52A0\u5408\u5E76\u514D\u8D39\u8BA2\u9605: ${name2} (${sources.map((s) => s.name).join(" + ")})`);
4862
+ try {
4863
+ addSubscription(mergedUrl, name2);
4864
+ setDefaultSubscription(name2);
4865
+ const info = await downloadMergedSubscription(urls, name2);
4866
+ const repoUrls = sources.map((s) => githubRepoUrl(s.url)).filter(Boolean);
4867
+ if (repoUrls.length > 0) saveSubscriptionCache(name2, { web_page_url: repoUrls.join(", ") });
4868
+ console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name2}" (${formatProxySummary(info)}, \u5408\u5E76 ${sources.length} \u6E90)`);
4869
+ } catch (e) {
4870
+ console.error(`\u6DFB\u52A0\u5931\u8D25: ${e.message}`);
4871
+ process.exit(1);
4872
+ }
4873
+ console.log("");
4874
+ await printSubscriptionList();
4875
+ return;
4876
+ }
4769
4877
  if (freeId < 1 || freeId > freeSources.length) {
4770
- console.error(`\u9519\u8BEF: \u514D\u8D39\u8BA2\u9605 ID \u8303\u56F4 1-${freeSources.length}`);
4878
+ console.error(`\u9519\u8BEF: \u514D\u8D39\u8BA2\u9605 ID \u8303\u56F4 0-${freeSources.length}`);
4771
4879
  console.log("\n\u53EF\u7528\u6E90:");
4772
- for (let i = 0; i < freeSources.length; i++) {
4773
- console.log(` ${String(i + 1).padStart(2, "0")} ${freeSources[i].name}`);
4774
- }
4880
+ printFreeSourceList();
4775
4881
  process.exit(1);
4776
4882
  }
4777
4883
  const source = freeSources[freeId - 1];
@@ -4799,13 +4905,10 @@ async function cmdSubscription(args) {
4799
4905
  }
4800
4906
  if (action === "free") {
4801
4907
  const id = parseInt(args[2], 10);
4802
- if (!id || Number.isNaN(id)) {
4803
- const freeSources = getFreeSubscriptionSources();
4908
+ if (Number.isNaN(id)) {
4804
4909
  console.log("\u7528\u6CD5: mihomo sub free <id>\n");
4805
4910
  console.log("\u53EF\u7528\u6E90:");
4806
- for (let i = 0; i < freeSources.length; i++) {
4807
- console.log(` ${String(i + 1).padStart(2, "0")} ${freeSources[i].name}`);
4808
- }
4911
+ printFreeSourceList();
4809
4912
  process.exit(1);
4810
4913
  }
4811
4914
  await addFreeSubscription(id);
@@ -4819,21 +4922,45 @@ async function cmdSubscription(args) {
4819
4922
  }
4820
4923
  const url = args[2];
4821
4924
  const name = args[3] || "default";
4822
- if (!url?.startsWith("http")) {
4925
+ if (!url) {
4823
4926
  console.error("\u9519\u8BEF: \u8BF7\u63D0\u4F9B\u6709\u6548\u7684\u8BA2\u9605 URL");
4824
4927
  process.exit(1);
4825
4928
  }
4826
- console.log(`\u6DFB\u52A0\u8BA2\u9605: ${name}`);
4827
- try {
4828
- addSubscription(url, name);
4829
- setDefaultSubscription(name);
4830
- const info = await downloadSubscription(url, name);
4831
- const repoUrl = githubRepoUrl(url);
4832
- if (repoUrl) saveSubscriptionCache(name, { web_page_url: repoUrl });
4833
- console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name}" (${formatProxySummary(info)})`);
4834
- } catch (e) {
4835
- console.error(`\u6DFB\u52A0\u5931\u8D25: ${e.message}`);
4836
- process.exit(1);
4929
+ if (isMultiUrl(url)) {
4930
+ const urls = splitUrls(url);
4931
+ for (const u of urls) {
4932
+ if (!u.startsWith("http")) {
4933
+ console.error(`\u9519\u8BEF: \u65E0\u6548\u7684 URL: ${u}`);
4934
+ process.exit(1);
4935
+ }
4936
+ }
4937
+ console.log(`\u6DFB\u52A0\u5408\u5E76\u8BA2\u9605: ${name} (${urls.length} \u4E2A\u6E90)`);
4938
+ try {
4939
+ addSubscription(url, name);
4940
+ setDefaultSubscription(name);
4941
+ const info = await downloadMergedSubscription(urls, name);
4942
+ console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name}" (${formatProxySummary(info)}, \u5408\u5E76 ${urls.length} \u6E90)`);
4943
+ } catch (e) {
4944
+ console.error(`\u6DFB\u52A0\u5931\u8D25: ${e.message}`);
4945
+ process.exit(1);
4946
+ }
4947
+ } else {
4948
+ if (!url.startsWith("http")) {
4949
+ console.error("\u9519\u8BEF: \u8BF7\u63D0\u4F9B\u6709\u6548\u7684\u8BA2\u9605 URL");
4950
+ process.exit(1);
4951
+ }
4952
+ console.log(`\u6DFB\u52A0\u8BA2\u9605: ${name}`);
4953
+ try {
4954
+ addSubscription(url, name);
4955
+ setDefaultSubscription(name);
4956
+ const info = await downloadSubscription(url, name);
4957
+ const repoUrl = githubRepoUrl(url);
4958
+ if (repoUrl) saveSubscriptionCache(name, { web_page_url: repoUrl });
4959
+ console.log(`\u5DF2\u6DFB\u52A0\u5E76\u5207\u6362\u5230 "${name}" (${formatProxySummary(info)})`);
4960
+ } catch (e) {
4961
+ console.error(`\u6DFB\u52A0\u5931\u8D25: ${e.message}`);
4962
+ process.exit(1);
4963
+ }
4837
4964
  }
4838
4965
  console.log("");
4839
4966
  await printSubscriptionList();
@@ -4867,7 +4994,13 @@ async function cmdSubscription(args) {
4867
4994
  const target = pickSingleSubscription(matches, name);
4868
4995
  console.log(`\u66F4\u65B0\u8BA2\u9605: ${target.name}`);
4869
4996
  try {
4870
- const info = await downloadSubscription(target.url, target.name);
4997
+ let info;
4998
+ if (isMultiUrl(target.url)) {
4999
+ const urls = splitUrls(target.url);
5000
+ info = await downloadMergedSubscription(urls, target.name);
5001
+ } else {
5002
+ info = await downloadSubscription(target.url, target.name);
5003
+ }
4871
5004
  console.log(`\u5DF2\u66F4\u65B0 (${formatProxySummary(info)})`);
4872
5005
  } catch (e) {
4873
5006
  console.error(`\u66F4\u65B0\u5931\u8D25: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihomo-cli",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "description": "A terminal-based mihomo (Clash.Meta) client for macOS",
6
6
  "bin": {