dsh-lan-peers 0.8.0 → 0.8.2
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/README.md +5 -5
- package/client/cli-dispatch.test.mjs +226 -0
- package/client/cli-group.test.mjs +9 -9
- package/client/cli.mjs +117 -2
- package/client/pairing-baseurl.test.mjs +9 -9
- package/host/server.mjs +480 -25
- package/host/test/auth.test.mjs +16 -16
- package/host/test/group-auth.test.mjs +3 -3
- package/host/test/group-cluster.mjs +3 -0
- package/host/test/group-dispatch.test.mjs +488 -0
- package/host/test/group-members-authoritative.test.mjs +119 -0
- package/host/test/group.test.mjs +18 -18
- package/host/test/helpers.mjs +1 -1
- package/host/test/pairing-ecdh.test.mjs +2 -2
- package/host/test/unpair.test.mjs +1 -1
- package/lib/admin-actions.test.mjs +1 -1
- package/lib/admin-dispatch.test.mjs +358 -0
- package/lib/admin-group-apply.test.mjs +11 -11
- package/lib/admin-group.test.mjs +37 -34
- package/lib/admin-unpair.test.mjs +3 -3
- package/lib/admin.mjs +337 -5
- package/lib/client-render.test.mjs +1 -1
- package/lib/client.js +1 -1
- package/lib/group-client.mjs +588 -29
- package/lib/index.js +15 -10
- package/lib/panel-archives.test.mjs +3 -3
- package/lib/panel-dispatch.test.mjs +345 -0
- package/lib/panel-group-apply.test.mjs +7 -7
- package/lib/panel-group.test.mjs +20 -17
- package/lib/panel-unpair.test.mjs +9 -9
- package/lib/tools.mjs +77 -3
- package/overlays/web.patch.yml +1 -1
- package/package.json +66 -51
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ dsh plugin --profile web add dsh-lan-peers
|
|
|
76
76
|
workspace: ~/.dsh/lan-peers/workspace # 远端派单会话的工作目录
|
|
77
77
|
allowRemotePrompts: false # 默认关;打开后才接受别人的派单
|
|
78
78
|
requireEncryption: true # 默认开;旧版本对端会被拒绝
|
|
79
|
-
trustedCidrs: ["192.168.
|
|
79
|
+
trustedCidrs: ["192.168.1.0/24"] # 可选,限制来源网段
|
|
80
80
|
agentPreset: standard
|
|
81
81
|
```
|
|
82
82
|
|
|
@@ -98,13 +98,13 @@ dsh plugin --profile web add dsh-lan-peers
|
|
|
98
98
|
|
|
99
99
|
## 配对
|
|
100
100
|
|
|
101
|
-
**界面**:左下角 🔗 伙伴 → 「生成配对码」,把 6 位码给对方;对方在「添加伙伴」里填本机地址(如 `192.168.
|
|
101
|
+
**界面**:左下角 🔗 伙伴 → 「生成配对码」,把 6 位码给对方;对方在「添加伙伴」里填本机地址(如 `192.168.1.10:7788`)+ 码。两个方向各配一次,双方都能主动派单。
|
|
102
102
|
|
|
103
103
|
**命令行**:
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
npx dsh-lan-peers code --state-dir ~/.dsh/lan-peers
|
|
107
|
-
npx dsh-lan-peers pair --url http://192.168.
|
|
107
|
+
npx dsh-lan-peers pair --url http://192.168.1.10:7788 --code 123456 --name my-nas
|
|
108
108
|
npx dsh-lan-peers peers --probe
|
|
109
109
|
```
|
|
110
110
|
|
|
@@ -112,12 +112,12 @@ npx dsh-lan-peers peers --probe
|
|
|
112
112
|
|
|
113
113
|
模型侧(会话里直接说):
|
|
114
114
|
|
|
115
|
-
> 用 lan_peer_ask 问
|
|
115
|
+
> 用 lan_peer_ask 问 my-nas:`/data/project` 下 `pwd` 输出什么?
|
|
116
116
|
|
|
117
117
|
命令行:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
npx dsh-lan-peers ask --peer
|
|
120
|
+
npx dsh-lan-peers ask --peer my-nas "在 /data/project 执行 pwd" --timeout 120
|
|
121
121
|
```
|
|
122
122
|
|
|
123
123
|
对端会在自己的 `workspace` 里建真实会话、由对端模型执行,然后把最终回复加密回传。
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// dsh-lan-peers · CLI 的群内派单可见面(v0.8.2-α,契约 §1/§4/§5)。
|
|
2
|
+
//
|
|
3
|
+
// 真进程跑 cli.mjs:hub 在本进程里起(所以子进程必须异步跑),本机 host 的管理接口
|
|
4
|
+
// 用一个小 HTTP 桩代替 —— 派单执行在 host 里(Lane X),CLI 只负责「发完消息再去触发」。
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createPeerServer } from '../host/server.mjs';
|
|
13
|
+
import { makeTempStateDir, peerFixture, seedPeers, writeJson0600 } from '../host/test/helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
16
|
+
const MEMBER = peerFixture('peer-cli-dispatch');
|
|
17
|
+
|
|
18
|
+
function runAsync(args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(process.execPath, [CLI, ...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
21
|
+
resolve({ status: error ? error.code : 0, stdout, stderr });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function startHub(t, overrides = {}) {
|
|
27
|
+
const stateDir = makeTempStateDir();
|
|
28
|
+
seedPeers(stateDir, overrides.peers ?? [MEMBER]);
|
|
29
|
+
const server = createPeerServer({
|
|
30
|
+
listenHost: '127.0.0.1',
|
|
31
|
+
stateDir,
|
|
32
|
+
// 这个文件测的是 CLI 行为:quorum:none = 写本地即提交,不牵扯选举投票。
|
|
33
|
+
group: { enabled: true, autoJoin: true, quorum: 'none' },
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
const info = await server.listen(0, '127.0.0.1');
|
|
37
|
+
t.after(async () => {
|
|
38
|
+
await server.close();
|
|
39
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
return { server, baseUrl: info.url, stateDir };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function memberStateDir(t, { baseUrl, hubInstanceId, identity = MEMBER, peerName = 'hub' }) {
|
|
45
|
+
const dir = makeTempStateDir('dsh-lan-peers-cli-dispatch-');
|
|
46
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
47
|
+
writeJson0600(path.join(dir, 'state.json'), {
|
|
48
|
+
instanceId: identity.peerId,
|
|
49
|
+
name: identity.name,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
writeJson0600(path.join(dir, 'peers.json'), {
|
|
53
|
+
peers: {
|
|
54
|
+
[hubInstanceId]: {
|
|
55
|
+
name: peerName,
|
|
56
|
+
baseUrl,
|
|
57
|
+
secret: identity.inboundSecret,
|
|
58
|
+
inboundSecret: 'unused-outbound',
|
|
59
|
+
addedAt: new Date().toISOString(),
|
|
60
|
+
lastSeen: null,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 本机 host 的管理接口桩:只实现 group/dispatch 与 group/accept,记录每一次请求。 */
|
|
68
|
+
async function startAdminStub(t, handler) {
|
|
69
|
+
const requests = [];
|
|
70
|
+
const server = http.createServer((req, res) => {
|
|
71
|
+
let raw = '';
|
|
72
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
73
|
+
req.on('end', () => {
|
|
74
|
+
const payload = raw === '' ? null : JSON.parse(raw);
|
|
75
|
+
requests.push({ method: req.method, url: String(req.url), payload });
|
|
76
|
+
let answer = {};
|
|
77
|
+
try {
|
|
78
|
+
answer = handler({ url: String(req.url), payload }) || {};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
answer = { status: 500, json: { error: String(error.message || error) } };
|
|
81
|
+
}
|
|
82
|
+
res.writeHead(Number(answer.status) || 200, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(answer.json ?? {}));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
87
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
88
|
+
return { requests, url: 'http://127.0.0.1:' + server.address().port };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 一条到 hub 的、已入群的成员 state-dir */
|
|
92
|
+
async function joinedMember(t) {
|
|
93
|
+
const hub = await startHub(t);
|
|
94
|
+
const dir = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hub.server.store.instanceId });
|
|
95
|
+
const joined = await runAsync(['group', 'join', '--peer', 'hub', '--state-dir', dir]);
|
|
96
|
+
assert.equal(joined.status, 0, joined.stderr);
|
|
97
|
+
return { hub, dir };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DONE = {
|
|
101
|
+
ok: true,
|
|
102
|
+
dispatch: { target: 'nas-box', targetName: 'nas-box', status: 'done', reason: '', elapsedMs: 1234, replyChars: 56 },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test('group --help 列出 dispatch 与 accept 两个新子命令', async () => {
|
|
106
|
+
const out = await runAsync(['group', '--help']);
|
|
107
|
+
assert.match(out.stdout, /dispatch <目标> <任务\.\.\.>/);
|
|
108
|
+
assert.match(out.stdout, /accept on\|off/);
|
|
109
|
+
assert.match(out.stdout, /@!名字 时作者侧自动派单/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('group dispatch <目标> <任务>:等价发一条 @!目标 任务,再触发本机派单并打印摘要', async (t) => {
|
|
113
|
+
const { hub, dir } = await joinedMember(t);
|
|
114
|
+
const admin = await startAdminStub(t, ({ url }) => (url.includes('/group/dispatch') ? { json: DONE } : { json: { ok: true } }));
|
|
115
|
+
|
|
116
|
+
const out = await runAsync([
|
|
117
|
+
'group', 'dispatch', 'nas-box', '跑', '回归', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
118
|
+
]);
|
|
119
|
+
assert.equal(out.status, 0, out.stderr);
|
|
120
|
+
assert.match(out.stdout, /已发送 seq=\d+/);
|
|
121
|
+
assert.match(out.stdout, /派单 → nas-box:done(1234ms \/ 56字)/);
|
|
122
|
+
|
|
123
|
+
const dispatched = admin.requests.filter((request) => request.url === '/api/lan-peers/group/dispatch');
|
|
124
|
+
assert.equal(dispatched.length, 1);
|
|
125
|
+
assert.deepEqual(dispatched[0].payload, { target: 'nas-box', task: '跑 回归', extraTargets: 0 });
|
|
126
|
+
|
|
127
|
+
// 群消息先入库,且内容就是 "@!目标 任务"
|
|
128
|
+
const history = await runAsync(['group', 'history', '--peer', 'hub', '--state-dir', dir]);
|
|
129
|
+
assert.equal(history.status, 0, history.stderr);
|
|
130
|
+
assert.match(history.stdout, /@!nas-box 跑 回归/);
|
|
131
|
+
assert.ok(hub.server, 'hub 必须还活着');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('group post --text "@!X 任务":自动派单;普通消息一次都不打派单接口', async (t) => {
|
|
135
|
+
const { dir } = await joinedMember(t);
|
|
136
|
+
const admin = await startAdminStub(t, ({ url }) => (url.includes('/group/dispatch') ? { json: DONE } : { json: { ok: true } }));
|
|
137
|
+
|
|
138
|
+
const withBang = await runAsync([
|
|
139
|
+
'group', 'post', '--text', '@!nas-box 修 CI', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(withBang.status, 0, withBang.stderr);
|
|
142
|
+
assert.match(withBang.stdout, /派单 → nas-box:done/);
|
|
143
|
+
assert.equal(admin.requests.length, 1);
|
|
144
|
+
assert.deepEqual(admin.requests[0].payload, { target: 'nas-box', task: '修 CI', extraTargets: 0 });
|
|
145
|
+
|
|
146
|
+
const plain = await runAsync([
|
|
147
|
+
'group', 'post', '--text', '@nas-box 只是提及', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
148
|
+
]);
|
|
149
|
+
assert.equal(plain.status, 0, plain.stderr);
|
|
150
|
+
assert.equal(admin.requests.length, 1, '没有 @! 就一次派单请求都不发');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('本机没有 host:消息照发,但派单未触发要如实报错 exit 2(不静默当普通消息)', async (t) => {
|
|
154
|
+
const { dir } = await joinedMember(t);
|
|
155
|
+
// 默认 --url 指向 127.0.0.1:7788:本测试里那里没有 host
|
|
156
|
+
const out = await runAsync([
|
|
157
|
+
'group', 'post', '--text', '@!nas-box 跑回归', '--peer', 'hub', '--state-dir', dir,
|
|
158
|
+
'--url', 'http://127.0.0.1:1',
|
|
159
|
+
]);
|
|
160
|
+
assert.equal(out.status, 2);
|
|
161
|
+
assert.match(out.stdout, /已发送 seq=\d+/, '群消息确实发出去了');
|
|
162
|
+
assert.match(out.stderr, /派单未触发/);
|
|
163
|
+
assert.match(out.stderr, /本机 host 未响应/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('skipped(没有 @! 目标)显示未派单,不算完成也不算失败', async (t) => {
|
|
167
|
+
const { dir } = await joinedMember(t);
|
|
168
|
+
const admin = await startAdminStub(t, () => ({ json: { ok: false, dispatch: { status: 'skipped', reason: 'no-target' } } }));
|
|
169
|
+
const out = await runAsync([
|
|
170
|
+
'group', 'dispatch', 'nas-box', '任务', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
171
|
+
]);
|
|
172
|
+
assert.equal(out.status, 0, '零动作不是失败');
|
|
173
|
+
assert.match(out.stdout, /未派单(消息里没有 @! 目标): no-target/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('派单失败(failed)打印 reason 并 exit 1', async (t) => {
|
|
177
|
+
const { dir } = await joinedMember(t);
|
|
178
|
+
const admin = await startAdminStub(t, () => ({
|
|
179
|
+
json: { ok: false, dispatch: { target: 'nas-box', status: 'failed', reason: 'not-accepting', elapsedMs: 12 } },
|
|
180
|
+
}));
|
|
181
|
+
const out = await runAsync([
|
|
182
|
+
'group', 'dispatch', 'nas-box', '任务', '--peer', 'hub', '--state-dir', dir, '--url', admin.url,
|
|
183
|
+
]);
|
|
184
|
+
assert.equal(out.status, 1);
|
|
185
|
+
assert.match(out.stdout, /派单 → nas-box:failed(not-accepting \/ 12ms)/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('group accept off:打本机管理接口并打印新状态', async (t) => {
|
|
189
|
+
const { dir } = await joinedMember(t);
|
|
190
|
+
const admin = await startAdminStub(t, ({ payload }) => ({
|
|
191
|
+
json: { ok: true, acceptDispatch: payload.acceptDispatch, persisted: true },
|
|
192
|
+
}));
|
|
193
|
+
const off = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
194
|
+
assert.equal(off.status, 0, off.stderr);
|
|
195
|
+
assert.match(off.stdout, /本机接单:已关闭/);
|
|
196
|
+
assert.deepEqual(admin.requests[0].payload, { acceptDispatch: false });
|
|
197
|
+
|
|
198
|
+
const on = await runAsync(['group', 'accept', 'on', '--state-dir', dir, '--url', admin.url]);
|
|
199
|
+
assert.equal(on.status, 0, on.stderr);
|
|
200
|
+
assert.match(on.stdout, /本机接单:接单中/);
|
|
201
|
+
assert.deepEqual(admin.requests[1].payload, { acceptDispatch: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('group accept:host 未接线(501)→ exit 2 且说清原因', async (t) => {
|
|
205
|
+
const { dir } = await joinedMember(t);
|
|
206
|
+
const admin = await startAdminStub(t, () => ({ status: 501, json: { error: 'accept-toggle-unsupported' } }));
|
|
207
|
+
const out = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
208
|
+
assert.equal(out.status, 2);
|
|
209
|
+
assert.match(out.stderr, /切换接单失败/);
|
|
210
|
+
assert.match(out.stderr, /accept-toggle-unsupported/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('group accept 参数必须是 on|off;persisted=false 要如实说「重启后回原值」', async (t) => {
|
|
214
|
+
const { dir } = await joinedMember(t);
|
|
215
|
+
const admin = await startAdminStub(t, ({ payload }) => ({
|
|
216
|
+
json: { ok: true, acceptDispatch: payload.acceptDispatch, persisted: false },
|
|
217
|
+
}));
|
|
218
|
+
const bad = await runAsync(['group', 'accept', 'toggle', '--state-dir', dir, '--url', admin.url]);
|
|
219
|
+
assert.equal(bad.status, 1);
|
|
220
|
+
assert.match(bad.stderr, /group accept on\|off/);
|
|
221
|
+
assert.equal(admin.requests.length, 0, '参数不对时一个请求都不发');
|
|
222
|
+
|
|
223
|
+
const ok = await runAsync(['group', 'accept', 'off', '--state-dir', dir, '--url', admin.url]);
|
|
224
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
225
|
+
assert.match(ok.stdout, /重启后回到配置值/);
|
|
226
|
+
});
|
|
@@ -185,10 +185,10 @@ test('group join --name 只改自己;两个同名节点在 state / history 里
|
|
|
185
185
|
const dirB = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hubId, identity: peerB });
|
|
186
186
|
|
|
187
187
|
// 两台机器的默认主机名相同(真机现场就是这个现象)
|
|
188
|
-
const joinedA = await runAsync(['group', 'join', '--name', '
|
|
188
|
+
const joinedA = await runAsync(['group', 'join', '--name', 'desk-alpha', '--peer', 'hub', '--state-dir', dirA]);
|
|
189
189
|
assert.equal(joinedA.status, 0, joinedA.stderr);
|
|
190
|
-
assert.match(joinedA.stdout, /显示名=
|
|
191
|
-
const joinedB = await runAsync(['group', 'join', '--name', '
|
|
190
|
+
assert.match(joinedA.stdout, /显示名=desk-alpha/);
|
|
191
|
+
const joinedB = await runAsync(['group', 'join', '--name', 'desk-alpha', '--peer', 'hub', '--state-dir', dirB]);
|
|
192
192
|
assert.equal(joinedB.status, 0, joinedB.stderr);
|
|
193
193
|
|
|
194
194
|
// 不传 --name 再 join 不会把名字抹掉
|
|
@@ -197,20 +197,20 @@ test('group join --name 只改自己;两个同名节点在 state / history 里
|
|
|
197
197
|
|
|
198
198
|
const state = await runAsync(['group', 'state', '--peer', 'hub', '--state-dir', dirA]);
|
|
199
199
|
assert.equal(state.status, 0, state.stderr);
|
|
200
|
-
assert.match(state.stdout, /
|
|
201
|
-
assert.match(state.stdout, /
|
|
200
|
+
assert.match(state.stdout, /desk-alpha \(peer-cli\)/, '同名成员要带短 id 后缀');
|
|
201
|
+
assert.match(state.stdout, /desk-alpha \(bbbbbbbb\)/);
|
|
202
202
|
|
|
203
203
|
await runAsync(['group', 'post', '--text', 'A 说的', '--peer', 'hub', '--state-dir', dirA]);
|
|
204
204
|
await runAsync(['group', 'post', '--text', 'B 说的', '--peer', 'hub', '--state-dir', dirB]);
|
|
205
205
|
const history = await runAsync(['group', 'history', '--peer', 'hub', '--state-dir', dirA]);
|
|
206
206
|
assert.equal(history.status, 0, history.stderr);
|
|
207
|
-
assert.match(history.stdout, /
|
|
208
|
-
assert.match(history.stdout, /
|
|
207
|
+
assert.match(history.stdout, /desk-alpha \(peer-cli\) \[chat\] A 说的/, 'history 的 from.name 也要能区分');
|
|
208
|
+
assert.match(history.stdout, /desk-alpha \(bbbbbbbb\) \[chat\] B 说的/);
|
|
209
209
|
|
|
210
210
|
// hub 侧存的仍是原始 name(渲染层才加后缀)
|
|
211
211
|
const raw = JSON.parse(fs.readFileSync(path.join(hub.stateDir, 'group.json'), 'utf8'));
|
|
212
|
-
assert.equal(raw.members[MEMBER.peerId].name, '
|
|
213
|
-
assert.equal(raw.members[peerB.peerId].name, '
|
|
212
|
+
assert.equal(raw.members[MEMBER.peerId].name, 'desk-alpha');
|
|
213
|
+
assert.equal(raw.members[peerB.peerId].name, 'desk-alpha');
|
|
214
214
|
|
|
215
215
|
// 空 --name 直接拒绝
|
|
216
216
|
const empty = await runAsync(['group', 'join', '--name', '', '--peer', 'hub', '--state-dir', dirA]);
|
package/client/cli.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
probePeer,
|
|
24
24
|
} from './peer-call.mjs';
|
|
25
25
|
import { readPeerEvents } from './sse.mjs';
|
|
26
|
+
// v0.8.2-α(契约 §1/§5):@! 的解析口径与面板共用一份 —— 解析属于协议,不属于某一端。
|
|
27
|
+
// 面板在管理接口里解析,CLI 在发完消息后解析,两边落在同一个函数上。
|
|
28
|
+
import { parseGroupDispatchCommand } from '../lib/admin.mjs';
|
|
26
29
|
import {
|
|
27
30
|
GROUP_APPLY_POLL_INTERVAL_MS,
|
|
28
31
|
GROUP_APPLY_POLL_TIMEOUT_MS,
|
|
@@ -137,6 +140,9 @@ const GROUP_USAGE = [
|
|
|
137
140
|
'子命令:',
|
|
138
141
|
' state 群信息 + 成员表(会拿到 groupKey,但不打印内容)',
|
|
139
142
|
' post --text "..." 发一条消息 [--kind chat|progress|result|system]',
|
|
143
|
+
' 正文含 @!名字 时作者侧自动派单(由本机 host 执行并打印回执摘要)',
|
|
144
|
+
' dispatch <目标> <任务...> 等价发一条 "@!目标 任务" 并派单,打印回执摘要',
|
|
145
|
+
' accept on|off 切换本机接单(写 profile 配置,热生效)',
|
|
140
146
|
' history [--since N] 取历史消息 [--limit 1..200]',
|
|
141
147
|
' tail [--since N] 跟随事件流并逐条打印 [--duration 秒]',
|
|
142
148
|
' join [--name 显示名] 向 hub 自助加入(hub 开了 autoJoin 才成功)',
|
|
@@ -703,8 +709,68 @@ async function cmdGroupState(dir, args) {
|
|
|
703
709
|
console.log('groupKey: 已在 group-key.json(32B,不显示)');
|
|
704
710
|
}
|
|
705
711
|
|
|
706
|
-
|
|
707
|
-
|
|
712
|
+
/**
|
|
713
|
+
* 契约 §3 的回执摘要(CLI 口径,与面板同形):派单 → 目标:done/failed(reason / 耗时 / 字数)。
|
|
714
|
+
* skipped 是第三种终态:这条消息没有 @! 目标 —— 零消息写入,不能说成完成也不能说成失败。
|
|
715
|
+
*/
|
|
716
|
+
function formatDispatchLine(dispatch) {
|
|
717
|
+
const data = dispatch && typeof dispatch === 'object' ? dispatch : {};
|
|
718
|
+
const status = String(data.status ?? '');
|
|
719
|
+
const who = String(data.targetName ?? '') || String(data.target ?? '') || '—';
|
|
720
|
+
if (status === 'skipped') return '未派单(消息里没有 @! 目标): ' + String(data.reason ?? 'no-target');
|
|
721
|
+
const parts = [];
|
|
722
|
+
// done 也可能带 reason(契约 §1:多个 @! 只取第一个,reason=multi-target-ignored)。
|
|
723
|
+
const reason = String(data.reason ?? '');
|
|
724
|
+
if (reason !== '') parts.push(reason);
|
|
725
|
+
else if (status !== 'done') parts.push('error');
|
|
726
|
+
const elapsed = Number(data.elapsedMs);
|
|
727
|
+
if (Number.isFinite(elapsed) && elapsed >= 0) parts.push(Math.round(elapsed) + 'ms');
|
|
728
|
+
const chars = Number(data.replyChars);
|
|
729
|
+
if (Number.isFinite(chars) && chars >= 0) parts.push(Math.round(chars) + '字');
|
|
730
|
+
return '派单 → ' + who + ':' + (status === 'done' ? 'done' : 'failed')
|
|
731
|
+
+ (parts.length ? '(' + parts.join(' / ') + ')' : '');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* 契约 §5:作者侧派单的执行口 = 本机回环的 POST /api/lan-peers/group/dispatch。
|
|
736
|
+
* 派单要开 1:1 会话、要往群里写 progress/result,只有本机 host 做得到;CLI 直连 hub
|
|
737
|
+
* 发消息,所以这里必须再打一次本机管理接口。
|
|
738
|
+
*
|
|
739
|
+
* 本机没有 host(纯 CLI 节点、host 没起)→ **如实报错并 exit 2**:
|
|
740
|
+
* 静默把 "@!X 任务" 当成一条普普通通的群消息发出去,是最坏的一种失败。
|
|
741
|
+
*/
|
|
742
|
+
async function triggerDispatch(args, command) {
|
|
743
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
744
|
+
let out;
|
|
745
|
+
try {
|
|
746
|
+
out = await adminCall(url, 'group/dispatch', {
|
|
747
|
+
method: 'POST',
|
|
748
|
+
headers: { 'Content-Type': 'application/json' },
|
|
749
|
+
body: JSON.stringify({
|
|
750
|
+
target: command.target,
|
|
751
|
+
task: command.task,
|
|
752
|
+
extraTargets: command.extraTargets,
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
} catch (error) {
|
|
756
|
+
const reason = String(error?.message ?? error);
|
|
757
|
+
process.stderr.write('消息已发出,但派单未触发:本机 host 未响应 ' + url + '(' + reason + ')\n'
|
|
758
|
+
+ '修法:先起本机 host(或对 --url 指向的管理接口重试 group dispatch)\n');
|
|
759
|
+
process.exitCode = 2;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const dispatch = out && out.dispatch && typeof out.dispatch === 'object' ? out.dispatch : null;
|
|
763
|
+
if (!dispatch) {
|
|
764
|
+
process.stderr.write('派单未返回结果:本机 host 未按契约回 dispatch 摘要\n');
|
|
765
|
+
process.exitCode = 2;
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
console.log(formatDispatchLine(dispatch));
|
|
769
|
+
if (dispatch.status === 'failed') process.exitCode = 1;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** 发一条群消息(post 与 dispatch 共用);作者侧在 POST 之后按契约 §1 解析 @! 并派单。 */
|
|
773
|
+
async function postGroupText(dir, args, text) {
|
|
708
774
|
if (!text) throw new Error(GROUP_USAGE);
|
|
709
775
|
if (Array.from(text).length > GROUP_TEXT_MAX_CHARS) {
|
|
710
776
|
throw new Error('text 超过 ' + GROUP_TEXT_MAX_CHARS + ' 字符上限(hub 会回 413 payload-too-large)');
|
|
@@ -726,6 +792,53 @@ async function cmdGroupPost(dir, args) {
|
|
|
726
792
|
'已发送 seq=' + sent.seq + ' ts=' + new Date(Number(sent.ts)).toISOString()
|
|
727
793
|
+ (channel.mode === 'group' ? '(群级签名直连 ' + channel.key.baseUrl + ')' : ''),
|
|
728
794
|
);
|
|
795
|
+
// 只有 chat 参与解析:progress/result 本身就是回执,再解析一遍等于自己派自己。
|
|
796
|
+
if (kind !== 'chat') return;
|
|
797
|
+
const command = parseGroupDispatchCommand(text);
|
|
798
|
+
if (!command) return;
|
|
799
|
+
await triggerDispatch(args, command);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function cmdGroupPost(dir, args) {
|
|
803
|
+
const text = String(args.text ?? args._.slice(1).join(' ') ?? '').trim();
|
|
804
|
+
await postGroupText(dir, args, text);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** 契约 §5:显式入口 —— 等价发一条 "@!目标 任务" 再派单(消息先入库,回执摘要打印出来)。 */
|
|
808
|
+
async function cmdGroupDispatch(dir, args) {
|
|
809
|
+
const target = String(args._[1] ?? '').trim();
|
|
810
|
+
const task = args._.slice(2).join(' ').trim() || String(args.text ?? '').trim();
|
|
811
|
+
if (target === '' || task === '') {
|
|
812
|
+
throw new Error('用法: dsh-lan-peers group dispatch <目标> <任务...>\n' + GROUP_USAGE);
|
|
813
|
+
}
|
|
814
|
+
await postGroupText(dir, args, '@!' + target + ' ' + task);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 契约 §4/§5:切换本机接单开关(写 profile 配置、热生效;结果以 host 回的为准)。 */
|
|
818
|
+
async function cmdGroupAccept(dir, args) {
|
|
819
|
+
const wanted = String(args._[1] ?? '').trim().toLowerCase();
|
|
820
|
+
const value = wanted === 'on' ? true : (wanted === 'off' ? false : null);
|
|
821
|
+
if (value === null) {
|
|
822
|
+
throw new Error('用法: dsh-lan-peers group accept on|off\n' + GROUP_USAGE);
|
|
823
|
+
}
|
|
824
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
825
|
+
let out;
|
|
826
|
+
try {
|
|
827
|
+
out = await adminCall(url, 'group/accept', {
|
|
828
|
+
method: 'POST',
|
|
829
|
+
headers: { 'Content-Type': 'application/json' },
|
|
830
|
+
body: JSON.stringify({ acceptDispatch: value }),
|
|
831
|
+
});
|
|
832
|
+
} catch (error) {
|
|
833
|
+
const reason = String(error?.message ?? error);
|
|
834
|
+
process.stderr.write('切换接单失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
|
|
835
|
+
+ '修法:确认本机 host 在跑且群功能已启用(' + reason + ' 为 501 表示 host 还没接线)\n');
|
|
836
|
+
process.exitCode = /^(40[39]|501)\b/.test(reason) ? 2 : 1;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const applied = !(out && out.acceptDispatch === false);
|
|
840
|
+
console.log('本机接单:' + (applied ? '接单中' : '已关闭')
|
|
841
|
+
+ (out && out.persisted === false ? '(仅本进程生效,重启后回到配置值)' : ''));
|
|
729
842
|
}
|
|
730
843
|
|
|
731
844
|
async function cmdGroupHistory(dir, args) {
|
|
@@ -1340,6 +1453,8 @@ async function cmdGroup(args) {
|
|
|
1340
1453
|
const handlers = {
|
|
1341
1454
|
state: cmdGroupState,
|
|
1342
1455
|
post: cmdGroupPost,
|
|
1456
|
+
dispatch: cmdGroupDispatch,
|
|
1457
|
+
accept: cmdGroupAccept,
|
|
1343
1458
|
history: cmdGroupHistory,
|
|
1344
1459
|
tail: cmdGroupTail,
|
|
1345
1460
|
join: cmdGroupJoin,
|
|
@@ -46,7 +46,7 @@ const runCli = (args) => new Promise((resolve) => {
|
|
|
46
46
|
test('v0.8.0 缺陷2:ECDH 配对把发起方的 baseUrl 存进接收方 peers.json', async (t) => {
|
|
47
47
|
const host = await startHost(t);
|
|
48
48
|
const { code } = host.server.issuePairingCode();
|
|
49
|
-
const selfBaseUrl = 'http://192.168.
|
|
49
|
+
const selfBaseUrl = 'http://192.168.1.77:7788';
|
|
50
50
|
|
|
51
51
|
const paired = await pairPeer({
|
|
52
52
|
url: host.baseUrl,
|
|
@@ -66,7 +66,7 @@ test('v0.8.0 缺陷2:ECDH 配对把发起方的 baseUrl 存进接收方 peers.
|
|
|
66
66
|
test('v0.8.0 缺陷2:旧明文配对同样带 baseUrl(0.3.1 路径不退化)', async (t) => {
|
|
67
67
|
const host = await startHost(t);
|
|
68
68
|
const { code } = host.server.issuePairingCode();
|
|
69
|
-
const selfBaseUrl = 'http://192.168.
|
|
69
|
+
const selfBaseUrl = 'http://192.168.1.78:7788';
|
|
70
70
|
const paired = await pairPeer({
|
|
71
71
|
url: host.baseUrl,
|
|
72
72
|
code,
|
|
@@ -103,24 +103,24 @@ test('v0.8.0 缺陷2:探测口径(私有 IPv4 优先 192.168、跳过虚拟/
|
|
|
103
103
|
'vEthernet (WSL)': [{ family: 'IPv4', address: '172.28.0.1', internal: false }],
|
|
104
104
|
'Wi-Fi': [
|
|
105
105
|
{ family: 'IPv6', address: 'fe80::1', internal: false },
|
|
106
|
-
{ family: 'IPv4', address: '192.168.
|
|
106
|
+
{ family: 'IPv4', address: '192.168.1.211', internal: false },
|
|
107
107
|
],
|
|
108
108
|
'以太网': [{ family: 'IPv4', address: '10.0.0.7', internal: false }],
|
|
109
109
|
Loopback: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
|
110
110
|
公网: [{ family: 'IPv4', address: '8.8.8.8', internal: false }],
|
|
111
111
|
};
|
|
112
|
-
assert.equal(detectSelfBaseUrl({ interfaces }), 'http://192.168.
|
|
113
|
-
assert.equal(detectSelfBaseUrl({ interfaces, port: 8123 }), 'http://192.168.
|
|
112
|
+
assert.equal(detectSelfBaseUrl({ interfaces }), 'http://192.168.1.211:7788');
|
|
113
|
+
assert.equal(detectSelfBaseUrl({ interfaces, port: 8123 }), 'http://192.168.1.211:8123');
|
|
114
114
|
// 显式值优先,且会去掉尾部斜杠;非 http(s) 一律忽略
|
|
115
115
|
assert.equal(
|
|
116
116
|
detectSelfBaseUrl({ interfaces, selfBaseUrl: 'http://10.9.9.9:7788/' }),
|
|
117
117
|
'http://10.9.9.9:7788',
|
|
118
118
|
);
|
|
119
|
-
assert.equal(detectSelfBaseUrl({ interfaces, selfBaseUrl: 'ftp://10.9.9.9' }), 'http://192.168.
|
|
119
|
+
assert.equal(detectSelfBaseUrl({ interfaces, selfBaseUrl: 'ftp://10.9.9.9' }), 'http://192.168.1.211:7788');
|
|
120
120
|
// 环境变量优先于探测(部署里给不出私有地址时的手动兜底)
|
|
121
121
|
assert.equal(
|
|
122
|
-
detectSelfBaseUrl({ interfaces, env: { DSH_LAN_PEERS_ADVERTISE_BASE_URL: 'http://192.168.
|
|
123
|
-
'http://192.168.
|
|
122
|
+
detectSelfBaseUrl({ interfaces, env: { DSH_LAN_PEERS_ADVERTISE_BASE_URL: 'http://192.168.1.30:7788' } }),
|
|
123
|
+
'http://192.168.1.30:7788',
|
|
124
124
|
);
|
|
125
125
|
// 完全没有私有地址 → 空串(调用方不会发空 baseUrl)
|
|
126
126
|
assert.equal(
|
|
@@ -134,7 +134,7 @@ test('v0.8.0 缺陷2:CLI pair --base-url 走同一条路径(真进程 + 真
|
|
|
134
134
|
const stateDir = makeTempStateDir('dsh-lan-peers-pair-cli-');
|
|
135
135
|
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }));
|
|
136
136
|
const { code } = host.server.issuePairingCode();
|
|
137
|
-
const selfBaseUrl = 'http://192.168.
|
|
137
|
+
const selfBaseUrl = 'http://192.168.1.79:7788';
|
|
138
138
|
|
|
139
139
|
const out = await runCli([
|
|
140
140
|
'pair', '--url', host.baseUrl, '--code', code,
|