mslxdff 0.1.174 → 0.1.176

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.
Files changed (117) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +254 -254
  4. package/src/bench/cline-bench.js +42 -42
  5. package/src/bench/probe.js +70 -70
  6. package/src/bench/report.js +162 -162
  7. package/src/bench/runner.js +77 -77
  8. package/src/bench/via-probe.js +124 -124
  9. package/src/bench/via-routes.js +87 -87
  10. package/src/bench/workbuddy-bench.js +54 -54
  11. package/src/chat/engine.js +160 -160
  12. package/src/chat/gateway.js +163 -163
  13. package/src/chat/orchestrator.js +234 -234
  14. package/src/chat/prompt.js +70 -70
  15. package/src/chat/repl.js +88 -88
  16. package/src/chat/terminal.js +135 -135
  17. package/src/chat/tools.js +306 -306
  18. package/src/chat-pipeline/auto-race.js +26 -1
  19. package/src/chat-pipeline/index.js +131 -131
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/chat-pipeline/serial-trial.js +35 -8
  22. package/src/chat-pipeline/terminal-verdict.js +109 -0
  23. package/src/cli/commands/group.js +249 -249
  24. package/src/cli/commands/model/picks.js +50 -50
  25. package/src/cli/commands/provider/bench-via.js +247 -247
  26. package/src/cli/commands/provider/bench.js +141 -141
  27. package/src/cli/commands/provider/index.js +136 -133
  28. package/src/cli/commands/provider/keys-guard.js +50 -0
  29. package/src/cli/commands/provider/keys.js +7 -0
  30. package/src/cli/commands/provider/models.js +145 -145
  31. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  32. package/src/cli/commands/provider/traework-checkin.js +144 -0
  33. package/src/cli/commands/provider/traework-login.js +7 -45
  34. package/src/cli/commands/sync.js +310 -310
  35. package/src/cli/help.js +3 -0
  36. package/src/cli/status.js +279 -279
  37. package/src/daemon.js +112 -112
  38. package/src/model-capabilities/enrich.js +86 -86
  39. package/src/model-capabilities/index.js +183 -183
  40. package/src/model-capabilities/parse.js +70 -70
  41. package/src/model-trace.js +2 -0
  42. package/src/models.js +225 -225
  43. package/src/providers/cline/auth.js +228 -228
  44. package/src/providers/cline/chat.js +307 -307
  45. package/src/providers/cline.js +2 -2
  46. package/src/providers/codearts/chat.js +7 -1
  47. package/src/providers/codearts/const.js +6 -1
  48. package/src/providers/codearts/index.js +18 -11
  49. package/src/providers/globalqwenwork/cosy.js +123 -123
  50. package/src/providers/globalqwenwork/crypto.js +218 -218
  51. package/src/providers/globalqwenwork/http.js +20 -20
  52. package/src/providers/globalqwenwork/rsa.js +54 -54
  53. package/src/providers/globalqwenwork/sse.js +268 -268
  54. package/src/providers/globalqwenwork/stream.js +130 -130
  55. package/src/providers/globalqwenwork/upstream.js +122 -122
  56. package/src/providers/keyring.js +60 -60
  57. package/src/providers/qoder/chat.js +203 -203
  58. package/src/providers/qoder/index.js +249 -249
  59. package/src/providers/qoder/sse.js +145 -145
  60. package/src/providers/qwenwork/account-store.js +133 -133
  61. package/src/providers/qwenwork/constants.js +67 -67
  62. package/src/providers/qwenwork/cosy.js +120 -120
  63. package/src/providers/qwenwork/crypto.js +218 -218
  64. package/src/providers/qwenwork/http.js +20 -20
  65. package/src/providers/qwenwork/index.js +327 -327
  66. package/src/providers/qwenwork/payload.js +144 -144
  67. package/src/providers/qwenwork/rsa.js +54 -54
  68. package/src/providers/qwenwork/sse.js +268 -268
  69. package/src/providers/qwenwork/stream.js +130 -130
  70. package/src/providers/qwenwork/upstream.js +120 -120
  71. package/src/providers/qwenwork.js +1 -1
  72. package/src/providers/registry.js +79 -79
  73. package/src/providers/traework/account-store.js +87 -2
  74. package/src/providers/traework/checkin.js +51 -9
  75. package/src/providers/traework/constants.js +20 -0
  76. package/src/providers/traework/headers.js +42 -10
  77. package/src/providers/traework/index.js +14 -4
  78. package/src/providers/traework/market-identity.js +116 -0
  79. package/src/providers/workbuddy/chat.js +248 -248
  80. package/src/providers/workbuddy/reshape.js +152 -152
  81. package/src/providers/workbuddy.js +2 -2
  82. package/src/reasoning.js +32 -32
  83. package/src/routes/chat/gateway.js +46 -46
  84. package/src/routes/chat/relay-pipeline.js +294 -294
  85. package/src/routes/chat/via-route-handler.js +146 -146
  86. package/src/routes/hedge.js +255 -255
  87. package/src/routes/models-route.js +167 -167
  88. package/src/routes/peers.js +273 -273
  89. package/src/routes/stream.js +385 -385
  90. package/src/runtime/bootstrap.js +50 -50
  91. package/src/runtime/provider-gate.js +36 -36
  92. package/src/runtime/providers-setup.js +168 -165
  93. package/src/runtime/traework-checkin.js +26 -11
  94. package/src/server.js +69 -69
  95. package/src/state/schemas/allowlist.js +92 -92
  96. package/src/sync-opencode.js +280 -280
  97. package/src/transport/index.js +244 -244
  98. package/src/transport/pool.js +56 -56
  99. package/src/transport/retry.js +24 -24
  100. package/src/transport/sse.js +93 -93
  101. package/src/upstream-probe/display.js +52 -52
  102. package/src/upstream-probe/probe.js +49 -49
  103. package/src/upstream-probe/rotate.js +110 -110
  104. package/src/upstream-probe/start.js +45 -45
  105. package/src/upstream.js +291 -289
  106. package/src/warp-pool/config.js +71 -0
  107. package/src/warp-pool/health.js +65 -0
  108. package/src/warp-pool/index.js +261 -0
  109. package/src/warp-pool/keys.js +29 -0
  110. package/src/warp-pool/nodes.js +94 -0
  111. package/src/warp-pool/register.js +113 -0
  112. package/src/warp-pool/rotate.js +150 -0
  113. package/src/warp-pool/strategy.js +43 -0
  114. package/src/warp-pool/wireproxy.js +153 -0
  115. package/docs/cli_help_mini.md +0 -143
  116. package/src/providers/AGENTS.md +0 -49
  117. package/src/routes/AGENTS.md +0 -37
@@ -0,0 +1,109 @@
1
+ // 终局上游判决分类器(纯函数,零 IO):上游把「这个模型你别想用」写死在错误里时的判据。
2
+ //
3
+ // 立此判据的现网取证(~/.config/mslxdff/deepseek-v4.1-flash-free.log,req=mv9m1d2v-n2y5):
4
+ // 22:04:41 上游 6s 就回 403 FreeTierError("OpenCode's free tier can only be used from within OpenCode"),
5
+ // 网关仍把这条拒绝继续丢给 4 个组员接力 → 组员撞的是同一堵墙(组员 149.13.91.10 回**同一个**
6
+ // FreeTierError,其余是 30s abort / fetch failed),客户端 22:05:46 才拿到 502 "peers failed",
7
+ // total=72752ms——真相(403 + 原文)被接力产物彻底埋掉,人只看到「一直转圈圈」。
8
+ //
9
+ // 边界(重要):只判「换节点也不会变」的拒绝。一切可能随号/随额度/随时间恢复的都**不算**终局,
10
+ // 接力与 failover 照常:401/402/429/5xx、fetch 异常,以及坏号型 403(zcode `security_reject`
11
+ // 「安全校验拒绝」、qoder 限流裹 403、`isQueued`/`serviceAvailable` 这类换号就能过的)。
12
+ // 本仓自己的 allowlist 403 在 serial-trial 更前面已短路,不经此处。
13
+ //
14
+ // ⚠ 喂进来的文本必须是**未截成 400 字符**的原文(调用方给 ≤4KB):TRANSIENT 护栏是唯一的反向刹车,
15
+ // 它命中的字样若落在截断之外,就会把「换个组员可能就能用」的失败误判成终局、静默停掉组网兜底
16
+ // (多 provider 明细体如 OpenRouter 的 `metadata.raw[]` 正是这种排列)。
17
+
18
+ // 上游身份/资格闸门:文案是具名判决(FreeTierError),4xx 任意码都算终局——不锁死 403,
19
+ // 防上游把同一判决挪到 400/404 就失明。
20
+ const FREE_TIER_PATTERNS = [
21
+ /FreeTierError/i,
22
+ /free tier can only be used/i,
23
+ /免费(?:层|额度).{0,12}(?:只能|仅限|不可)/,
24
+ ];
25
+
26
+ // 「模型不存在/不可用」:每条 pattern 自身就含 model 语义,故不再另设 `\bmodel\b` 独立单词门
27
+ // (那道门既挡死自己的 `model_not_found`/`ProviderModelNotFoundError`,又被 `"param":"model"`
28
+ // 这类 JSON 键名随手推开,纯属自相矛盾的假安全)。中英文都收:中文上游目录只有中文文案。
29
+ const MODEL_GONE_PATTERNS = [
30
+ /model not found/i,
31
+ /providermodelnotfounderror/i,
32
+ /model is unavailable/i,
33
+ /model_not_found/i,
34
+ /no such model/i,
35
+ /unknown model/i,
36
+ /requested model\b[\s\S]{0,60}\b(not available|not found|does not exist|is not supported)/i,
37
+ /模型(?:不存在|未找到|不可用|已下线|已下架|无法访问)/,
38
+ /(?:没有|无)权限访问(?:该|此)?模型/,
39
+ ];
40
+
41
+ // 反向护栏:形状像终局但语义是「这个号/这一发不行」,换号换节点可能就好——绝不终局。
42
+ // 只认明确凭据/限流语义词,不收裸 `token`:`max_tokens` 之类的参数报错会被它误判成可接力。
43
+ const TRANSIENT_MARKERS = [
44
+ /security_reject/i,
45
+ /安全校验拒绝/,
46
+ /isQueued/i,
47
+ /serviceAvailable["\s:]*false/i,
48
+ /rate.?limit|too many requests|over.?quota|retry after/i,
49
+ /invalid (api )?key|missing api key|unauthorized|expired (token|key)|no credentials|鉴权失败|无有效(?:账号|凭据|key)/i,
50
+ ];
51
+
52
+ /**
53
+ * 有界读一份失败响应的正文供判决(串行 trial 与 auto 竞速两条出口共用同一口径):
54
+ * 1s 上限防流式错误体拖慢、≤4KB 防大 body 把正则拖成长扫描,读不到就返回空串——
55
+ * 判据见空串一律**不判终局**(保守回退到「照跑接力」的旧行为,绝不因读不到就停兜底)。
56
+ * 取的是 `clone()`:原响应必须留给收场出口 relay 原字节。
57
+ */
58
+ export async function readVerdictText(res, { timeoutMs = 1000, maxChars = 4000 } = {}) {
59
+ try {
60
+ return String(await Promise.race([
61
+ res.clone().text(),
62
+ new Promise((r) => { const t = setTimeout(() => r(""), timeoutMs); t.unref?.(); }),
63
+ ])).slice(0, maxChars);
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ function firstHit(list, text) {
70
+ for (const re of list) if (re.test(text)) return re.source;
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * 上游这条失败响应是不是「换节点也不会变」的终局判决。
76
+ * @param {number} status 上游 HTTP 状态
77
+ * @param {string} bodyText 上游错误体原文(≤4KB,未被日志摘要截成 400 字的那份;见文件头 ⚠)
78
+ * @returns {{terminal: boolean, kind?: string, marker?: string, notice?: string}}
79
+ */
80
+ export function classifyTerminalUpstream(status, bodyText = "") {
81
+ const code = Number(status);
82
+ const text = typeof bodyText === "string" ? bodyText : "";
83
+ if (!Number.isInteger(code) || code < 400 || code > 499 || !text.trim()) return { terminal: false };
84
+ // 顺序即优先级:① 先认「上游把免费层说死」——它的文案里也可能带 quota/limit 字样,反过来排会
85
+ // 把终局判成可接力;② 再排坏号/凭据/限流类可恢复拒绝;③ 最后才判模型 id 不存在。
86
+ const freeHit = firstHit(FREE_TIER_PATTERNS, text);
87
+ if (freeHit) {
88
+ return { terminal: true, kind: "free-tier-blocked", marker: freeHit, notice: "上游免费层资格判决(组员同源),已跳过接力" };
89
+ }
90
+ if (firstHit(TRANSIENT_MARKERS, text)) return { terminal: false };
91
+ const goneHit = firstHit(MODEL_GONE_PATTERNS, text);
92
+ if (goneHit) {
93
+ return { terminal: true, kind: "model-gone", marker: goneHit, notice: `上游不认这个模型 id(status=${code}),已跳过接力` };
94
+ }
95
+ return { terminal: false };
96
+ }
97
+
98
+ /**
99
+ * 逃生阀:`MSLXDFF_TERMINAL_SHORTCIRCUIT=0`(含 `false`/`off`/`no`)关掉终局短路,
100
+ * 恢复「任何失败都照跑组员接力」的旧行为。缺省=开(终局判决不接力)。
101
+ * 上游哪天改了文案导致误判/漏判时不停机回退(同 `MSLXDFF_FREE_LANE=0` 的先例:开关方向与名字同向,
102
+ * 别再写一个「设 1 表示关掉」的反向旋钮)。
103
+ */
104
+ export function terminalShortcircuitOff(env = process.env) {
105
+ const raw = env.MSLXDFF_TERMINAL_SHORTCIRCUIT;
106
+ if (raw === undefined || raw === null || raw === "") return false;
107
+ const s = String(raw).trim().toLowerCase();
108
+ return s === "0" || s === "false" || s === "off" || s === "no";
109
+ }
@@ -1,249 +1,249 @@
1
- import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../../groups.js";
2
- import { createPeersService } from "../../peers.js";
3
- import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../../state.js";
4
- import { groupIs, markJoined, probeHealth, syncAllJoinedGroups } from "../group-helpers.js";
5
- import { joinGroupCore } from "../join-core.js";
6
- import { errMsg } from "../util.js";
7
- import { argValue } from "../policy.js";
8
- import { compatFetch, timeoutSignal } from "../../compat.js";
9
-
10
- export async function handleGroupCreate(args) {
11
- const createGroupArg = argValue(args, "-creategroup", "--creategroup") || groupIs("create", args);
12
- if (!createGroupArg) return false;
13
- const groups = createGroupsService({});
14
- const peers = createPeersService({});
15
- const name = createGroupArg;
16
- const { created } = groups.create(name);
17
- markJoined({ name, leaderUrl: "", myUrl: "", memberName: "leader" });
18
- const synced = await syncAllJoinedGroups({ peers, groups });
19
- const s = synced.find((x) => x.name === name);
20
- console.log(created ? `group created: ${name}` : `group already exists: ${name}`);
21
- console.log(`members on this node: ${s ? `${s.total} (failover: ${s.added})` : "?"}`);
22
- console.log(`others join with: mslxdff -addtogroup <this-node-host> ${name}`);
23
- process.exit(0);
24
- }
25
-
26
- export async function handleGroupCommand(args) {
27
- const groupCmd = argValue(args, "-group", "--group");
28
- if (!groupCmd || groupCmd === "create") return false;
29
- const groups = createGroupsService({});
30
- const peers = createPeersService({});
31
- const rest = args.slice(args.indexOf("-group") + 1);
32
- const [action, a] = rest;
33
- if (action === "sync") {
34
- const synced = await syncAllJoinedGroups({ peers, groups });
35
- if (!synced.length) {
36
- console.log("not joined to any group (use -addtogroup or -creategroup)");
37
- } else {
38
- for (const s of synced) {
39
- if (s.error) console.log(`${s.name}: sync failed — ${s.error}`);
40
- else console.log(`${s.name}: ${s.total} member(s), ${s.added} failover target(s) configured`);
41
- }
42
- }
43
- } else if (action === "leave" && a) {
44
- const before = loadGroupsJoined().filter((g) => g.name === a).length;
45
- saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== a));
46
- const removed = peers.removeByGroup(a);
47
- console.log(before ? `left group "${a}" (${removed} member(s) removed)` : `not a member of group "${a}"`);
48
- } else if (action === "list") {
49
- const joinedList = loadGroupsJoined();
50
- if (!joinedList.length) {
51
- console.log("no groups on this node");
52
- process.exit(0);
53
- }
54
- const { token } = await loadToken();
55
- const fetchImpl = (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(1500) });
56
- for (const g of joinedList) {
57
- const isLeader = !g.leaderUrl;
58
- let members;
59
- if (isLeader) {
60
- members = groups.list()[g.name]?.members || {};
61
- } else {
62
- try {
63
- members = await refreshGroupMembers(g.name, {
64
- leaderUrl: g.leaderUrl,
65
- memberName: g.memberName,
66
- url: g.myUrl,
67
- token,
68
- fetchImpl,
69
- });
70
- } catch (err) {
71
- console.log(`${g.name} (members unavailable — leader unreachable: ${errMsg(err)})`);
72
- continue;
73
- }
74
- }
75
- const entries = Object.entries(members).filter(([id]) => id !== "leader");
76
- console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
77
- const probes = await Promise.all(
78
- entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
79
- );
80
- const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
81
- const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
82
- const display = leaderProbe ? [...probes, leaderProbe] : probes;
83
- let seq = 0;
84
- for (const r of display) {
85
- const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
86
- const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
87
- if (isBb) {
88
- const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
89
- const ip = r.publicIp || m?.publicIp || "?";
90
- const via = r.stale ? "stale" : `via leader ${ago}`;
91
- const stateBb = `${via} ip=${ip}`;
92
- if (r.id === "leader") {
93
- console.log(` leader ${r.url} ${stateBb}`);
94
- continue;
95
- }
96
- seq += 1;
97
- const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
98
- console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
99
- continue;
100
- }
101
- const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
102
- if (r.id === "leader") {
103
- console.log(` leader ${r.url} ${state}`);
104
- continue;
105
- }
106
- seq += 1;
107
- const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
108
- console.log(` ${seq}. ${r.url}${label} ${state}`);
109
- }
110
- }
111
- console.log(`\njoined groups (${joinedList.length}):`);
112
- for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
113
- } else if (action === "remove" && a) {
114
- const seq = Number(a);
115
- if (!Number.isInteger(seq) || seq < 1) {
116
- console.error(`usage: mslxdff -group remove <seq>`);
117
- process.exit(1);
118
- }
119
- const joined = loadGroupsJoined().find((g) => !g.leaderUrl);
120
- if (!joined) {
121
- console.error("group remove requires being the leader — this node leads no group");
122
- process.exit(1);
123
- }
124
- const members = groups.list()[joined.name]?.members || {};
125
- const entries = Object.entries(members).filter(([id]) => id !== "leader");
126
- const target = entries[seq - 1];
127
- if (!target) {
128
- console.error(`member #${seq} not found — group "${joined.name}" has ${entries.length} member(s)`);
129
- process.exit(1);
130
- }
131
- const [id, m] = target;
132
- try {
133
- const removed = groups.removeMember(joined.name, { url: m.url });
134
- if (removed) console.log(`removed ${m.url} from "${joined.name}"`);
135
- else console.log(`member ${m.url} already gone from "${joined.name}"`);
136
- } catch (err) {
137
- console.error(`remove failed: ${errMsg(err)}`);
138
- process.exit(1);
139
- }
140
- } else {
141
- console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
142
- }
143
- process.exit(0);
144
- }
145
-
146
- export async function handleAddToGroup(args) {
147
- const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
148
- if (addToGroupIdx < 0) return false;
149
- const rawArgs = args.slice(addToGroupIdx + 1);
150
- const isBroadband = rawArgs.includes("--broadband");
151
- const filtered = rawArgs.filter((a) => a !== "--broadband");
152
- const [leaderHost, name] = filtered;
153
- if (filtered.length > 2) {
154
- console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
155
- process.exit(1);
156
- }
157
- if (!leaderHost || !name) {
158
- // 缺参数 → 手机宽带接入向导(默认 broadband:手机无公网入站,只出站)
159
- const { runJoinWizard } = await import("../join-wizard.js");
160
- const r = await runJoinWizard({ leaderInput: leaderHost || null, groupName: name || null });
161
- process.exit(r.ok ? 0 : 1);
162
- }
163
- const r = await joinGroupCore({ leaderHost, name, isBroadband, args });
164
- if (!r.ok) {
165
- console.error(`join failed: ${r.error}`);
166
- process.exit(1);
167
- }
168
- console.log(`joined group "${name}" at ${r.leaderUrl}${isBroadband ? " [broadband]" : ""}`);
169
- if (r.syncError) console.log(` local failover setup failed: ${r.syncError}`);
170
- else console.log(` ${r.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
171
- process.exit(0);
172
- }
173
-
174
- export async function handleResetBan(args) {
175
- const resetBanArg = argValue(args, "-resetban", "--resetban");
176
- if (resetBanArg === null && !args.includes("-resetban") && !args.includes("--resetban")) return false;
177
- const bans = createBansService({});
178
- const ip = resetBanArg || null;
179
- bans.clear(ip || undefined);
180
- console.log(ip ? `ban cleared for ${ip}` : "all bans cleared");
181
- process.exit(0);
182
- }
183
-
184
- export async function handleLeaveGroup(args) {
185
- const leaveAll = args.includes("-leavegroup") || args.includes("--leavegroup") || args.includes("-leave-groups");
186
- if (!leaveAll) return false;
187
- const peers = createPeersService({});
188
- const joined = loadGroupsJoined();
189
- if (!joined.length) {
190
- console.log("not joined to any group");
191
- process.exit(0);
192
- }
193
- const myToken = (await loadToken()).token;
194
- const leaders = [];
195
- for (const g of joined) {
196
- if (g.leaderUrl) {
197
- const peersRemoved = peers.removeByGroup(g.name);
198
- try {
199
- const res = await compatFetch(`${g.leaderUrl}/v1/groups/leave`, {
200
- method: "POST",
201
- headers: {
202
- "Content-Type": "application/json",
203
- "Authorization": `Bearer ${myToken}`,
204
- },
205
- body: JSON.stringify({ name: g.name }),
206
- });
207
- if (res.ok) console.log(`${g.name}: left (deregistered from ${g.leaderUrl})`);
208
- else console.log(`${g.name}: left locally (leader said: ${await res.text().catch(() => `HTTP ${res.status}`)})`);
209
- } catch (err) {
210
- console.log(`${g.name}: left locally (leader unreachable: ${errMsg(err)})`);
211
- }
212
- } else {
213
- leaders.push(g.name);
214
- }
215
- }
216
- const left = joined.filter((g) => g.leaderUrl).map((g) => g.name);
217
- saveGroupsJoined(loadGroupsJoined().filter((g) => !left.includes(g.name)));
218
- console.log(`left ${left.length} group(s)`);
219
- if (leaders.length) {
220
- console.log(`\nskipped ${leaders.length} group(s) where this node is the leader:`);
221
- for (const n of leaders) console.log(` ${n} — leaders can't leave; disband it with: mslxdff -delgroup ${n}`);
222
- }
223
- process.exit(0);
224
- }
225
-
226
- export async function handleDelGroup(args) {
227
- const delGroupName = argValue(args, "-delgroup", "--delgroup");
228
- if (!delGroupName) return false;
229
- const groups = createGroupsService({});
230
- const peers = createPeersService({});
231
- const local = groups.list()[delGroupName];
232
- if (!local) {
233
- const joined0 = loadGroupsJoined().find((g) => g.name === delGroupName);
234
- if (joined0?.leaderUrl) {
235
- console.log(`"${delGroupName}" is led by ${joined0.leaderUrl} — you are a member, use -leavegroup to leave it`);
236
- } else if (joined0) {
237
- console.log(`"${delGroupName}" exists in local state but has no group definition — nothing to delete`);
238
- } else {
239
- console.log(`group "${delGroupName}" not found on this node`);
240
- }
241
- process.exit(1);
242
- }
243
- const disbanded = groups.delete(delGroupName);
244
- const members = Object.values(disbanded?.members || {});
245
- peers.removeByGroup(delGroupName);
246
- saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== delGroupName));
247
- console.log(`group "${delGroupName}" disbanded (${members.length} member${members.length === 1 ? "" : "s"} removed)`);
248
- process.exit(0);
249
- }
1
+ import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../../groups.js";
2
+ import { createPeersService } from "../../peers.js";
3
+ import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../../state.js";
4
+ import { groupIs, markJoined, probeHealth, syncAllJoinedGroups } from "../group-helpers.js";
5
+ import { joinGroupCore } from "../join-core.js";
6
+ import { errMsg } from "../util.js";
7
+ import { argValue } from "../policy.js";
8
+ import { compatFetch, timeoutSignal } from "../../compat.js";
9
+
10
+ export async function handleGroupCreate(args) {
11
+ const createGroupArg = argValue(args, "-creategroup", "--creategroup") || groupIs("create", args);
12
+ if (!createGroupArg) return false;
13
+ const groups = createGroupsService({});
14
+ const peers = createPeersService({});
15
+ const name = createGroupArg;
16
+ const { created } = groups.create(name);
17
+ markJoined({ name, leaderUrl: "", myUrl: "", memberName: "leader" });
18
+ const synced = await syncAllJoinedGroups({ peers, groups });
19
+ const s = synced.find((x) => x.name === name);
20
+ console.log(created ? `group created: ${name}` : `group already exists: ${name}`);
21
+ console.log(`members on this node: ${s ? `${s.total} (failover: ${s.added})` : "?"}`);
22
+ console.log(`others join with: mslxdff -addtogroup <this-node-host> ${name}`);
23
+ process.exit(0);
24
+ }
25
+
26
+ export async function handleGroupCommand(args) {
27
+ const groupCmd = argValue(args, "-group", "--group");
28
+ if (!groupCmd || groupCmd === "create") return false;
29
+ const groups = createGroupsService({});
30
+ const peers = createPeersService({});
31
+ const rest = args.slice(args.indexOf("-group") + 1);
32
+ const [action, a] = rest;
33
+ if (action === "sync") {
34
+ const synced = await syncAllJoinedGroups({ peers, groups });
35
+ if (!synced.length) {
36
+ console.log("not joined to any group (use -addtogroup or -creategroup)");
37
+ } else {
38
+ for (const s of synced) {
39
+ if (s.error) console.log(`${s.name}: sync failed — ${s.error}`);
40
+ else console.log(`${s.name}: ${s.total} member(s), ${s.added} failover target(s) configured`);
41
+ }
42
+ }
43
+ } else if (action === "leave" && a) {
44
+ const before = loadGroupsJoined().filter((g) => g.name === a).length;
45
+ saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== a));
46
+ const removed = peers.removeByGroup(a);
47
+ console.log(before ? `left group "${a}" (${removed} member(s) removed)` : `not a member of group "${a}"`);
48
+ } else if (action === "list") {
49
+ const joinedList = loadGroupsJoined();
50
+ if (!joinedList.length) {
51
+ console.log("no groups on this node");
52
+ process.exit(0);
53
+ }
54
+ const { token } = await loadToken();
55
+ const fetchImpl = (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(1500) });
56
+ for (const g of joinedList) {
57
+ const isLeader = !g.leaderUrl;
58
+ let members;
59
+ if (isLeader) {
60
+ members = groups.list()[g.name]?.members || {};
61
+ } else {
62
+ try {
63
+ members = await refreshGroupMembers(g.name, {
64
+ leaderUrl: g.leaderUrl,
65
+ memberName: g.memberName,
66
+ url: g.myUrl,
67
+ token,
68
+ fetchImpl,
69
+ });
70
+ } catch (err) {
71
+ console.log(`${g.name} (members unavailable — leader unreachable: ${errMsg(err)})`);
72
+ continue;
73
+ }
74
+ }
75
+ const entries = Object.entries(members).filter(([id]) => id !== "leader");
76
+ console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
77
+ const probes = await Promise.all(
78
+ entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
79
+ );
80
+ const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
81
+ const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
82
+ const display = leaderProbe ? [...probes, leaderProbe] : probes;
83
+ let seq = 0;
84
+ for (const r of display) {
85
+ const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
86
+ const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
87
+ if (isBb) {
88
+ const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
89
+ const ip = r.publicIp || m?.publicIp || "?";
90
+ const via = r.stale ? "stale" : `via leader ${ago}`;
91
+ const stateBb = `${via} ip=${ip}`;
92
+ if (r.id === "leader") {
93
+ console.log(` leader ${r.url} ${stateBb}`);
94
+ continue;
95
+ }
96
+ seq += 1;
97
+ const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
98
+ console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
99
+ continue;
100
+ }
101
+ const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
102
+ if (r.id === "leader") {
103
+ console.log(` leader ${r.url} ${state}`);
104
+ continue;
105
+ }
106
+ seq += 1;
107
+ const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
108
+ console.log(` ${seq}. ${r.url}${label} ${state}`);
109
+ }
110
+ }
111
+ console.log(`\njoined groups (${joinedList.length}):`);
112
+ for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
113
+ } else if (action === "remove" && a) {
114
+ const seq = Number(a);
115
+ if (!Number.isInteger(seq) || seq < 1) {
116
+ console.error(`usage: mslxdff -group remove <seq>`);
117
+ process.exit(1);
118
+ }
119
+ const joined = loadGroupsJoined().find((g) => !g.leaderUrl);
120
+ if (!joined) {
121
+ console.error("group remove requires being the leader — this node leads no group");
122
+ process.exit(1);
123
+ }
124
+ const members = groups.list()[joined.name]?.members || {};
125
+ const entries = Object.entries(members).filter(([id]) => id !== "leader");
126
+ const target = entries[seq - 1];
127
+ if (!target) {
128
+ console.error(`member #${seq} not found — group "${joined.name}" has ${entries.length} member(s)`);
129
+ process.exit(1);
130
+ }
131
+ const [id, m] = target;
132
+ try {
133
+ const removed = groups.removeMember(joined.name, { url: m.url });
134
+ if (removed) console.log(`removed ${m.url} from "${joined.name}"`);
135
+ else console.log(`member ${m.url} already gone from "${joined.name}"`);
136
+ } catch (err) {
137
+ console.error(`remove failed: ${errMsg(err)}`);
138
+ process.exit(1);
139
+ }
140
+ } else {
141
+ console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
142
+ }
143
+ process.exit(0);
144
+ }
145
+
146
+ export async function handleAddToGroup(args) {
147
+ const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
148
+ if (addToGroupIdx < 0) return false;
149
+ const rawArgs = args.slice(addToGroupIdx + 1);
150
+ const isBroadband = rawArgs.includes("--broadband");
151
+ const filtered = rawArgs.filter((a) => a !== "--broadband");
152
+ const [leaderHost, name] = filtered;
153
+ if (filtered.length > 2) {
154
+ console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
155
+ process.exit(1);
156
+ }
157
+ if (!leaderHost || !name) {
158
+ // 缺参数 → 手机宽带接入向导(默认 broadband:手机无公网入站,只出站)
159
+ const { runJoinWizard } = await import("../join-wizard.js");
160
+ const r = await runJoinWizard({ leaderInput: leaderHost || null, groupName: name || null });
161
+ process.exit(r.ok ? 0 : 1);
162
+ }
163
+ const r = await joinGroupCore({ leaderHost, name, isBroadband, args });
164
+ if (!r.ok) {
165
+ console.error(`join failed: ${r.error}`);
166
+ process.exit(1);
167
+ }
168
+ console.log(`joined group "${name}" at ${r.leaderUrl}${isBroadband ? " [broadband]" : ""}`);
169
+ if (r.syncError) console.log(` local failover setup failed: ${r.syncError}`);
170
+ else console.log(` ${r.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
171
+ process.exit(0);
172
+ }
173
+
174
+ export async function handleResetBan(args) {
175
+ const resetBanArg = argValue(args, "-resetban", "--resetban");
176
+ if (resetBanArg === null && !args.includes("-resetban") && !args.includes("--resetban")) return false;
177
+ const bans = createBansService({});
178
+ const ip = resetBanArg || null;
179
+ bans.clear(ip || undefined);
180
+ console.log(ip ? `ban cleared for ${ip}` : "all bans cleared");
181
+ process.exit(0);
182
+ }
183
+
184
+ export async function handleLeaveGroup(args) {
185
+ const leaveAll = args.includes("-leavegroup") || args.includes("--leavegroup") || args.includes("-leave-groups");
186
+ if (!leaveAll) return false;
187
+ const peers = createPeersService({});
188
+ const joined = loadGroupsJoined();
189
+ if (!joined.length) {
190
+ console.log("not joined to any group");
191
+ process.exit(0);
192
+ }
193
+ const myToken = (await loadToken()).token;
194
+ const leaders = [];
195
+ for (const g of joined) {
196
+ if (g.leaderUrl) {
197
+ const peersRemoved = peers.removeByGroup(g.name);
198
+ try {
199
+ const res = await compatFetch(`${g.leaderUrl}/v1/groups/leave`, {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ "Authorization": `Bearer ${myToken}`,
204
+ },
205
+ body: JSON.stringify({ name: g.name }),
206
+ });
207
+ if (res.ok) console.log(`${g.name}: left (deregistered from ${g.leaderUrl})`);
208
+ else console.log(`${g.name}: left locally (leader said: ${await res.text().catch(() => `HTTP ${res.status}`)})`);
209
+ } catch (err) {
210
+ console.log(`${g.name}: left locally (leader unreachable: ${errMsg(err)})`);
211
+ }
212
+ } else {
213
+ leaders.push(g.name);
214
+ }
215
+ }
216
+ const left = joined.filter((g) => g.leaderUrl).map((g) => g.name);
217
+ saveGroupsJoined(loadGroupsJoined().filter((g) => !left.includes(g.name)));
218
+ console.log(`left ${left.length} group(s)`);
219
+ if (leaders.length) {
220
+ console.log(`\nskipped ${leaders.length} group(s) where this node is the leader:`);
221
+ for (const n of leaders) console.log(` ${n} — leaders can't leave; disband it with: mslxdff -delgroup ${n}`);
222
+ }
223
+ process.exit(0);
224
+ }
225
+
226
+ export async function handleDelGroup(args) {
227
+ const delGroupName = argValue(args, "-delgroup", "--delgroup");
228
+ if (!delGroupName) return false;
229
+ const groups = createGroupsService({});
230
+ const peers = createPeersService({});
231
+ const local = groups.list()[delGroupName];
232
+ if (!local) {
233
+ const joined0 = loadGroupsJoined().find((g) => g.name === delGroupName);
234
+ if (joined0?.leaderUrl) {
235
+ console.log(`"${delGroupName}" is led by ${joined0.leaderUrl} — you are a member, use -leavegroup to leave it`);
236
+ } else if (joined0) {
237
+ console.log(`"${delGroupName}" exists in local state but has no group definition — nothing to delete`);
238
+ } else {
239
+ console.log(`group "${delGroupName}" not found on this node`);
240
+ }
241
+ process.exit(1);
242
+ }
243
+ const disbanded = groups.delete(delGroupName);
244
+ const members = Object.values(disbanded?.members || {});
245
+ peers.removeByGroup(delGroupName);
246
+ saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== delGroupName));
247
+ console.log(`group "${delGroupName}" disbanded (${members.length} member${members.length === 1 ? "" : "s"} removed)`);
248
+ process.exit(0);
249
+ }