tt-help-cli-ycl 1.4.115 → 1.4.119

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.
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Clash 控制端自动发现 — discover.js
3
+ *
4
+ * 职责:
5
+ * 1. 探测已知配置文件的 external-controller 与 secret(Clash Verge / 69cloud)
6
+ * 2. 兜底: 枚举 127.0.0.1 监听端口,按 mihomo /version 响应特征识别
7
+ * 3. 验证闭环: 端口 + secret 必须通过 /version 认证才算可用
8
+ * 4. 多控制端选择策略: system-proxy / preference / first-alive
9
+ * 5. 全部失败回退旧默认值(9097 + 原 secret,向后兼容)
10
+ *
11
+ * 需求文档: clash控制端自动发现需求.md(已拍板 1A 2A 3A 4A 5A)
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "fs";
15
+ import { spawnSync } from "child_process";
16
+ import { homedir } from "os";
17
+ import { join } from "path";
18
+
19
+ const IS_WIN = process.platform === "win32";
20
+
21
+ export const DISCOVER_DEFAULTS = {
22
+ port: 9097,
23
+ secret: "abcdefg",
24
+ proxyPort: 7897,
25
+ selection: "system-proxy",
26
+ };
27
+
28
+ // 已知配置文件探测表(决策 4A: Clash Verge 优先)
29
+ const CONFIG_PATHS = (() => {
30
+ const appData = process.env.APPDATA || "";
31
+ const home = homedir();
32
+ return IS_WIN
33
+ ? [
34
+ {
35
+ client: "Clash Verge",
36
+ path: join(
37
+ appData,
38
+ "io.github.clash-verge-rev.clash-verge-rev",
39
+ "config.yaml",
40
+ ),
41
+ },
42
+ {
43
+ client: "69cloud",
44
+ path: join(appData, "69cloud", "config", "config.yaml"),
45
+ },
46
+ ]
47
+ : [
48
+ {
49
+ client: "Clash Verge",
50
+ path: join(
51
+ home,
52
+ "Library/Application Support/io.github.clash-verge-rev.clash-verge-rev",
53
+ "config.yaml",
54
+ ),
55
+ },
56
+ {
57
+ client: "69cloud",
58
+ path: join(
59
+ home,
60
+ "Library/Application Support/69cloud",
61
+ "config",
62
+ "config.yaml",
63
+ ),
64
+ },
65
+ ];
66
+ })();
67
+
68
+ // ── 结果缓存(仅缓存成功结果;回退结果不缓存,便于重试时重新探测) ──
69
+ let _cache = null;
70
+
71
+ export function invalidateDiscoverCache() {
72
+ _cache = null;
73
+ }
74
+
75
+ // ══════════════════════════════════════════════
76
+ // 工具函数(导出便于单测)
77
+ // ══════════════════════════════════════════════
78
+
79
+ /** 读取 ~/.tt-help.json 中的 clash 段(apiPort/secret/selection/configPaths) */
80
+ export function readUserConfig() {
81
+ try {
82
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
83
+ const p = join(home, ".tt-help.json");
84
+ if (!existsSync(p)) return {};
85
+ const cfg = JSON.parse(readFileSync(p, "utf-8"));
86
+ return cfg.clash && typeof cfg.clash === "object" ? cfg.clash : {};
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ /** 极简平面 YAML 解析:仅取顶层 `key: value` 行,够 config.yaml 用 */
93
+ export function parseFlatYaml(text) {
94
+ const out = {};
95
+ for (const line of String(text).split(/\r?\n/)) {
96
+ if (/^\s*(#|$)/.test(line)) continue;
97
+ const idx = line.indexOf(":");
98
+ if (idx <= 0) continue;
99
+ const key = line.slice(0, idx).trim();
100
+ const raw = line.slice(idx + 1).trim();
101
+ if (!key || !/^[A-Za-z0-9_-]+$/.test(key)) continue;
102
+ if (
103
+ !raw ||
104
+ raw.startsWith("-") ||
105
+ raw.startsWith("{") ||
106
+ raw.startsWith("[")
107
+ )
108
+ continue;
109
+ out[key] = raw.replace(/^["']|["']$/g, "");
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** 从 external-controller 字符串(如 "127.0.0.1:9097")解析端口 */
115
+ export function parseController(str) {
116
+ if (!str) return null;
117
+ const m = String(str).match(/(?:127\.0\.0\.1|localhost|0\.0\.0\.0)?:(\d+)/);
118
+ return m ? parseInt(m[1], 10) : null;
119
+ }
120
+
121
+ /**
122
+ * GET /version 探测(不抛异常)
123
+ * @returns {{status:number, signature:'ok'|'unauthorized'|null}|null}
124
+ * ok - 200 且 JSON 含 version(已认证通过或免认证)
125
+ * unauthorized - 401/403 且 body 含 Unauthorized(mihomo 特征,需要 secret)
126
+ * null - 非 mihomo / 不可达 / 超时
127
+ */
128
+ export async function probeVersion(port, secret = "", timeoutMs = 2500) {
129
+ const headers = {};
130
+ if (secret) headers.Authorization = `Bearer ${secret}`;
131
+ try {
132
+ const res = await fetch(`http://127.0.0.1:${port}/version`, {
133
+ headers,
134
+ signal: AbortSignal.timeout(timeoutMs),
135
+ });
136
+ const text = await res.text().catch(() => "");
137
+ if (res.status === 200) {
138
+ try {
139
+ const j = JSON.parse(text);
140
+ if (typeof j?.version === "string")
141
+ return { status: res.status, signature: "ok" };
142
+ } catch {}
143
+ }
144
+ if (
145
+ (res.status === 401 || res.status === 403) &&
146
+ text.includes("Unauthorized")
147
+ ) {
148
+ return { status: res.status, signature: "unauthorized" };
149
+ }
150
+ return { status: res.status, signature: null };
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /** 验证「端口 + secret」是否可完成认证(/version 返回 ok) */
157
+ export async function verifyController(port, secret = "") {
158
+ const r = await probeVersion(port, secret);
159
+ return !!r && r.signature === "ok";
160
+ }
161
+
162
+ /** 枚举本机 127.0.0.1 的 TCP LISTEN 端口 */
163
+ export function listListeningPorts() {
164
+ const ports = new Set();
165
+ try {
166
+ if (IS_WIN) {
167
+ const r = spawnSync("netstat", ["-ano", "-p", "TCP"], {
168
+ encoding: "utf-8",
169
+ timeout: 5000,
170
+ stdio: ["pipe", "pipe", "pipe"],
171
+ });
172
+ for (const line of String(r.stdout || "").split(/\r?\n/)) {
173
+ if (!line.includes("LISTENING")) continue;
174
+ const m = line.match(/127\.0\.0\.1:(\d+)/);
175
+ if (m) ports.add(parseInt(m[1], 10));
176
+ }
177
+ } else {
178
+ const r = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
179
+ encoding: "utf-8",
180
+ timeout: 5000,
181
+ stdio: ["pipe", "pipe", "pipe"],
182
+ });
183
+ for (const line of String(r.stdout || "").split(/\r?\n/)) {
184
+ if (!line.includes("(LISTEN)")) continue;
185
+ const m = line.match(/(?:127\.0\.0\.1|localhost):(\d+)/);
186
+ if (m) ports.add(parseInt(m[1], 10));
187
+ }
188
+ }
189
+ } catch {}
190
+ return [...ports];
191
+ }
192
+
193
+ /** 读取操作系统当前代理端口(mac scutil --proxy / win 注册表) */
194
+ export function getSystemProxyPorts() {
195
+ const ports = new Set();
196
+ try {
197
+ if (IS_WIN) {
198
+ const r = spawnSync(
199
+ "reg",
200
+ [
201
+ "query",
202
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
203
+ "/v",
204
+ "ProxyServer",
205
+ ],
206
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
207
+ );
208
+ for (const m of String(r.stdout || "").matchAll(
209
+ /(?:127\.0\.0\.1|localhost):(\d+)/g,
210
+ )) {
211
+ ports.add(parseInt(m[1], 10));
212
+ }
213
+ } else {
214
+ const r = spawnSync("scutil", ["--proxy"], {
215
+ encoding: "utf-8",
216
+ timeout: 5000,
217
+ stdio: ["pipe", "pipe", "pipe"],
218
+ });
219
+ const out = String(r.stdout || "");
220
+ const enabled = (key) => {
221
+ const m = out.match(new RegExp(`${key}\\s*:\\s*(\\d+)`));
222
+ return !!m && parseInt(m[1], 10) === 1;
223
+ };
224
+ const portOf = (key) => {
225
+ const m = out.match(new RegExp(`${key}\\s*:\\s*(\\d+)`));
226
+ return m ? parseInt(m[1], 10) : null;
227
+ };
228
+ const proxyHost = (key) => {
229
+ const m = out.match(new RegExp(`${key}\\s*:\\s*([\\w.\\-]+)`));
230
+ return m ? m[1] : "";
231
+ };
232
+ if (enabled("HTTPEnable")) {
233
+ const h = proxyHost("HTTPProxy");
234
+ const p = portOf("HTTPPort");
235
+ if ((h === "127.0.0.1" || h === "localhost") && p) ports.add(p);
236
+ }
237
+ if (enabled("SOCKSEnable")) {
238
+ const h = proxyHost("SOCKSProxy");
239
+ const p = portOf("SOCKSPort");
240
+ if ((h === "127.0.0.1" || h === "localhost") && p) ports.add(p);
241
+ }
242
+ }
243
+ } catch {}
244
+ return [...ports];
245
+ }
246
+
247
+ /** 读取候选控制端的 mixed-port(代理端口) */
248
+ export async function getMixedPort(port, secret = "") {
249
+ try {
250
+ const headers = {};
251
+ if (secret) headers.Authorization = `Bearer ${secret}`;
252
+ const res = await fetch(`http://127.0.0.1:${port}/configs`, {
253
+ headers,
254
+ signal: AbortSignal.timeout(3000),
255
+ });
256
+ if (!res.ok) return null;
257
+ const j = await res.json().catch(() => null);
258
+ return j && j["mixed-port"] ? parseInt(j["mixed-port"], 10) : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ // ══════════════════════════════════════════════
265
+ // 主流程
266
+ // ══════════════════════════════════════════════
267
+
268
+ /**
269
+ * 收集可用控制端候选(discover / discoverAll 共用)
270
+ * 显式指定端口验证失败时抛错(不回退,便于排障)
271
+ */
272
+ async function _collectCandidates({ apiPort, secret, extraPaths }) {
273
+ // 1) 显式指定端口:验证失败直接报错,不回退(逃生舱语义)
274
+ if (apiPort > 0) {
275
+ const ok = await verifyController(apiPort, secret);
276
+ if (!ok) {
277
+ throw new Error(
278
+ `显式指定的控制端口 127.0.0.1:${apiPort} 验证失败(secret ${secret ? "已提供" : "为空"})`,
279
+ );
280
+ }
281
+ return [
282
+ { port: apiPort, secret, clientName: "mihomo", source: "手动指定" },
283
+ ];
284
+ }
285
+
286
+ // 2) 已知配置文件探测(按优先级顺序)
287
+ const candidates = [];
288
+ const pathList = [
289
+ ...CONFIG_PATHS,
290
+ ...extraPaths.map((p) => ({ client: "自定义", path: p })),
291
+ ];
292
+ for (const entry of pathList) {
293
+ let cfg = null;
294
+ try {
295
+ if (!existsSync(entry.path)) continue;
296
+ cfg = parseFlatYaml(readFileSync(entry.path, "utf-8"));
297
+ } catch (e) {
298
+ console.error(`[discover] 读取配置失败 ${entry.path}: ${e.message}`);
299
+ continue;
300
+ }
301
+ const port = parseController(cfg["external-controller"]);
302
+ if (!port) continue;
303
+ const fileSecret =
304
+ typeof cfg.secret === "string" && cfg.secret ? cfg.secret : "";
305
+ const okWithSecret = await verifyController(port, fileSecret);
306
+ const okEmpty = fileSecret ? false : await verifyController(port, "");
307
+ if (!okWithSecret && !okEmpty) continue;
308
+ candidates.push({
309
+ port,
310
+ secret: okWithSecret ? fileSecret : "",
311
+ clientName: entry.client,
312
+ source: `配置文件(${entry.path})`,
313
+ });
314
+ }
315
+
316
+ // 3) 端口枚举兜底(配置文件一无所获时)
317
+ if (!candidates.length) {
318
+ const probes = listListeningPorts().map(async (port) => {
319
+ const r = await probeVersion(port, "", 2000);
320
+ if (r?.signature !== "unauthorized") return null;
321
+ if (await verifyController(port, "")) return port;
322
+ return null;
323
+ });
324
+ const results = await Promise.all(probes);
325
+ const port = results.find((p) => p !== null);
326
+ if (port) {
327
+ candidates.push({
328
+ port,
329
+ secret: "",
330
+ clientName: "mihomo",
331
+ source: `端口探测(${port})`,
332
+ });
333
+ }
334
+ }
335
+
336
+ return candidates;
337
+ }
338
+
339
+ /** 回退默认条目(决策 5A,向后兼容) */
340
+ function _fallbackEntry() {
341
+ return {
342
+ port: DISCOVER_DEFAULTS.port,
343
+ secret: DISCOVER_DEFAULTS.secret,
344
+ proxyPort: DISCOVER_DEFAULTS.proxyPort,
345
+ proxyUrl: `http://127.0.0.1:${DISCOVER_DEFAULTS.proxyPort}`,
346
+ clientName: "Clash Verge",
347
+ source: "未发现可用控制端",
348
+ fallback: true,
349
+ };
350
+ }
351
+
352
+ /**
353
+ * 为每个候选解析代理端口,并按 system-proxy 命中排序(命中排最前,主控制端)
354
+ * 稳定排序:未命中的候选保持发现顺序(Clash Verge 优先)
355
+ */
356
+ async function _finalizeCandidates(candidates) {
357
+ const sysPorts = getSystemProxyPorts();
358
+ const out = [];
359
+ for (const c of candidates) {
360
+ const mp = await getMixedPort(c.port, c.secret);
361
+ const proxyPort = mp || DISCOVER_DEFAULTS.proxyPort;
362
+ out.push({ ...c, proxyPort, proxyUrl: `http://127.0.0.1:${proxyPort}` });
363
+ }
364
+ return out.sort((a, b) => {
365
+ const am = sysPorts.includes(a.proxyPort) ? 0 : 1;
366
+ const bm = sysPorts.includes(b.proxyPort) ? 0 : 1;
367
+ return am - bm;
368
+ });
369
+ }
370
+
371
+ /**
372
+ * 自动发现本机 mihomo 控制端(单选,保持原有选择策略语义)
373
+ * @param {object} [options]
374
+ * @param {number} [options.apiPort] 显式控制端口(CLI)
375
+ * @param {string} [options.secret] 显式 secret(CLI)
376
+ * @param {string} [options.selection] 选择策略 system-proxy / preference / first-alive
377
+ * @param {string[]} [options.configPaths] 追加配置路径
378
+ * @returns {Promise<{port,secret,proxyUrl,proxyPort,clientName,source,fallback}>}
379
+ * @throws 显式 apiPort 验证失败时抛错(不回退,便于排障)
380
+ */
381
+ export async function discover(options = {}) {
382
+ const user = readUserConfig();
383
+ const apiPort = Number(options.apiPort ?? user.apiPort ?? 0) || 0;
384
+ const secret = options.secret ?? user.secret ?? "";
385
+ const selection =
386
+ options.selection || user.selection || DISCOVER_DEFAULTS.selection;
387
+ const extraPaths = [
388
+ ...(Array.isArray(user.configPaths) ? user.configPaths : []),
389
+ ...(Array.isArray(options.configPaths) ? options.configPaths : []),
390
+ ];
391
+
392
+ if (!apiPort && _cache) return _cache;
393
+
394
+ const candidates = await _collectCandidates({ apiPort, secret, extraPaths });
395
+
396
+ // 多候选选择
397
+ let chosen = null;
398
+ if (candidates.length === 1) {
399
+ chosen = candidates[0];
400
+ } else if (candidates.length > 1) {
401
+ chosen = await _selectCandidate(candidates, selection);
402
+ }
403
+
404
+ // 全失败 → 回退旧默认值(不缓存,等待期重试可重新探测)
405
+ if (!chosen) return _fallbackEntry();
406
+
407
+ const mixedPort = await getMixedPort(chosen.port, chosen.secret);
408
+ chosen.proxyPort = mixedPort || DISCOVER_DEFAULTS.proxyPort;
409
+ chosen.proxyUrl = `http://127.0.0.1:${chosen.proxyPort}`;
410
+ return _cacheResult(chosen);
411
+ }
412
+
413
+ /**
414
+ * 自动发现本机全部 mihomo 控制端(多控制端模式)
415
+ * 返回按 system-proxy 命中排序的控制器列表,每个元素含
416
+ * {port, secret, proxyPort, proxyUrl, clientName, source, fallback}
417
+ * 全部失败时返回单条回退默认条目(与 discover 一致)
418
+ */
419
+ export async function discoverAll(options = {}) {
420
+ const user = readUserConfig();
421
+ const apiPort = Number(options.apiPort ?? user.apiPort ?? 0) || 0;
422
+ const secret = options.secret ?? user.secret ?? "";
423
+ const extraPaths = [
424
+ ...(Array.isArray(user.configPaths) ? user.configPaths : []),
425
+ ...(Array.isArray(options.configPaths) ? options.configPaths : []),
426
+ ];
427
+
428
+ const candidates = await _collectCandidates({ apiPort, secret, extraPaths });
429
+ if (!candidates.length) return [_fallbackEntry()];
430
+ return _finalizeCandidates(candidates);
431
+ }
432
+
433
+ async function _selectCandidate(candidates, selection) {
434
+ if (selection === "system-proxy") {
435
+ const sysPorts = getSystemProxyPorts();
436
+ if (sysPorts.length) {
437
+ for (const c of candidates) {
438
+ const mp = await getMixedPort(c.port, c.secret);
439
+ if (mp && sysPorts.includes(mp)) return c;
440
+ }
441
+ }
442
+ // 系统代理读取失败或无匹配 → 降级 preference(决策 1A 补充约定)
443
+ }
444
+ // preference 与 first-alive 均按发现顺序(配置路径表已是优先级顺序)
445
+ return candidates[0];
446
+ }
447
+
448
+ function _cacheResult(res) {
449
+ if (!res.fallback) _cache = res;
450
+ return res;
451
+ }
package/src/clash/ipc.js CHANGED
@@ -1,19 +1,55 @@
1
1
  /**
2
- * 文件 IPC 模块
2
+ * 文件 IPC 模块(支持按代理端口分通道)
3
3
  *
4
4
  * 与 watchdog 进程间通信,通过 tmpdir 下的 JSON 文件。
5
5
  *
6
- * clash-health.json — daemon → watchdog(代理健康状态 + 运行元数据)
7
- * clash-cmd.json — watchdog → daemon(远程命令)
6
+ * 单通道(默认,port=0,向后兼容):
7
+ * clash-health.json — daemon → watchdog(代理健康状态 + 运行元数据)
8
+ * clash-cmd.json — 调用方 → daemon(远程命令)
9
+ * 多通道(port>0,多客户端场景,按代理端口区分):
10
+ * clash-health-{port}.json / clash-cmd-{port}.json
8
11
  */
9
- import { readFileSync, writeFileSync, existsSync } from "fs";
12
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
10
13
  import { join } from "path";
11
14
  import { tmpdir } from "os";
12
15
 
13
16
  const HEALTH_FILE = join(tmpdir(), "clash-health.json");
14
17
  const CMD_FILE = join(tmpdir(), "clash-cmd.json");
15
18
 
16
- // ---- health(daemon → watchdog) ----
19
+ /** 按通道端口计算文件路径(0 = 默认单通道) */
20
+ function channelFile(base, port) {
21
+ if (!port) return base;
22
+ return base.replace(/\.json$/, `-${port}.json`);
23
+ }
24
+
25
+ /** 列出所有已存在的命令通道端口(含 0=默认通道) */
26
+ export function listCommandChannels() {
27
+ try {
28
+ return readdirSync(tmpdir())
29
+ .filter((n) => n === "clash-cmd.json" || /^clash-cmd-\d+\.json$/.test(n))
30
+ .map((n) =>
31
+ n === "clash-cmd.json"
32
+ ? 0
33
+ : parseInt(n.match(/-(\d+)\.json$/)?.[1] || "0", 10),
34
+ );
35
+ } catch {
36
+ return [0];
37
+ }
38
+ }
39
+
40
+ /** 列出所有已存在的健康通道端口(不含默认通道 0,供看门狗上报多控制端状态) */
41
+ export function listHealthChannels() {
42
+ try {
43
+ return readdirSync(tmpdir())
44
+ .filter((n) => /^clash-health-\d+\.json$/.test(n))
45
+ .map((n) => parseInt(n.match(/-(\d+)\.json$/)?.[1] || "0", 10))
46
+ .filter((p) => p > 0);
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ // ---- health(daemon → watchdog / 调用方回执) ----
17
53
 
18
54
  /**
19
55
  * 写入健康状态到 IPC 文件
@@ -27,11 +63,12 @@ const CMD_FILE = join(tmpdir(), "clash-cmd.json");
27
63
  * @param {string} [state.error] - 错误信息
28
64
  * @param {string} [state.lastSwitch] - 最近一次切换记录
29
65
  * @param {object} [state.meta] - 运行元数据(groupName, proxyUrl, testUrl, healthyNodesCount 等)
66
+ * @param {number} [port] - 通道端口(0=默认单通道)
30
67
  */
31
- export function writeHealth(state) {
68
+ export function writeHealth(state, port = 0) {
32
69
  try {
33
70
  writeFileSync(
34
- HEALTH_FILE,
71
+ channelFile(HEALTH_FILE, port),
35
72
  JSON.stringify(
36
73
  {
37
74
  ...state,
@@ -47,10 +84,11 @@ export function writeHealth(state) {
47
84
  }
48
85
  }
49
86
 
50
- export function readHealth() {
87
+ export function readHealth(port = 0) {
51
88
  try {
52
- if (!existsSync(HEALTH_FILE)) return null;
53
- const data = JSON.parse(readFileSync(HEALTH_FILE, "utf-8"));
89
+ const file = channelFile(HEALTH_FILE, port);
90
+ if (!existsSync(file)) return null;
91
+ const data = JSON.parse(readFileSync(file, "utf-8"));
54
92
  const age = Date.now() - new Date(data.lastCheck || 0).getTime();
55
93
  return {
56
94
  ...data,
@@ -62,26 +100,32 @@ export function readHealth() {
62
100
  }
63
101
  }
64
102
 
65
- // ---- command(watchdog → daemon) ----
103
+ // ---- command(调用方/watchdog → daemon) ----
66
104
 
67
105
  /**
68
- * 写入命令(watchdog 调用,daemon 轮询读取)
106
+ * 写入命令(watchdog/业务进程调用,daemon 轮询读取)
69
107
  * 支持单条对象或命令数组,统一按数组存储,daemon 一次处理一批
70
- * @param {object|object[]} cmd - 命令对象或命令数组,如 { action: "setCountryFilter", countries: ["US"] }
108
+ * @param {object|object[]} cmd - 命令对象或命令数组,如 { action: "failover", reason: "...", proxyPort: 7890 }
109
+ * @param {number} [port] - 通道端口(0=默认单通道;多客户端时按代理端口写入对应通道)
71
110
  */
72
- export function writeCommand(cmd) {
111
+ export function writeCommand(cmd, port = 0) {
73
112
  try {
74
113
  const payload = Array.isArray(cmd) ? cmd : [cmd];
75
- writeFileSync(CMD_FILE, JSON.stringify(payload, null, 2), "utf-8");
114
+ writeFileSync(
115
+ channelFile(CMD_FILE, port),
116
+ JSON.stringify(payload, null, 2),
117
+ "utf-8",
118
+ );
76
119
  } catch {
77
120
  /* 非关键路径 */
78
121
  }
79
122
  }
80
123
 
81
- export function readCommand() {
124
+ export function readCommand(port = 0) {
82
125
  try {
83
- if (!existsSync(CMD_FILE)) return null;
84
- const text = readFileSync(CMD_FILE, "utf-8").trim();
126
+ const file = channelFile(CMD_FILE, port);
127
+ if (!existsSync(file)) return null;
128
+ const text = readFileSync(file, "utf-8").trim();
85
129
  if (!text || text === "{}" || text === "[]") return null;
86
130
  const parsed = JSON.parse(text);
87
131
  return Array.isArray(parsed) ? parsed : [parsed];
@@ -90,8 +134,9 @@ export function readCommand() {
90
134
  }
91
135
  }
92
136
 
93
- export function clearCommand() {
137
+ export function clearCommand(port = 0) {
94
138
  try {
95
- if (existsSync(CMD_FILE)) writeFileSync(CMD_FILE, "{}", "utf-8");
139
+ const file = channelFile(CMD_FILE, port);
140
+ if (existsSync(file)) writeFileSync(file, "{}", "utf-8");
96
141
  } catch {}
97
142
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * clash-daemon CLI 命令入口
3
3
  *
4
- * 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US] [--dry-run]
4
+ * 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US]
5
+ * [--api-port <端口>] [--secret <secret>] [--selection <策略>] [--config-path <路径>] [--dry-run]
6
+ * 控制端(端口/secret)默认自动发现,显式参数优先于自动发现
5
7
  */
6
8
  import { ClashDaemon } from "../clash/daemon.js";
7
9
 
@@ -14,6 +16,13 @@ export async function handleClashDaemon(options) {
14
16
  options["dry-run"] === "" ||
15
17
  options["dry-run"] === true ||
16
18
  process.argv.includes("--dry-run"),
19
+ apiPort: parseInt(options["api-port"]) || undefined,
20
+ secret: options.secret || undefined,
21
+ selection: options.selection || undefined,
22
+ configPaths:
23
+ Array.isArray(options["config-paths"]) && options["config-paths"].length
24
+ ? options["config-paths"]
25
+ : undefined,
17
26
  });
18
27
  await d.start();
19
28
  }