dsh-lan-peers 0.8.4 → 0.10.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/README.md +21 -0
- package/client/cli-archives.test.mjs +4 -3
- package/client/cli-code.test.mjs +4 -1
- package/client/cli-dispatch-settings.test.mjs +300 -0
- package/client/cli-dm.test.mjs +186 -0
- package/client/cli-files.test.mjs +278 -0
- package/client/cli-group.test.mjs +15 -5
- package/client/cli-unpair.test.mjs +4 -1
- package/client/cli.mjs +918 -438
- package/client/cli.test.mjs +2 -0
- package/client/http.test.mjs +6 -2
- package/client/i18n.test.mjs +191 -0
- package/client/pairing.test.mjs +3 -2
- package/client/peer-call-budget.test.mjs +246 -0
- package/client/peer-call.mjs +156 -20
- package/host/server.mjs +3987 -497
- package/host/test/auth.test.mjs +4 -3
- package/host/test/dispatch-pending-visibility.test.mjs +223 -0
- package/host/test/dispatch-ttl.test.mjs +242 -0
- package/host/test/dispatch-workspace.test.mjs +1139 -0
- package/host/test/dm.test.mjs +826 -0
- package/host/test/file-transfer.test.mjs +617 -0
- package/host/test/group-answer-hub.test.mjs +203 -0
- package/host/test/group-auth.test.mjs +2 -2
- package/host/test/group-cluster.mjs +11 -1
- package/host/test/group-commit.test.mjs +33 -2
- package/host/test/group-epoch.test.mjs +3 -2
- package/host/test/group-hub-commit.test.mjs +325 -0
- package/host/test/group-key-rotation.test.mjs +2 -1
- package/host/test/group-presence.test.mjs +258 -0
- package/host/test/group-push-timeout.test.mjs +146 -0
- package/host/test/group-replica.test.mjs +1122 -6
- package/host/test/group-seq74-freeze.test.mjs +140 -0
- package/host/test/group-v2-auth.test.mjs +1223 -0
- package/host/test/group-v2-e2e.test.mjs +565 -0
- package/host/test/group-vote-shelved-reminder.test.mjs +221 -0
- package/host/test/group.test.mjs +191 -106
- package/host/test/helpers.mjs +6 -42
- package/host/test/identity-clock-skew.test.mjs +292 -0
- package/host/test/leak-check-rules.test.mjs +146 -0
- package/host/test/ports-scan.test.mjs +66 -0
- package/host/test/rate-limit.test.mjs +7 -0
- package/host/test/session-reuse-policy.test.mjs +390 -0
- package/host/test/state-lastseen.test.mjs +145 -0
- package/host/test/version-consistency.test.mjs +84 -0
- package/lib/admin-dispatch-settings.test.mjs +454 -0
- package/lib/admin-dm.test.mjs +302 -0
- package/lib/admin-files.test.mjs +514 -0
- package/lib/admin-group-vote.test.mjs +357 -0
- package/lib/admin-group.test.mjs +281 -8
- package/lib/admin-surface.test.mjs +1 -97
- package/lib/admin-token-file.test.mjs +160 -0
- package/lib/admin-unpair.test.mjs +4 -3
- package/lib/admin.mjs +7365 -3466
- package/lib/callee-contract.test.mjs +34 -0
- package/lib/client-render.test.mjs +95 -6
- package/lib/client.js +73 -11
- package/lib/dispatch-prefs.mjs +542 -0
- package/lib/dm-store.mjs +276 -0
- package/lib/file-progress.test.mjs +288 -0
- package/lib/file-store.mjs +702 -0
- package/lib/group-ca.mjs +259 -0
- package/lib/group-ca.test.mjs +333 -0
- package/lib/group-client-v2.test.mjs +850 -0
- package/lib/group-client.mjs +2130 -2184
- package/lib/group-identity.mjs +630 -0
- package/lib/group-identity.test.mjs +425 -0
- package/lib/group-notes.mjs +118 -0
- package/lib/group-notes.test.mjs +152 -0
- package/lib/group-resident.mjs +599 -0
- package/lib/group-resident.test.mjs +309 -0
- package/lib/group-task.mjs +126 -0
- package/lib/group-task.test.mjs +153 -0
- package/lib/group-vote.mjs +390 -0
- package/lib/group-vote.test.mjs +340 -0
- package/lib/i18n.mjs +1479 -0
- package/lib/index.js +251 -206
- package/lib/panel-dispatch.test.mjs +47 -159
- package/lib/panel-dm-scroll.test.mjs +376 -0
- package/lib/panel-dm.test.mjs +286 -0
- package/lib/panel-float.test.mjs +576 -0
- package/lib/panel-group.test.mjs +462 -11
- package/lib/panel-i18n.test.mjs +481 -0
- package/lib/panel-lang.test.mjs +328 -0
- package/lib/panel-member-history.test.mjs +67 -0
- package/lib/panel-pending-bar.test.mjs +370 -0
- package/lib/panel-settings.test.mjs +512 -0
- package/lib/panel-token-heal.test.mjs +261 -0
- package/lib/panel-uncommitted.test.mjs +180 -0
- package/lib/panel-wechat-ui.test.mjs +453 -0
- package/lib/panel-zoom-clamp.test.mjs +324 -0
- package/lib/runner-ctx.mjs +478 -22
- package/lib/runner-dsh.mjs +212 -18
- package/lib/runner-projections.test.mjs +339 -0
- package/lib/runner-workspace.test.mjs +855 -0
- package/lib/session-reuse-prefs.mjs +116 -0
- package/lib/session-reuse.mjs +138 -0
- package/lib/session-reuse.test.mjs +226 -0
- package/lib/tools.mjs +56 -69
- package/package.json +1 -1
- package/client/cli-dispatch.test.mjs +0 -226
- package/host/test/group-dispatch.test.mjs +0 -488
- package/lib/admin-dispatch.test.mjs +0 -366
package/README.md
CHANGED
|
@@ -122,6 +122,27 @@ npx dsh-lan-peers ask --peer my-nas "在 /data/project 执行 pwd" --timeout 120
|
|
|
122
122
|
|
|
123
123
|
对端会在自己的 `workspace` 里建真实会话、由对端模型执行,然后把最终回复加密回传。
|
|
124
124
|
|
|
125
|
+
## 版本互通
|
|
126
|
+
|
|
127
|
+
同一个群里允许混版本,但 **0.10 与 0.9.x / 0.8.4 之间有两条硬断裂**。下表是「两端各自正常工作时,用户实际看到的结果」。
|
|
128
|
+
|
|
129
|
+
| 组合 | 私聊 ask | 群聊 chat | 群内派单 | 群纪要 | 群表决 | 引用行 |
|
|
130
|
+
|---|---|---|---|---|---|---|
|
|
131
|
+
| 0.10.x ↔ 0.9.x | WORKING | WORKING | BROKEN:老节点发的 `progress`/`result` 被 0.10 hub 判 `400 {"error":"bad-request"}`,群里没有「开始/结果」回执 | DEGRADED:老 hub 的 pull 不带 `notes`,成员面板一直空白、不报错 | DEGRADED:同理;成员提提案/投票打到老 hub 得 `404 {"error":"not-found"}` | DEGRADED:老 hub 的 meta 白名单没有 `quote`,静默丢弃,只少「引用 …」那一行 |
|
|
132
|
+
| 0.10.x ↔ 0.8.4 | WORKING | WORKING | BROKEN:同上(0.8.4 的回执同样是 `progress`/`result`) | DEGRADED:同上 | DEGRADED:同上 | DEGRADED:同上 |
|
|
133
|
+
|
|
134
|
+
**变在哪、为什么断**
|
|
135
|
+
|
|
136
|
+
- **两条硬断裂**:① 群内派单整条线在 0.10.0 删除(`lan_group_ask`、管理面 `group/dispatch-options` / `group/accept`、执行器、回执),`GROUP_KINDS` 只剩 `['chat','system']`;② 于是老对端继续投 `progress`/`result` 回执一律 `400 {"error":"bad-request"}` —— 老节点那边只留一行 `[group] dispatch receipt failed …` 日志,群里从此看不到派单进度。**历史不会丢**:已有聊天记录里的 `progress`/`result` 仍照常显示成一行系统行。
|
|
137
|
+
- **一条软断裂**:`meta.quote` 是 0.10 新增字段,老 hub 的 meta 白名单没有它,**静默丢弃、不报错**(HTTP 仍是 200)。代价是引用行消失,以及「首答胜」的去重判据在跨版本时失效。
|
|
138
|
+
- **身份面不是断裂**:0.8.4 只有 v1 群级鉴权(`X-Peer-Auth: group` + groupKey HMAC),0.9 起有 v2(证书 + ed25519),0.10 默认两种都收(`allowLegacyGroupAuth` 默认 true)。
|
|
139
|
+
- **私聊 ask 形状没变**:`/v1/answer` 三代一直是 `{status, reply|error, elapsedMs}`;0.10 只多一个错误码 `empty-reply`(对端跑完却没给正文),老调用方按 `peer error: …` 呈报即可。
|
|
140
|
+
- `acceptDispatch` 双向安全:0.10 出站不再发 `X-Peer-Accept`,入站遇到老字段直接丢弃;老 hub 缺这个字段按「接单」处理。
|
|
141
|
+
|
|
142
|
+
**升级口径**:hub 与所有成员**在同一个窗口内一起升到 0.10** —— 跨 0.10 线的群内派单双向都不工作,纪要与表决也要 hub 先到 0.10 才有数据。
|
|
143
|
+
|
|
144
|
+
> 证据:每格都能追到代码或用例(`host/server.mjs`、`lib/group-*.mjs`、`host/test/group.test.mjs`、`lib/panel-group.test.mjs`)。`interop/run-interop.mjs` 的六行 A–F 目前只覆盖 **0.3.1 基线 ↔ 当前版本**,**不含**上面两组组合 —— 所以本表是代码路径结论,不是互通实跑结论。
|
|
145
|
+
|
|
125
146
|
## 安全模型
|
|
126
147
|
|
|
127
148
|
- 配对码 6 位、10 分钟、一次性;每个伙伴两条独立密钥(我签给你的 / 你签给我的)。
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 断言请求形状、stdout 文案与退出码(403/409 归「现在做不了」,其余算失败)。
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
|
+
import { listenOnRandomPort } from '../test-support/ports.mjs';
|
|
8
9
|
import http from 'node:http';
|
|
9
10
|
import { execFile } from 'node:child_process';
|
|
10
11
|
import fs from 'node:fs';
|
|
@@ -12,6 +13,8 @@ import path from 'node:path';
|
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { makeTempStateDir } from '../host/test/helpers.mjs';
|
|
14
15
|
|
|
16
|
+
process.env.DSH_LAN_PEERS_LANG = 'zh'; // 语言钉死:断言写的是中文文案,不随机器 locale 漂移
|
|
17
|
+
|
|
15
18
|
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
16
19
|
const MB = 1024 * 1024;
|
|
17
20
|
|
|
@@ -38,9 +41,7 @@ async function startAdmin(t, handler) {
|
|
|
38
41
|
res.end(JSON.stringify(reply.body ?? {}));
|
|
39
42
|
});
|
|
40
43
|
});
|
|
41
|
-
const port = await
|
|
42
|
-
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
|
43
|
-
});
|
|
44
|
+
const { port } = await listenOnRandomPort(server);
|
|
44
45
|
t.after(() => new Promise((resolve) => { server.close(() => resolve()); }));
|
|
45
46
|
const dir = makeTempStateDir('dsh-lan-peers-cli-archives-');
|
|
46
47
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
package/client/cli-code.test.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 本机没 host / 拿不到 token 时 exit 2 并说清怎么起 host,绝不本地造一个码。
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
|
+
import { listenOnRandomPort } from '../test-support/ports.mjs';
|
|
8
9
|
import { execFile } from 'node:child_process';
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import http from 'node:http';
|
|
@@ -13,6 +14,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
14
|
import { createAdmin } from '../lib/admin.mjs';
|
|
14
15
|
import { makeTempStateDir } from '../host/test/helpers.mjs';
|
|
15
16
|
|
|
17
|
+
process.env.DSH_LAN_PEERS_LANG = 'zh'; // 语言钉死:断言写的是中文文案,不随机器 locale 漂移
|
|
18
|
+
|
|
16
19
|
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
17
20
|
|
|
18
21
|
const run = (args) => new Promise((resolve) => {
|
|
@@ -59,7 +62,7 @@ async function startAdmin(t, { dir, code = '246810' } = {}) {
|
|
|
59
62
|
});
|
|
60
63
|
});
|
|
61
64
|
});
|
|
62
|
-
await
|
|
65
|
+
await listenOnRandomPort(server);
|
|
63
66
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
64
67
|
return { admin, calls, state, url: 'http://127.0.0.1:' + server.address().port };
|
|
65
68
|
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// dsh-lan-peers · v0.8.5-α(契约 §5/§6,Lane H2)CLI:dispatch pending/approve/reject/settings
|
|
2
|
+
// 与 dm <peer> <正文> --workspace/--perm。
|
|
3
|
+
//
|
|
4
|
+
// 全部只打本机回环管理接口(/api/lan-peers/dispatch*),所以这里起一个只实现那几条路由的
|
|
5
|
+
// node:http 桩,真跑 cli.mjs 子进程,断言请求形状(含 token 头)、stdout 文案与退出码:
|
|
6
|
+
// 401/403/409/501 归「本机现在做不了」(exit 2),其余算失败(exit 1)。
|
|
7
|
+
// 铁律(契约 §0):跨机只走 id+name;日志只记 id 前 8 位、权限档、工作区 id、字节数。
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { listenOnRandomPort } from '../test-support/ports.mjs';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { makeTempStateDir, writeJson0600 } from '../host/test/helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
19
|
+
const TOKEN = 'cli-dispatch-settings-token';
|
|
20
|
+
const PEER_ID = 'peer-b-1234';
|
|
21
|
+
|
|
22
|
+
function runAsync(args, env = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
execFile(process.execPath, [CLI, ...args],
|
|
25
|
+
// 语言钉死:断言写的是中文文案,不能随机器 locale 翻成英文。
|
|
26
|
+
{ encoding: 'utf8', env: { ...process.env, DSH_LAN_PEERS_LANG: 'zh', ...env } },
|
|
27
|
+
(error, stdout, stderr) => {
|
|
28
|
+
resolve({ status: error ? error.code : 0, stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function startAdmin(t, handler, { token = TOKEN } = {}) {
|
|
34
|
+
const seen = [];
|
|
35
|
+
const server = http.createServer((req, res) => {
|
|
36
|
+
let raw = '';
|
|
37
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
const call = {
|
|
40
|
+
method: req.method,
|
|
41
|
+
url: req.url,
|
|
42
|
+
token: req.headers['x-lan-peers-token'] ?? '',
|
|
43
|
+
contentType: req.headers['content-type'] ?? '',
|
|
44
|
+
body: raw === '' ? null : JSON.parse(raw),
|
|
45
|
+
};
|
|
46
|
+
seen.push(call);
|
|
47
|
+
const reply = handler(call) ?? {};
|
|
48
|
+
res.writeHead(reply.status ?? 200, { 'Content-Type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify(reply.body ?? {}));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
const { port } = await listenOnRandomPort(server);
|
|
53
|
+
t.after(() => new Promise((resolve) => { server.close(() => resolve()); }));
|
|
54
|
+
|
|
55
|
+
const dir = makeTempStateDir('dsh-lan-peers-cli-dispatch-');
|
|
56
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
57
|
+
writeJson0600(path.join(dir, 'peers.json'), {
|
|
58
|
+
peers: { [PEER_ID]: { name: '书房', baseUrl: 'http://10.0.0.2:7788', secret: 's', inboundSecret: 'i' } },
|
|
59
|
+
});
|
|
60
|
+
if (token !== '') fs.writeFileSync(path.join(dir, 'admin-token'), token + '\n', { mode: 0o600 });
|
|
61
|
+
return { seen, url: 'http://127.0.0.1:' + port, dir };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOW = Date.now();
|
|
65
|
+
const PENDING = [
|
|
66
|
+
{
|
|
67
|
+
approvalId: 'ap-1', from: 'peer-b-12345678', workspaceId: 'main', workspaceName: '主工作区',
|
|
68
|
+
permission: 'danger-full-access', promptBytes: 64, at: NOW, expiresAt: NOW + 120_000,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const PREFS = {
|
|
72
|
+
defaultWorkspaceId: 'main',
|
|
73
|
+
maxPermission: 'workspace-write',
|
|
74
|
+
workspaces: [
|
|
75
|
+
{ id: 'main', name: '主工作区', path: 'D:/work/main' },
|
|
76
|
+
{ id: 'docs', name: '文档', path: 'D:/work/docs' },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
test('dispatch pending:GET dispatch/pending,逐行 id/来源/工作区/权限/剩余秒数', async (t) => {
|
|
81
|
+
const admin = await startAdmin(t, () => ({ body: { items: PENDING } }));
|
|
82
|
+
const out = await runAsync(['dispatch', 'pending', '--url', admin.url, '--state-dir', admin.dir]);
|
|
83
|
+
|
|
84
|
+
assert.equal(out.status, 0, out.stderr);
|
|
85
|
+
assert.equal(admin.seen.length, 1);
|
|
86
|
+
assert.equal(admin.seen[0].method, 'GET');
|
|
87
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dispatch/pending');
|
|
88
|
+
assert.equal(admin.seen[0].token, TOKEN);
|
|
89
|
+
assert.match(out.stdout, /^ap-1\t来自 peer-b-1\t工作区 main\/主工作区\tdanger-full-access\t剩余 \d+s$/m);
|
|
90
|
+
assert.match(out.stderr, /待批准 1 条/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('dispatch pending:没有待批准时只打一行说明,空表头不出现', async (t) => {
|
|
94
|
+
const admin = await startAdmin(t, () => ({ body: { items: [] } }));
|
|
95
|
+
const out = await runAsync(['dispatch', 'pending', '--url', admin.url, '--state-dir', admin.dir]);
|
|
96
|
+
assert.equal(out.status, 0);
|
|
97
|
+
assert.equal(out.stdout.trim(), '');
|
|
98
|
+
assert.match(out.stderr, /(没有待批准请求)/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('dispatch approve <id>:POST { id } 并打印 state', async (t) => {
|
|
102
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true, state: 'approved' } }));
|
|
103
|
+
const out = await runAsync(['dispatch', 'approve', 'ap-1', '--url', admin.url, '--state-dir', admin.dir]);
|
|
104
|
+
|
|
105
|
+
assert.equal(out.status, 0, out.stderr);
|
|
106
|
+
assert.equal(admin.seen[0].method, 'POST');
|
|
107
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dispatch/approve');
|
|
108
|
+
assert.deepEqual(admin.seen[0].body, { id: 'ap-1' });
|
|
109
|
+
assert.match(out.stdout, /^已批准 ap-1(state=approved)$/m);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('dispatch reject <id>:POST { id };未知 approval(404)exit 1,501 exit 2', async (t) => {
|
|
113
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true, state: 'rejected' } }));
|
|
114
|
+
const ok = await runAsync(['dispatch', 'reject', 'ap-2', '--url', admin.url, '--state-dir', admin.dir]);
|
|
115
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
116
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dispatch/reject');
|
|
117
|
+
assert.deepEqual(admin.seen[0].body, { id: 'ap-2' });
|
|
118
|
+
assert.match(ok.stdout, /^已拒绝 ap-2(state=rejected)$/m);
|
|
119
|
+
|
|
120
|
+
const missing = await startAdmin(t, () => ({ status: 404, body: { error: 'unknown-approval' } }));
|
|
121
|
+
const notFound = await runAsync(['dispatch', 'reject', 'ap-3', '--url', missing.url, '--state-dir', missing.dir]);
|
|
122
|
+
assert.equal(notFound.status, 1, '404 是业务失败,不是「现在做不了」');
|
|
123
|
+
assert.match(notFound.stderr, /派单设置操作失败: 404 unknown-approval/);
|
|
124
|
+
|
|
125
|
+
const off = await startAdmin(t, () => ({ status: 501, body: { error: 'dispatch-unsupported' } }));
|
|
126
|
+
const unsupported = await runAsync(['dispatch', 'reject', 'ap-4', '--url', off.url, '--state-dir', off.dir]);
|
|
127
|
+
assert.equal(unsupported.status, 2, '501 = 本机还没有门面');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('dispatch approve 缺 id:直接报用法,一个请求都不发', async (t) => {
|
|
131
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true } }));
|
|
132
|
+
const out = await runAsync(['dispatch', 'approve', '--url', admin.url, '--state-dir', admin.dir]);
|
|
133
|
+
assert.equal(out.status, 1);
|
|
134
|
+
assert.match(out.stderr, /需要 approvalId/);
|
|
135
|
+
assert.equal(admin.seen.length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('dispatch settings:只读时 GET dispatch/settings,打印默认/上限/工作区清单(含 path)', async (t) => {
|
|
139
|
+
const admin = await startAdmin(t, () => ({ body: PREFS }));
|
|
140
|
+
const out = await runAsync(['dispatch', 'settings', '--url', admin.url, '--state-dir', admin.dir]);
|
|
141
|
+
|
|
142
|
+
assert.equal(out.status, 0, out.stderr);
|
|
143
|
+
assert.equal(admin.seen[0].method, 'GET');
|
|
144
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dispatch/settings');
|
|
145
|
+
assert.match(out.stdout, /^默认工作区 main\t最大授权 workspace-write\t工作区 2 个$/m);
|
|
146
|
+
assert.match(out.stdout, /^ main\t主工作区\tD:\/work\/main$/m);
|
|
147
|
+
assert.match(out.stdout, /^ docs\t文档\tD:\/work\/docs$/m);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('dispatch settings --default X --max-perm Y:POST 只带改动的字段;非法档位在本地挡住', async (t) => {
|
|
151
|
+
const admin = await startAdmin(t, (call) => ({ body: { ...PREFS, ...call.body } }));
|
|
152
|
+
const out = await runAsync([
|
|
153
|
+
'dispatch', 'settings', '--default', 'docs', '--max-perm', 'read-only',
|
|
154
|
+
'--url', admin.url, '--state-dir', admin.dir,
|
|
155
|
+
]);
|
|
156
|
+
assert.equal(out.status, 0, out.stderr);
|
|
157
|
+
assert.equal(admin.seen[0].method, 'POST');
|
|
158
|
+
assert.deepEqual(admin.seen[0].body, { defaultWorkspaceId: 'docs', maxPermission: 'read-only' });
|
|
159
|
+
assert.match(out.stdout, /^派单设置已保存: 默认 docs · 最大授权 read-only · 工作区 2 个$/m);
|
|
160
|
+
|
|
161
|
+
const bad = await runAsync([
|
|
162
|
+
'dispatch', 'settings', '--max-perm', 'root', '--url', admin.url, '--state-dir', admin.dir,
|
|
163
|
+
]);
|
|
164
|
+
assert.equal(bad.status, 1);
|
|
165
|
+
assert.match(bad.stderr, /--max-perm 只能是 read-only \| workspace-write \| danger-full-access/);
|
|
166
|
+
assert.equal(admin.seen.length, 1, '非法档位不发第二个请求');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('dm <peer> <正文> --workspace/--perm:body 带两个字段,打印实际生效的那组', async (t) => {
|
|
170
|
+
const admin = await startAdmin(t, () => ({
|
|
171
|
+
body: {
|
|
172
|
+
ok: true, id: 'm-5', sessionId: 's-1', reply: '只读跑完了', elapsedMs: 30, reused: true,
|
|
173
|
+
workspace: { id: 'docs', name: '文档' }, permission: 'read-only',
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
const out = await runAsync([
|
|
177
|
+
'dm', '书房', '看下文档目录', '--workspace', 'docs', '--perm', 'read-only',
|
|
178
|
+
'--url', admin.url, '--state-dir', admin.dir,
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
assert.equal(out.status, 0, out.stderr);
|
|
182
|
+
assert.deepEqual(admin.seen[0].body, {
|
|
183
|
+
peerId: PEER_ID, text: '看下文档目录', workspaceId: 'docs', permission: 'read-only',
|
|
184
|
+
});
|
|
185
|
+
assert.match(out.stdout, /workspace=docs perm=read-only$/m);
|
|
186
|
+
assert.doesNotMatch(out.stderr, /看下文档目录/, '正文不进 stderr');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('dm 完全权限:202 待确认时打印「等待对方确认」而不是假装有回复', async (t) => {
|
|
190
|
+
const admin = await startAdmin(t, () => ({
|
|
191
|
+
status: 202,
|
|
192
|
+
body: { ok: true, pendingApproval: true, approvalId: 'ap-7', expiresAt: Date.now() + 600_000 },
|
|
193
|
+
}));
|
|
194
|
+
const out = await runAsync([
|
|
195
|
+
'dm', '书房', '把构建跑一遍', '--perm', 'danger-full-access',
|
|
196
|
+
'--url', admin.url, '--state-dir', admin.dir,
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
assert.equal(out.status, 0, out.stderr);
|
|
200
|
+
assert.match(out.stdout, /^已发给 书房:等待对方确认这次派单(approval ap-7,剩余 \d+ 秒)$/m);
|
|
201
|
+
assert.match(out.stdout, /^approvalId=ap-7 state=pending-approval$/m);
|
|
202
|
+
assert.equal(/回复:/.test(out.stdout), false, '还没执行就没有回复可说');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('dm 不带工作区与档位:请求体与以前一模一样(契约 §2 的「都不传」)', async (t) => {
|
|
206
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true, id: 'm-6', sessionId: 's-1', reply: 'ok', elapsedMs: 3, reused: false } }));
|
|
207
|
+
const out = await runAsync(['dm', '书房', '在吗', '--url', admin.url, '--state-dir', admin.dir]);
|
|
208
|
+
assert.equal(out.status, 0, out.stderr);
|
|
209
|
+
assert.deepEqual(admin.seen[0].body, { peerId: PEER_ID, text: '在吗' });
|
|
210
|
+
assert.match(out.stdout, /^id=m-6 sessionId=s-1 reused=false elapsedMs=3$/m);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('语言钉住:每个 client/cli*.test.mjs 都显式设了 DSH_LAN_PEERS_LANG=zh', async () => {
|
|
214
|
+
// 中文正则断言若依赖机器 locale,换到 en-US 机器会整体翻成英文而失败;
|
|
215
|
+
// 这里把「必须显式钉住」变成一条断言,防止以后新加的 CLI 用例又漂回去。
|
|
216
|
+
const dir = path.dirname(CLI);
|
|
217
|
+
const files = fs.readdirSync(dir).filter((name) => /^cli.*\.test\.mjs$/.test(name));
|
|
218
|
+
assert.ok(files.length >= 8, '至少覆盖现有的 8 个 CLI 测试文件');
|
|
219
|
+
for (const name of files) {
|
|
220
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
221
|
+
assert.match(text, /DSH_LAN_PEERS_LANG['\]]?\s*[:=]\s*'zh'/, name + ' 必须把语言钉成 zh');
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// v0.9.0-α:dispatch settings 的会话复用三元键(值与面板、管理接口同一份 config.json)。
|
|
227
|
+
// 口径:不带参数就显示(含单位);带了就只发那几个键;看不懂的输入本地挡住、非零退出。
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
const REUSE_PREFS = {
|
|
230
|
+
...PREFS,
|
|
231
|
+
sessionReuseTtlMs: 86_400_000,
|
|
232
|
+
sessionNewApprovalWindowMs: 604_800_000,
|
|
233
|
+
sessionNewRequestAllowed: false,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
test('dispatch settings:只读时打印会话复用三值(带单位),老宿主没有这三个键就不打这一行', async (t) => {
|
|
237
|
+
const admin = await startAdmin(t, () => ({ body: REUSE_PREFS }));
|
|
238
|
+
const out = await runAsync(['dispatch', 'settings', '--url', admin.url, '--state-dir', admin.dir]);
|
|
239
|
+
assert.equal(out.status, 0, out.stderr);
|
|
240
|
+
assert.match(out.stdout, /^会话复用 24 小时 · 新建批准窗 7 天 · 接受新会话请求 否$/m);
|
|
241
|
+
// 既有的默认/上限/清单三行一个字都没变。
|
|
242
|
+
assert.match(out.stdout, /^默认工作区 main\t最大授权 workspace-write\t工作区 2 个$/m);
|
|
243
|
+
assert.match(out.stdout, /^ docs\t文档\tD:\/work\/docs$/m);
|
|
244
|
+
|
|
245
|
+
const old = await startAdmin(t, () => ({ body: PREFS }));
|
|
246
|
+
const oldOut = await runAsync(['dispatch', 'settings', '--url', old.url, '--state-dir', old.dir]);
|
|
247
|
+
assert.equal(oldOut.status, 0);
|
|
248
|
+
assert.equal(/会话复用/.test(oldOut.stdout), false, '老宿主没有这三个键,不假装是「关闭」');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('dispatch settings --reuse-ttl/--approval-window/--allow-new-request:POST 只带这几个键并回显', async (t) => {
|
|
252
|
+
const admin = await startAdmin(t, () => ({
|
|
253
|
+
body: {
|
|
254
|
+
...PREFS,
|
|
255
|
+
sessionReuseTtlMs: 604_800_000,
|
|
256
|
+
sessionNewApprovalWindowMs: 86_400_000,
|
|
257
|
+
sessionNewRequestAllowed: false,
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
const out = await runAsync([
|
|
261
|
+
'dispatch', 'settings',
|
|
262
|
+
'--reuse-ttl', '7d', '--approval-window', '1d', '--allow-new-request', 'false',
|
|
263
|
+
'--url', admin.url, '--state-dir', admin.dir,
|
|
264
|
+
]);
|
|
265
|
+
assert.equal(out.status, 0, out.stderr);
|
|
266
|
+
assert.equal(admin.seen[0].method, 'POST');
|
|
267
|
+
assert.deepEqual(admin.seen[0].body, {
|
|
268
|
+
sessionReuseTtlMs: 604_800_000,
|
|
269
|
+
sessionNewApprovalWindowMs: 86_400_000,
|
|
270
|
+
sessionNewRequestAllowed: false,
|
|
271
|
+
});
|
|
272
|
+
assert.match(out.stdout, /^派单设置已保存: 默认 main · 最大授权 workspace-write · 工作区 2 个$/m);
|
|
273
|
+
assert.match(out.stdout, /^会话复用 7 天 · 新建批准窗 24 小时 · 接受新会话请求 否$/m);
|
|
274
|
+
|
|
275
|
+
// off / 0 都是「关闭」,纯毫秒照收。
|
|
276
|
+
const off = await startAdmin(t, (call) => ({ body: { ...REUSE_PREFS, ...call.body } }));
|
|
277
|
+
const offOut = await runAsync([
|
|
278
|
+
'dispatch', 'settings', '--reuse-ttl', 'off', '--approval-window', '604800000',
|
|
279
|
+
'--url', off.url, '--state-dir', off.dir,
|
|
280
|
+
]);
|
|
281
|
+
assert.equal(offOut.status, 0, offOut.stderr);
|
|
282
|
+
assert.deepEqual(off.seen[0].body, { sessionReuseTtlMs: 0, sessionNewApprovalWindowMs: 604_800_000 });
|
|
283
|
+
assert.match(offOut.stdout, /^会话复用 关闭 · 新建批准窗 7 天/m);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('dispatch settings:看不懂或越界的会话复用输入本地挡住,非零退出且一个请求都不发', async (t) => {
|
|
287
|
+
const admin = await startAdmin(t, () => ({ body: REUSE_PREFS }));
|
|
288
|
+
for (const args of [
|
|
289
|
+
['--reuse-ttl', '45d'],
|
|
290
|
+
['--reuse-ttl', 'abc'],
|
|
291
|
+
['--approval-window', '12y'],
|
|
292
|
+
['--allow-new-request', 'maybe'],
|
|
293
|
+
]) {
|
|
294
|
+
const out = await runAsync(['dispatch', 'settings', ...args, '--url', admin.url, '--state-dir', admin.dir]);
|
|
295
|
+
assert.equal(out.status, 1, args.join(' ') + ' 必须以 1 退出');
|
|
296
|
+
assert.equal(out.stderr.includes('at '), false, '不许甩栈:' + out.stderr);
|
|
297
|
+
assert.match(out.stderr, /--reuse-ttl 只认|--approval-window 只认|--allow-new-request 只认/);
|
|
298
|
+
}
|
|
299
|
+
assert.equal(admin.seen.length, 0, '非法输入不发请求');
|
|
300
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// dsh-lan-peers · v0.8.5-α(契约 §5,Lane G2)CLI:dm <peer> <正文> / dm log / dm sync。
|
|
2
|
+
//
|
|
3
|
+
// 三条命令都只打本机回环管理接口(/api/lan-peers/dm*),所以这里起一个只实现那几条
|
|
4
|
+
// 路由的 node:http 桩,真跑 cli.mjs 子进程,断言请求形状(含 token 头)、stdout 文案与
|
|
5
|
+
// 退出码:401/403/409/501 归「本机现在做不了」(exit 2),其余算失败(exit 1)。
|
|
6
|
+
// 铁律(契约 §0):正文可以进 stdout(用户自己要看的),但绝不进 stderr/日志。
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { listenOnRandomPort } from '../test-support/ports.mjs';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { makeTempStateDir, writeJson0600 } from '../host/test/helpers.mjs';
|
|
16
|
+
|
|
17
|
+
process.env.DSH_LAN_PEERS_LANG = 'zh'; // 语言钉死:断言写的是中文文案,不随机器 locale 漂移
|
|
18
|
+
|
|
19
|
+
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.mjs');
|
|
20
|
+
const TOKEN = 'cli-dm-token';
|
|
21
|
+
const PEER_ID = 'peer-b-1234';
|
|
22
|
+
|
|
23
|
+
function runAsync(args, env = {}) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
execFile(process.execPath, [CLI, ...args], { encoding: 'utf8', env: { ...process.env, ...env } },
|
|
26
|
+
(error, stdout, stderr) => {
|
|
27
|
+
resolve({ status: error ? error.code : 0, stdout, stderr });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 只实现 /api/lan-peers/dm* 的管理接口桩;handler(call) 决定状态码与响应体。 */
|
|
33
|
+
async function startAdmin(t, handler, { token = TOKEN } = {}) {
|
|
34
|
+
const seen = [];
|
|
35
|
+
const server = http.createServer((req, res) => {
|
|
36
|
+
let raw = '';
|
|
37
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
const call = {
|
|
40
|
+
method: req.method,
|
|
41
|
+
url: req.url,
|
|
42
|
+
token: req.headers['x-lan-peers-token'] ?? '',
|
|
43
|
+
contentType: req.headers['content-type'] ?? '',
|
|
44
|
+
body: raw === '' ? null : JSON.parse(raw),
|
|
45
|
+
};
|
|
46
|
+
seen.push(call);
|
|
47
|
+
const reply = handler(call) ?? {};
|
|
48
|
+
res.writeHead(reply.status ?? 200, { 'Content-Type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify(reply.body ?? {}));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
const { port } = await listenOnRandomPort(server);
|
|
53
|
+
t.after(() => new Promise((resolve) => { server.close(() => resolve()); }));
|
|
54
|
+
|
|
55
|
+
const dir = makeTempStateDir('dsh-lan-peers-cli-dm-');
|
|
56
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
57
|
+
writeJson0600(path.join(dir, 'peers.json'), {
|
|
58
|
+
peers: { [PEER_ID]: { name: '书房', baseUrl: 'http://10.0.0.2:7788', secret: 's', inboundSecret: 'i' } },
|
|
59
|
+
});
|
|
60
|
+
if (token !== '') fs.writeFileSync(path.join(dir, 'admin-token'), token + '\n', { mode: 0o600 });
|
|
61
|
+
return { seen, url: 'http://127.0.0.1:' + port, dir };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const AT = 1_760_000_000_000;
|
|
65
|
+
const HISTORY = [
|
|
66
|
+
{ id: 'm-1', ts: AT, from: 'self', text: '我叫你小助手', sessionId: 's-1', state: 'sent' },
|
|
67
|
+
{ id: 'm-2', ts: AT + 900, from: 'peer', text: '好的,记住了', sessionId: 's-1', state: 'sent' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// dm <peer> <正文>
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test('dm <peer> <正文>:POST dm/send 带 token,打印回复与 sessionId/reused', async (t) => {
|
|
75
|
+
const admin = await startAdmin(t, () => ({
|
|
76
|
+
body: { ok: true, id: 'm-3', sessionId: 's-1', reply: '还记得,你让我叫小助手', elapsedMs: 1227, reused: true },
|
|
77
|
+
}));
|
|
78
|
+
const out = await runAsync(['dm', '书房', '我刚才让你叫什么?', '--url', admin.url, '--state-dir', admin.dir]);
|
|
79
|
+
|
|
80
|
+
assert.equal(out.status, 0, out.stderr);
|
|
81
|
+
assert.match(out.stdout, /^回复: 还记得,你让我叫小助手$/m);
|
|
82
|
+
assert.match(out.stdout, /^id=m-3 sessionId=s-1 reused=true elapsedMs=1227$/m);
|
|
83
|
+
assert.match(out.stderr, /发送中 → 书房 · 27 字节/, '先把「往哪发、多大」说清楚');
|
|
84
|
+
|
|
85
|
+
assert.equal(admin.seen.length, 1);
|
|
86
|
+
assert.equal(admin.seen[0].method, 'POST');
|
|
87
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dm/send');
|
|
88
|
+
assert.deepEqual(admin.seen[0].body, { peerId: PEER_ID, text: '我刚才让你叫什么?' }, 'CLI 传 peerId,不传名字');
|
|
89
|
+
assert.equal(admin.seen[0].token, TOKEN, '管理接口 token 从 state-dir 读出来带上');
|
|
90
|
+
assert.doesNotMatch(out.stderr, /我刚才让你叫什么/, '正文不得进 stderr(日志口径只记字节数)');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('dm <peer> <正文>:本机就能拦下的错误不发请求(未知对端 / 空正文 / 缺参数)', async (t) => {
|
|
94
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true } }));
|
|
95
|
+
|
|
96
|
+
const unknown = await runAsync(['dm', '不存在的伙伴', '在吗', '--url', admin.url, '--state-dir', admin.dir]);
|
|
97
|
+
assert.equal(unknown.status, 1);
|
|
98
|
+
assert.match(unknown.stderr, /未知对端: 不存在的伙伴/);
|
|
99
|
+
|
|
100
|
+
const blank = await runAsync(['dm', '书房', ' ', '--url', admin.url, '--state-dir', admin.dir]);
|
|
101
|
+
assert.equal(blank.status, 1);
|
|
102
|
+
assert.match(blank.stderr, /用法: dsh-lan-peers dm <peer> <正文>/);
|
|
103
|
+
|
|
104
|
+
const missing = await runAsync(['dm', '书房', '--url', admin.url, '--state-dir', admin.dir]);
|
|
105
|
+
assert.equal(missing.status, 1);
|
|
106
|
+
assert.match(missing.stderr, /用法: dsh-lan-peers dm <peer> <正文>/);
|
|
107
|
+
|
|
108
|
+
assert.equal(admin.seen.length, 0, '本地就能拦下的错误不发请求');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// dm log <peer>
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test('dm log <peer> --limit N:GET dm?peer=,只打印最近 N 条', async (t) => {
|
|
116
|
+
const admin = await startAdmin(t, () => ({ body: { peer: { peerId: PEER_ID, name: '书房' }, messages: HISTORY } }));
|
|
117
|
+
const out = await runAsync(['dm', 'log', '书房', '--limit', '1', '--url', admin.url, '--state-dir', admin.dir]);
|
|
118
|
+
|
|
119
|
+
assert.equal(out.status, 0, out.stderr);
|
|
120
|
+
assert.equal(admin.seen.length, 1);
|
|
121
|
+
assert.equal(admin.seen[0].method, 'GET');
|
|
122
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dm?peer=' + PEER_ID);
|
|
123
|
+
assert.equal(admin.seen[0].token, TOKEN);
|
|
124
|
+
|
|
125
|
+
const lines = out.stdout.trim().split('\n');
|
|
126
|
+
assert.equal(lines.length, 1, '--limit 1 只打印一条');
|
|
127
|
+
assert.equal(lines[0].split('\t')[1], '对方');
|
|
128
|
+
assert.equal(lines[0].split('\t')[2], 'm-2');
|
|
129
|
+
assert.match(lines[0], /好的,记住了$/);
|
|
130
|
+
assert.match(out.stderr, /共 2 条,打印最近 1 条/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('dm log <peer>:默认打印全部(不超过 20 条),没有记录时也给出确定输出', async (t) => {
|
|
134
|
+
const admin = await startAdmin(t, () => ({ body: { peer: { peerId: PEER_ID, name: '书房' }, messages: HISTORY } }));
|
|
135
|
+
const out = await runAsync(['dm', 'log', '书房', '--url', admin.url, '--state-dir', admin.dir]);
|
|
136
|
+
assert.equal(out.status, 0, out.stderr);
|
|
137
|
+
const lines = out.stdout.trim().split('\n');
|
|
138
|
+
assert.equal(lines.length, 2);
|
|
139
|
+
assert.equal(lines[0].split('\t')[1], '我');
|
|
140
|
+
assert.match(lines[0], /^\d{4}-\d{2}-\d{2}T/);
|
|
141
|
+
assert.match(out.stderr.trim(), /共 2 条$/);
|
|
142
|
+
|
|
143
|
+
const empty = await startAdmin(t, () => ({ body: { peer: { peerId: PEER_ID, name: '书房' }, messages: [] } }));
|
|
144
|
+
const none = await runAsync(['dm', 'log', '书房', '--url', empty.url, '--state-dir', empty.dir]);
|
|
145
|
+
assert.equal(none.status, 0, none.stderr);
|
|
146
|
+
assert.equal(none.stdout, '');
|
|
147
|
+
assert.match(none.stderr, /共 0 条/);
|
|
148
|
+
|
|
149
|
+
const bad = await runAsync(['dm', 'log', '书房', '--limit', 'abc', '--url', admin.url, '--state-dir', admin.dir]);
|
|
150
|
+
assert.equal(bad.status, 1);
|
|
151
|
+
assert.match(bad.stderr, /--limit 必须是正整数/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// dm sync <peer>
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test('dm sync <peer>:POST dm/sync { peerId },打印 added/total', async (t) => {
|
|
159
|
+
const admin = await startAdmin(t, () => ({ body: { ok: true, added: 3, total: 5 } }));
|
|
160
|
+
const out = await runAsync(['dm', 'sync', '书房', '--url', admin.url, '--state-dir', admin.dir]);
|
|
161
|
+
|
|
162
|
+
assert.equal(out.status, 0, out.stderr);
|
|
163
|
+
assert.equal(out.stdout.trim(), '已对齐 added=3 total=5');
|
|
164
|
+
assert.equal(admin.seen.length, 1);
|
|
165
|
+
assert.equal(admin.seen[0].url, '/api/lan-peers/dm/sync');
|
|
166
|
+
assert.equal(admin.seen[0].method, 'POST');
|
|
167
|
+
assert.deepEqual(admin.seen[0].body, { peerId: PEER_ID });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('失败口径:501/409 归「本机现在做不了」exit 2,其余 exit 1,且都不把正文带上日志', async (t) => {
|
|
171
|
+
const unsupported = await startAdmin(t, () => ({ status: 501, body: { error: 'dm-unsupported' } }));
|
|
172
|
+
const unwired = await runAsync(['dm', '书房', '在吗', '--url', unsupported.url, '--state-dir', unsupported.dir]);
|
|
173
|
+
assert.equal(unwired.status, 2);
|
|
174
|
+
assert.match(unwired.stderr, /私聊失败: 501 dm-unsupported/);
|
|
175
|
+
|
|
176
|
+
const offline = await startAdmin(t, () => ({ status: 409, body: { error: 'peer-offline' } }));
|
|
177
|
+
const down = await runAsync(['dm', 'sync', '书房', '--url', offline.url, '--state-dir', offline.dir]);
|
|
178
|
+
assert.equal(down.status, 2);
|
|
179
|
+
assert.match(down.stderr, /私聊失败: 409 peer-offline/, '目标离线要原样给出 reason');
|
|
180
|
+
|
|
181
|
+
const broken = await startAdmin(t, () => ({ status: 500, body: { error: 'boom' } }));
|
|
182
|
+
const failed = await runAsync(['dm', '书房', '在吗', '--url', broken.url, '--state-dir', broken.dir]);
|
|
183
|
+
assert.equal(failed.status, 1);
|
|
184
|
+
assert.match(failed.stderr, /私聊失败: 500 boom/);
|
|
185
|
+
assert.doesNotMatch(failed.stderr, /在吗/, '正文不得进错误输出');
|
|
186
|
+
});
|