dsh-lan-peers 0.8.1 → 0.8.3
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/client/cli-code.test.mjs +108 -0
- package/client/cli-dispatch.test.mjs +226 -0
- package/client/cli.mjs +182 -22
- package/host/server.mjs +518 -51
- package/host/test/auth.test.mjs +21 -7
- package/host/test/group-applications-admin.test.mjs +5 -3
- package/host/test/group-cluster.mjs +3 -0
- package/host/test/group-dispatch.test.mjs +488 -0
- package/host/test/group-election.test.mjs +105 -31
- package/host/test/group-epoch.test.mjs +33 -17
- package/host/test/group-key-rotation.test.mjs +9 -0
- package/host/test/group-security-lane-a.test.mjs +281 -0
- package/host/test/pairing-ecdh.test.mjs +6 -5
- package/lib/admin-actions.test.mjs +11 -3
- package/lib/admin-archives.test.mjs +11 -3
- package/lib/admin-dispatch.test.mjs +366 -0
- package/lib/admin-group-apply.test.mjs +11 -3
- package/lib/admin-group.test.mjs +16 -5
- package/lib/admin-surface.test.mjs +477 -0
- package/lib/admin-unpair.test.mjs +6 -1
- package/lib/admin.mjs +523 -32
- package/lib/client-render.test.mjs +23 -1
- package/lib/client.js +56 -7
- package/lib/group-client.mjs +568 -18
- package/lib/index.js +15 -10
- package/lib/panel-dispatch.test.mjs +345 -0
- package/lib/panel-group.test.mjs +4 -1
- package/lib/tools.mjs +77 -3
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// dsh-lan-peers · M6 之后 `lan-peers code` 必须请本机 host 发码。
|
|
2
|
+
//
|
|
3
|
+
// 审计/产品口径:配对码只在 daemon 进程内存里,CLI 是独立进程 —— 它自己造的码对正在跑的
|
|
4
|
+
// host 无效,用户抄走的是废码。所以 code 子命令改成打本机管理接口(带 token),
|
|
5
|
+
// 本机没 host / 拿不到 token 时 exit 2 并说清怎么起 host,绝不本地造一个码。
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createAdmin } from '../lib/admin.mjs';
|
|
14
|
+
import { makeTempStateDir } from '../host/test/helpers.mjs';
|
|
15
|
+
|
|
16
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
17
|
+
|
|
18
|
+
const run = (args) => new Promise((resolve) => {
|
|
19
|
+
execFile(process.execPath, [CLI, ...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
20
|
+
resolve({ code: error ? error.code : 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** 真 admin 挂在真 HTTP 上:token 文件由它启动时写进这个 stateDir(CLI 读同一份)。 */
|
|
25
|
+
async function startAdmin(t, { dir, code = '246810' } = {}) {
|
|
26
|
+
const state = { instanceId: 'inst-self', name: 'self' };
|
|
27
|
+
const store = {
|
|
28
|
+
instanceId: 'inst-self',
|
|
29
|
+
stateDir: dir,
|
|
30
|
+
state,
|
|
31
|
+
listPeers: () => [],
|
|
32
|
+
getPeer: () => null,
|
|
33
|
+
issuePairingCode: () => {
|
|
34
|
+
const issued = { code, expiresAt: Date.now() + 600_000 };
|
|
35
|
+
state.pairingCode = issued;
|
|
36
|
+
return issued;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const admin = createAdmin({
|
|
40
|
+
server: { config: { logger: () => {} }, store },
|
|
41
|
+
nameOf: () => 'self',
|
|
42
|
+
rename: (n) => n,
|
|
43
|
+
});
|
|
44
|
+
const calls = [];
|
|
45
|
+
const server = http.createServer((req, res) => {
|
|
46
|
+
let body = '';
|
|
47
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
48
|
+
req.on('end', () => {
|
|
49
|
+
calls.push({ url: String(req.url || ''), method: String(req.method || 'GET'), headers: req.headers });
|
|
50
|
+
const url = new URL(String(req.url || '/'), 'http://127.0.0.1');
|
|
51
|
+
Promise.resolve(admin.handle(req, res, url)).then((handled) => {
|
|
52
|
+
if (!handled) {
|
|
53
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
54
|
+
res.end('{"error":"not-found"}');
|
|
55
|
+
}
|
|
56
|
+
}).catch((error) => {
|
|
57
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
58
|
+
res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
63
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
64
|
+
return { admin, calls, state, url: 'http://127.0.0.1:' + server.address().port };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('code:请本机 host 发码并原样打印(与 admin 手里的码一致)', async (t) => {
|
|
68
|
+
const dir = makeTempStateDir('lanb-cli-code-');
|
|
69
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
70
|
+
const rig = await startAdmin(t, { dir, code: '246810' });
|
|
71
|
+
|
|
72
|
+
const out = await run(['code', '--state-dir', dir, '--url', rig.url]);
|
|
73
|
+
assert.equal(out.code, 0, out.stderr);
|
|
74
|
+
assert.match(out.stdout, /配对码: 246810/);
|
|
75
|
+
assert.doesNotMatch(out.stdout + out.stderr, /token/, '不能把 token 字样漏到界面上');
|
|
76
|
+
|
|
77
|
+
const issued = rig.calls.find((call) => call.url === '/api/lan-peers/code');
|
|
78
|
+
assert.ok(issued, '必须打 POST /api/lan-peers/code: ' + JSON.stringify(rig.calls.map((c) => c.url)));
|
|
79
|
+
assert.equal(issued.method, 'POST');
|
|
80
|
+
assert.equal(issued.headers['x-lan-peers-token'], rig.admin.token, 'token 从 <stateDir>/admin-token 读,只走请求头');
|
|
81
|
+
|
|
82
|
+
// 打印出来的码就是 host 手里的那一个:GET state 说「已生成」。
|
|
83
|
+
const state = await fetch(rig.url + '/api/lan-peers/state', { headers: { 'x-lan-peers-token': rig.admin.token } });
|
|
84
|
+
const body = await state.json();
|
|
85
|
+
assert.equal(body.pairing.active, true, 'CLI 打印的码必须是本机 host 活着的那一个');
|
|
86
|
+
assert.equal(JSON.stringify(body).includes('246810'), false, 'GET state 仍不回显码');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('code:本机没有 host / 拿不到 token → exit 2,且绝不本地造码打印', async (t) => {
|
|
90
|
+
const dir = makeTempStateDir('lanb-cli-code-');
|
|
91
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
92
|
+
const rig = await startAdmin(t, { dir, code: '246810' });
|
|
93
|
+
// 拿掉 token 文件:等价于「本机 host 没起 / 不是同一个进程」。
|
|
94
|
+
fs.rmSync(path.join(dir, 'admin-token'));
|
|
95
|
+
|
|
96
|
+
const out = await run(['code', '--state-dir', dir, '--url', rig.url]);
|
|
97
|
+
assert.equal(out.code, 2, '拿不到码就是 exit 2: ' + out.stdout + out.stderr);
|
|
98
|
+
assert.match(out.stderr, /请先启动本机 host/);
|
|
99
|
+
assert.match(out.stderr, /token-required/, '失败原因要如实转述');
|
|
100
|
+
assert.doesNotMatch(out.stdout, /\d{6}/, '失败时不能在 stdout 吐出任何 6 位数字(那会被当成码)');
|
|
101
|
+
assert.equal(fs.existsSync(path.join(dir, 'admin-token')), false, 'CLI 不该自己去造 token');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('code --help 说明「请本机 host 发码」,不再声称写 state.json', async (t) => {
|
|
105
|
+
const out = await run(['code', '--help']);
|
|
106
|
+
assert.match(out.stdout, /请本机 host 生成一次性配对码/);
|
|
107
|
+
assert.doesNotMatch(out.stdout, /同时写入 state\.json/, 'M6 之后 state.json 不再存码');
|
|
108
|
+
});
|
|
@@ -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
|
+
});
|
package/client/cli.mjs
CHANGED
|
@@ -7,11 +7,9 @@
|
|
|
7
7
|
// node cli.mjs events --peer <peerId|name> --session <sessionId> [--duration 300] [--verbose]
|
|
8
8
|
// node cli.mjs unpair --peer <peerId|name> [--local-only] [--timeout 10]
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
11
10
|
import fs from 'node:fs';
|
|
12
11
|
import os from 'node:os';
|
|
13
12
|
import path from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
13
|
import {
|
|
16
14
|
DEFAULT_ANSWER_WAIT_MS,
|
|
17
15
|
MAX_ANSWER_WAIT_MS,
|
|
@@ -23,6 +21,9 @@ import {
|
|
|
23
21
|
probePeer,
|
|
24
22
|
} from './peer-call.mjs';
|
|
25
23
|
import { readPeerEvents } from './sse.mjs';
|
|
24
|
+
// v0.8.2-α(契约 §1/§5):@! 的解析口径与面板共用一份 —— 解析属于协议,不属于某一端。
|
|
25
|
+
// 面板在管理接口里解析,CLI 在发完消息后解析,两边落在同一个函数上。
|
|
26
|
+
import { parseGroupDispatchCommand } from '../lib/admin.mjs';
|
|
26
27
|
import {
|
|
27
28
|
GROUP_APPLY_POLL_INTERVAL_MS,
|
|
28
29
|
GROUP_APPLY_POLL_TIMEOUT_MS,
|
|
@@ -79,9 +80,11 @@ const USAGE = [
|
|
|
79
80
|
].join('\n');
|
|
80
81
|
|
|
81
82
|
const CODE_USAGE = [
|
|
82
|
-
'用法: dsh-lan-peers code [--state-dir <目录>]',
|
|
83
|
+
'用法: dsh-lan-peers code [--state-dir <目录>] [--url <本机管理接口>]',
|
|
83
84
|
'',
|
|
84
|
-
'
|
|
85
|
+
'请本机 host 生成一次性配对码(10 分钟有效,用后即焚)。',
|
|
86
|
+
'M6 起码只活在 host 进程内存里:本命令是独立进程,自己生成的码对正在跑的 host 无效,',
|
|
87
|
+
'所以必须先启动本机 host(或直接用界面面板的「生成配对码」)。',
|
|
85
88
|
].join('\n');
|
|
86
89
|
|
|
87
90
|
const PAIR_USAGE = [
|
|
@@ -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 才成功)',
|
|
@@ -221,20 +227,40 @@ async function signedCall({ baseUrl, secret, senderId, method = 'GET', apiPath,
|
|
|
221
227
|
return callPeer({ baseUrl, secret, senderId, method, apiPath, body, timeoutMs: 60_000 });
|
|
222
228
|
}
|
|
223
229
|
|
|
230
|
+
/**
|
|
231
|
+
* M6:配对码只活在 daemon 进程内存里(state.json 不再持久化它)。本命令是**独立进程**,
|
|
232
|
+
* 自己生成一个码打印出来,正在跑的 host 根本不知道 —— 用户抄走的是一条无效凭据
|
|
233
|
+
* (旧行为「另一个进程能写码」本身也正是要堵掉的植入路径)。所以这里改成请本机 host 发码:
|
|
234
|
+
* 它才是唯一该写这条的进程,也是唯一知道码的进程。
|
|
235
|
+
*
|
|
236
|
+
* 本机没有 host / 拿不到 token → exit 2 并说清怎么起 host,绝不本地造一个码糊弄过去。
|
|
237
|
+
*/
|
|
224
238
|
async function cmdCode(args) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
const dir = stateDir(args);
|
|
240
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
241
|
+
let out;
|
|
242
|
+
try {
|
|
243
|
+
out = await adminCall(url, 'code', {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: '{}',
|
|
247
|
+
}, dir);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const reason = String(error?.message ?? error);
|
|
250
|
+
process.stderr.write('生成配对码失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
|
|
251
|
+
+ '修法:请先启动本机 host(或用界面面板的「生成配对码」)—— 码只能在 host 进程里生成\n');
|
|
252
|
+
process.exitCode = 2;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const code = String(out && out.code ? out.code : '').trim();
|
|
256
|
+
if (!/^\d{6}$/.test(code)) {
|
|
257
|
+
process.stderr.write('生成配对码失败:本机 host 未按契约回 6 位码\n');
|
|
258
|
+
process.exitCode = 2;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
console.log('配对码: ' + code + ' 有效期: 10 分钟,一次性');
|
|
237
262
|
console.log('对方把它填进「添加伙伴」里即可配对(地址填本机 http://<ip>:7788)');
|
|
263
|
+
console.log('提示: 这个码只在本机 host 进程里有效,重启 host 即失效');
|
|
238
264
|
}
|
|
239
265
|
|
|
240
266
|
async function cmdPair(args) {
|
|
@@ -703,8 +729,72 @@ async function cmdGroupState(dir, args) {
|
|
|
703
729
|
console.log('groupKey: 已在 group-key.json(32B,不显示)');
|
|
704
730
|
}
|
|
705
731
|
|
|
706
|
-
|
|
707
|
-
|
|
732
|
+
/**
|
|
733
|
+
* 契约 §3 的回执摘要(CLI 口径,与面板同形):派单 → 目标:done/failed(reason / 耗时 / 字数)。
|
|
734
|
+
* skipped 是第三种终态:这条消息没有 @! 目标 —— 零消息写入,不能说成完成也不能说成失败。
|
|
735
|
+
*/
|
|
736
|
+
function formatDispatchLine(dispatch) {
|
|
737
|
+
const data = dispatch && typeof dispatch === 'object' ? dispatch : {};
|
|
738
|
+
const status = String(data.status ?? '');
|
|
739
|
+
const who = String(data.targetName ?? '') || String(data.target ?? '') || '—';
|
|
740
|
+
if (status === 'skipped') return '未派单(消息里没有 @! 目标): ' + String(data.reason ?? 'no-target');
|
|
741
|
+
// M1:同名歧义是「用户得先选一个」,不是网络错误 —— 给出可操作的重发语法。
|
|
742
|
+
if (status === 'failed' && String(data.reason ?? '') === 'ambiguous') {
|
|
743
|
+
return '派单 → ' + who + ':有多个同名成员,请用 @!名字 (短id) 重发';
|
|
744
|
+
}
|
|
745
|
+
const parts = [];
|
|
746
|
+
// done 也可能带 reason(契约 §1:多个 @! 只取第一个,reason=multi-target-ignored)。
|
|
747
|
+
const reason = String(data.reason ?? '');
|
|
748
|
+
if (reason !== '') parts.push(reason);
|
|
749
|
+
else if (status !== 'done') parts.push('error');
|
|
750
|
+
const elapsed = Number(data.elapsedMs);
|
|
751
|
+
if (Number.isFinite(elapsed) && elapsed >= 0) parts.push(Math.round(elapsed) + 'ms');
|
|
752
|
+
const chars = Number(data.replyChars);
|
|
753
|
+
if (Number.isFinite(chars) && chars >= 0) parts.push(Math.round(chars) + '字');
|
|
754
|
+
return '派单 → ' + who + ':' + (status === 'done' ? 'done' : 'failed')
|
|
755
|
+
+ (parts.length ? '(' + parts.join(' / ') + ')' : '');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* 契约 §5:作者侧派单的执行口 = 本机回环的 POST /api/lan-peers/group/dispatch。
|
|
760
|
+
* 派单要开 1:1 会话、要往群里写 progress/result,只有本机 host 做得到;CLI 直连 hub
|
|
761
|
+
* 发消息,所以这里必须再打一次本机管理接口。
|
|
762
|
+
*
|
|
763
|
+
* 本机没有 host(纯 CLI 节点、host 没起)→ **如实报错并 exit 2**:
|
|
764
|
+
* 静默把 "@!X 任务" 当成一条普普通通的群消息发出去,是最坏的一种失败。
|
|
765
|
+
*/
|
|
766
|
+
async function triggerDispatch(args, command) {
|
|
767
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
768
|
+
let out;
|
|
769
|
+
try {
|
|
770
|
+
out = await adminCall(url, 'group/dispatch', {
|
|
771
|
+
method: 'POST',
|
|
772
|
+
headers: { 'Content-Type': 'application/json' },
|
|
773
|
+
body: JSON.stringify({
|
|
774
|
+
target: command.target,
|
|
775
|
+
task: command.task,
|
|
776
|
+
extraTargets: command.extraTargets,
|
|
777
|
+
}),
|
|
778
|
+
}, stateDir(args));
|
|
779
|
+
} catch (error) {
|
|
780
|
+
const reason = String(error?.message ?? error);
|
|
781
|
+
process.stderr.write('消息已发出,但派单未触发:本机 host 未响应 ' + url + '(' + reason + ')\n'
|
|
782
|
+
+ '修法:先起本机 host(或对 --url 指向的管理接口重试 group dispatch)\n');
|
|
783
|
+
process.exitCode = 2;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const dispatch = out && out.dispatch && typeof out.dispatch === 'object' ? out.dispatch : null;
|
|
787
|
+
if (!dispatch) {
|
|
788
|
+
process.stderr.write('派单未返回结果:本机 host 未按契约回 dispatch 摘要\n');
|
|
789
|
+
process.exitCode = 2;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
console.log(formatDispatchLine(dispatch));
|
|
793
|
+
if (dispatch.status === 'failed') process.exitCode = 1;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 发一条群消息(post 与 dispatch 共用);作者侧在 POST 之后按契约 §1 解析 @! 并派单。 */
|
|
797
|
+
async function postGroupText(dir, args, text) {
|
|
708
798
|
if (!text) throw new Error(GROUP_USAGE);
|
|
709
799
|
if (Array.from(text).length > GROUP_TEXT_MAX_CHARS) {
|
|
710
800
|
throw new Error('text 超过 ' + GROUP_TEXT_MAX_CHARS + ' 字符上限(hub 会回 413 payload-too-large)');
|
|
@@ -726,6 +816,53 @@ async function cmdGroupPost(dir, args) {
|
|
|
726
816
|
'已发送 seq=' + sent.seq + ' ts=' + new Date(Number(sent.ts)).toISOString()
|
|
727
817
|
+ (channel.mode === 'group' ? '(群级签名直连 ' + channel.key.baseUrl + ')' : ''),
|
|
728
818
|
);
|
|
819
|
+
// 只有 chat 参与解析:progress/result 本身就是回执,再解析一遍等于自己派自己。
|
|
820
|
+
if (kind !== 'chat') return;
|
|
821
|
+
const command = parseGroupDispatchCommand(text);
|
|
822
|
+
if (!command) return;
|
|
823
|
+
await triggerDispatch(args, command);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function cmdGroupPost(dir, args) {
|
|
827
|
+
const text = String(args.text ?? args._.slice(1).join(' ') ?? '').trim();
|
|
828
|
+
await postGroupText(dir, args, text);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** 契约 §5:显式入口 —— 等价发一条 "@!目标 任务" 再派单(消息先入库,回执摘要打印出来)。 */
|
|
832
|
+
async function cmdGroupDispatch(dir, args) {
|
|
833
|
+
const target = String(args._[1] ?? '').trim();
|
|
834
|
+
const task = args._.slice(2).join(' ').trim() || String(args.text ?? '').trim();
|
|
835
|
+
if (target === '' || task === '') {
|
|
836
|
+
throw new Error('用法: dsh-lan-peers group dispatch <目标> <任务...>\n' + GROUP_USAGE);
|
|
837
|
+
}
|
|
838
|
+
await postGroupText(dir, args, '@!' + target + ' ' + task);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** 契约 §4/§5:切换本机接单开关(写 profile 配置、热生效;结果以 host 回的为准)。 */
|
|
842
|
+
async function cmdGroupAccept(dir, args) {
|
|
843
|
+
const wanted = String(args._[1] ?? '').trim().toLowerCase();
|
|
844
|
+
const value = wanted === 'on' ? true : (wanted === 'off' ? false : null);
|
|
845
|
+
if (value === null) {
|
|
846
|
+
throw new Error('用法: dsh-lan-peers group accept on|off\n' + GROUP_USAGE);
|
|
847
|
+
}
|
|
848
|
+
const url = String(args.url ?? '').trim() || ADMIN_DEFAULT_URL;
|
|
849
|
+
let out;
|
|
850
|
+
try {
|
|
851
|
+
out = await adminCall(url, 'group/accept', {
|
|
852
|
+
method: 'POST',
|
|
853
|
+
headers: { 'Content-Type': 'application/json' },
|
|
854
|
+
body: JSON.stringify({ acceptDispatch: value }),
|
|
855
|
+
}, dir);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const reason = String(error?.message ?? error);
|
|
858
|
+
process.stderr.write('切换接单失败:本机 host 未接受 ' + url + '(' + reason + ')\n'
|
|
859
|
+
+ '修法:确认本机 host 在跑且群功能已启用(' + reason + ' 为 501 表示 host 还没接线)\n');
|
|
860
|
+
process.exitCode = /^(40[39]|501)\b/.test(reason) ? 2 : 1;
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const applied = !(out && out.acceptDispatch === false);
|
|
864
|
+
console.log('本机接单:' + (applied ? '接单中' : '已关闭')
|
|
865
|
+
+ (out && out.persisted === false ? '(仅本进程生效,重启后回到配置值)' : ''));
|
|
729
866
|
}
|
|
730
867
|
|
|
731
868
|
async function cmdGroupHistory(dir, args) {
|
|
@@ -939,16 +1076,37 @@ async function cmdGroupClaim(dir, args) {
|
|
|
939
1076
|
|
|
940
1077
|
const ADMIN_DEFAULT_URL = 'http://127.0.0.1:7788';
|
|
941
1078
|
const ADMIN_TIMEOUT_MS = 10000;
|
|
1079
|
+
// S3:管理接口要 X-Lan-Peers-Token。host 启动时把它写到 <stateDir>/admin-token(0600),
|
|
1080
|
+
// CLI 只读不写;token 本身绝不出现在 URL、日志或错误消息里(只说去哪读)。
|
|
1081
|
+
const ADMIN_TOKEN_FILE = 'admin-token';
|
|
1082
|
+
|
|
1083
|
+
function readAdminToken(dir) {
|
|
1084
|
+
try {
|
|
1085
|
+
return fs.readFileSync(path.join(String(dir || DEFAULT_STATE_DIR), ADMIN_TOKEN_FILE), 'utf8').trim();
|
|
1086
|
+
} catch {
|
|
1087
|
+
return '';
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
942
1090
|
|
|
943
1091
|
/** 打本机回环管理接口;非 2xx 抛 `<status> <error>` 形式的 Error(与 group-client 同款)。 */
|
|
944
|
-
async function adminCall(baseUrl, apiPath, init) {
|
|
1092
|
+
async function adminCall(baseUrl, apiPath, init, dir) {
|
|
945
1093
|
const base = String(baseUrl || ADMIN_DEFAULT_URL).replace(/\/+$/, '');
|
|
1094
|
+
const token = readAdminToken(dir);
|
|
1095
|
+
const headers = { ...(init && init.headers ? init.headers : {}) };
|
|
1096
|
+
if (token !== '') headers['X-Lan-Peers-Token'] = token;
|
|
946
1097
|
const reply = await fetch(base + '/api/lan-peers/' + apiPath, {
|
|
947
1098
|
...init,
|
|
1099
|
+
headers,
|
|
948
1100
|
signal: AbortSignal.timeout(ADMIN_TIMEOUT_MS),
|
|
949
1101
|
});
|
|
950
1102
|
const body = await reply.json().catch(() => ({}));
|
|
951
|
-
if (!reply.ok)
|
|
1103
|
+
if (!reply.ok) {
|
|
1104
|
+
const status = reply.status;
|
|
1105
|
+
const where = path.join(String(dir || DEFAULT_STATE_DIR), ADMIN_TOKEN_FILE);
|
|
1106
|
+
// 401 一定是 token 缺失/不匹配:直接指路(host 启动时写入,权限 0600)。
|
|
1107
|
+
const hint = status === 401 ? '(管理接口需要 token:确认本机 host 在跑,且 ' + where + ' 可读)' : '';
|
|
1108
|
+
throw new Error(status + ' ' + String(body.error || 'admin-call-failed') + hint);
|
|
1109
|
+
}
|
|
952
1110
|
return body;
|
|
953
1111
|
}
|
|
954
1112
|
|
|
@@ -962,7 +1120,7 @@ function reportArchiveError(error) {
|
|
|
962
1120
|
async function cmdGroupArchives(dir, args) {
|
|
963
1121
|
let out;
|
|
964
1122
|
try {
|
|
965
|
-
out = await adminCall(args.url, 'group/archives');
|
|
1123
|
+
out = await adminCall(args.url, 'group/archives', undefined, dir);
|
|
966
1124
|
} catch (error) {
|
|
967
1125
|
reportArchiveError(error);
|
|
968
1126
|
return;
|
|
@@ -998,7 +1156,7 @@ async function cmdGroupArchiveDelete(dir, args) {
|
|
|
998
1156
|
method: 'POST',
|
|
999
1157
|
headers: { 'Content-Type': 'application/json' },
|
|
1000
1158
|
body: JSON.stringify({ file: file, action: 'delete' }),
|
|
1001
|
-
});
|
|
1159
|
+
}, dir);
|
|
1002
1160
|
} catch (error) {
|
|
1003
1161
|
reportArchiveError(error);
|
|
1004
1162
|
return;
|
|
@@ -1340,6 +1498,8 @@ async function cmdGroup(args) {
|
|
|
1340
1498
|
const handlers = {
|
|
1341
1499
|
state: cmdGroupState,
|
|
1342
1500
|
post: cmdGroupPost,
|
|
1501
|
+
dispatch: cmdGroupDispatch,
|
|
1502
|
+
accept: cmdGroupAccept,
|
|
1343
1503
|
history: cmdGroupHistory,
|
|
1344
1504
|
tail: cmdGroupTail,
|
|
1345
1505
|
join: cmdGroupJoin,
|