dsh-qq-digest 0.1.0 → 0.2.0
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 +39 -11
- package/install/cordis.patch.fragment.yml +4 -1
- package/lib/index.js +107 -19
- package/package.json +4 -2
- package/scripts/check-all.mjs +169 -0
- package/scripts/check-core-purity.mjs +70 -0
- package/scripts/final-check.mjs +197 -0
- package/scripts/host-smoke.mjs +235 -0
- package/scripts/install.mjs +128 -0
- package/scripts/link-profile.mjs +96 -0
- package/scripts/smoke.mjs +115 -0
- package/scripts/trigger.mjs +67 -0
- package/scripts/verify-dsh.mjs +110 -0
- package/src/core/gate.js +16 -2
- package/src/mcp-server.js +15 -2
- package/src/run-lock.js +35 -3
- package/src/runtime.js +65 -9
- package/src/service.js +57 -7
- package/src/store-file.js +80 -13
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 发布前/后的全量自检:仓库、脱敏、测试、npm、GitHub、DSH 运行态,一次跑完。
|
|
4
|
+
*
|
|
5
|
+
* 用法:node scripts/final-check.mjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
let failures = 0;
|
|
16
|
+
const ok = (label, detail) => console.log(`PASS ${label}${detail ? ` ${detail}` : ''}`);
|
|
17
|
+
const bad = (label, detail) => { console.log(`FAIL ${label}${detail ? ` ${detail}` : ''}`); failures += 1; };
|
|
18
|
+
const info = (label, detail) => console.log(`INFO ${label}${detail ? ` ${detail}` : ''}`);
|
|
19
|
+
|
|
20
|
+
function run(cmd, args, options = {}) {
|
|
21
|
+
try {
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
out: execFileSync(cmd, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim(),
|
|
25
|
+
};
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return { ok: false, out: String(err.stdout ?? '').trim(), err: String(err.stderr ?? err.message).trim() };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── 1. 仓库 ──────────────────────────────────────────────────────────────
|
|
32
|
+
const log = run('git', ['log', '--oneline']);
|
|
33
|
+
if (log.ok && log.out) {
|
|
34
|
+
const commits = log.out.split('\n');
|
|
35
|
+
ok('git 提交', `${commits.length} 个:${commits[0]}`);
|
|
36
|
+
} else bad('git 提交读不到');
|
|
37
|
+
|
|
38
|
+
const status = run('git', ['status', '--porcelain']);
|
|
39
|
+
if (status.ok) {
|
|
40
|
+
if (status.out === '') ok('工作区干净');
|
|
41
|
+
else bad('工作区有未提交改动', status.out.split('\n').slice(0, 3).join(' | '));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tracked = run('git', ['ls-files']);
|
|
45
|
+
const fileCount = tracked.ok ? tracked.out.split('\n').filter(Boolean).length : 0;
|
|
46
|
+
info('跟踪文件', `${fileCount} 个`);
|
|
47
|
+
|
|
48
|
+
const remote = run('git', ['remote', '-v']);
|
|
49
|
+
info('remote', remote.ok && remote.out ? remote.out.split('\n')[0] : '(未配置)');
|
|
50
|
+
|
|
51
|
+
// ── 2. 脱敏 ──────────────────────────────────────────────────────────────
|
|
52
|
+
// 敏感词**不能写在这个文件里**:本文件随 npm 包一起发布(package.json files 含 scripts),
|
|
53
|
+
// 把真实 QQ 号/群号写进来,等于用「检查泄漏的脚本」本身泄漏一遍,而且它还会命中自己,
|
|
54
|
+
// 于是永远报 4 项 FAIL。现在改为外部注入:
|
|
55
|
+
// QQ_DIGEST_SENSITIVE_PATTERNS="123456,88888888" node scripts/final-check.mjs
|
|
56
|
+
const PATTERNS = String(process.env.QQ_DIGEST_SENSITIVE_PATTERNS ?? '')
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((value) => value.trim())
|
|
59
|
+
.filter((value) => value !== '');
|
|
60
|
+
|
|
61
|
+
// 排除检查器自身:否则只要配了词,就必然命中这个文件。
|
|
62
|
+
const GREP_EXCLUDE = [':(exclude)scripts/final-check.mjs'];
|
|
63
|
+
|
|
64
|
+
let leak = 0;
|
|
65
|
+
if (PATTERNS.length === 0) {
|
|
66
|
+
info('脱敏', '未设置 QQ_DIGEST_SENSITIVE_PATTERNS,已跳过(仓库内不得残留个人标识)');
|
|
67
|
+
} else {
|
|
68
|
+
for (const pattern of PATTERNS) {
|
|
69
|
+
const hit = run('git', ['grep', '-n', pattern, '--', '.', ...GREP_EXCLUDE]);
|
|
70
|
+
if (hit.ok && hit.out !== '') {
|
|
71
|
+
bad(`仓库里仍有 ${pattern}`, hit.out.split('\n').slice(0, 2).join(' | '));
|
|
72
|
+
leak += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (leak === 0) ok('仓库内无个人标识(QQ/群号/用户名)');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bridgePath = join(root, 'src', 'adapters', 'bridge.js');
|
|
79
|
+
const bridgeSrc = run('node', ['-e', `process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(bridgePath)},'utf8'))`]);
|
|
80
|
+
if (bridgeSrc.ok && /resolveBridgePaths/.test(bridgeSrc.out)) ok('桥接路径可解析(无硬编码目录)');
|
|
81
|
+
else bad('bridge.js 仍在硬编码路径');
|
|
82
|
+
|
|
83
|
+
// ── 3. 测试 ──────────────────────────────────────────────────────────────
|
|
84
|
+
const tests = run('node', ['--test']);
|
|
85
|
+
const summary = (tests.out.match(/# tests (\d+)/) ?? tests.out.match(/ℹ tests (\d+)/) ?? [])[1];
|
|
86
|
+
const failed = (tests.out.match(/# fail (\d+)/) ?? tests.out.match(/ℹ fail (\d+)/) ?? [])[1];
|
|
87
|
+
if (tests.ok && failed === '0') ok('单元/集成测试', `${summary} 用例全过`);
|
|
88
|
+
else bad('测试失败', `fail=${failed ?? '?'}`);
|
|
89
|
+
|
|
90
|
+
const purity = run('node', ['scripts/check-core-purity.mjs']);
|
|
91
|
+
if (purity.ok) ok('core 纯净性', purity.out.split('\n')[0].slice(0, 60));
|
|
92
|
+
else bad('core 纯净性检查失败', purity.out);
|
|
93
|
+
|
|
94
|
+
const smoke = run('node', ['scripts/host-smoke.mjs']);
|
|
95
|
+
const smokeLine = (smoke.out.match(/(\d+)\/(\d+) 通过/) ?? []).slice(1).join('/');
|
|
96
|
+
if (smoke.ok && smokeLine && smokeLine.split('/')[0] === smokeLine.split('/')[1]) ok('宿主冒烟', `${smokeLine} 通过`);
|
|
97
|
+
else bad('宿主冒烟失败', smokeLine || smoke.out.slice(-200));
|
|
98
|
+
|
|
99
|
+
// ── 4. npm ──────────────────────────────────────────────────────────────
|
|
100
|
+
const whoami = run('npm', ['whoami'], { shell: true });
|
|
101
|
+
info('npm 登录', whoami.ok ? whoami.out : '(未登录)');
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch('https://registry.npmjs.org/dsh-qq-digest', { signal: AbortSignal.timeout(15000) });
|
|
105
|
+
if (res.status === 404) {
|
|
106
|
+
info('npm 包', '尚未创建');
|
|
107
|
+
} else {
|
|
108
|
+
const meta = await res.json();
|
|
109
|
+
const versions = Object.keys(meta.versions ?? {});
|
|
110
|
+
const latest = meta['dist-tags']?.latest;
|
|
111
|
+
info('npm 版本', versions.join(', ') || '(无)');
|
|
112
|
+
info('npm latest', String(latest));
|
|
113
|
+
info('npm maintainer', (meta.maintainers ?? []).map((m) => `${m.name}<${m.email}>`).join(', ') || '(无)');
|
|
114
|
+
|
|
115
|
+
if (versions.some((v) => v !== '0.0.0-stage')) {
|
|
116
|
+
ok('npm 已有正式版本', versions.filter((v) => v !== '0.0.0-stage').join(', '));
|
|
117
|
+
} else {
|
|
118
|
+
bad('npm 只有占位版 0.0.0-stage —— 暂存版本仍未批准', String(latest));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
bad('npm registry 查询失败', String(err.message));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 5. GitHub ───────────────────────────────────────────────────────────
|
|
126
|
+
const lsRemote = run('git', ['ls-remote', 'origin'], {
|
|
127
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
128
|
+
timeout: 20000,
|
|
129
|
+
});
|
|
130
|
+
if (lsRemote.ok && lsRemote.out !== '') {
|
|
131
|
+
const heads = lsRemote.out.split('\n').filter((line) => line.includes('refs/heads/'));
|
|
132
|
+
ok('GitHub 仓库可达', heads.length ? heads.map((h) => h.split('refs/heads/')[1]).join(',') : '(空仓库)');
|
|
133
|
+
} else if (lsRemote.ok) {
|
|
134
|
+
info('GitHub 仓库', '存在但为空,可以直接 push');
|
|
135
|
+
} else {
|
|
136
|
+
bad('GitHub 仓库不可达', lsRemote.err.split('\n').slice(0, 2).join(' '));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 猜不出端口就等于误报。这里从 DSH 自己留下的痕迹里取:
|
|
141
|
+
* 1) QQ_DIGEST_PORT(显式)
|
|
142
|
+
* 2) ~/.dsh/dsh-process.json 里记录的端口
|
|
143
|
+
* 3) 几个历史默认端口兜底
|
|
144
|
+
*/
|
|
145
|
+
function detectDshPorts() {
|
|
146
|
+
const ports = new Set([8784, 1402, 14802]);
|
|
147
|
+
try {
|
|
148
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
|
|
149
|
+
? process.env.DSH_HOME.trim()
|
|
150
|
+
: join(homedir(), '.dsh');
|
|
151
|
+
const parsed = JSON.parse(readFileSync(join(home, 'dsh-process.json'), 'utf8'));
|
|
152
|
+
for (const value of [parsed?.port, parsed?.webPort, parsed?.web?.port]) {
|
|
153
|
+
if (Number.isInteger(value) && value > 0) ports.add(value);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// 没有就只试兜底端口
|
|
157
|
+
}
|
|
158
|
+
return [...ports];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 6. DSH 运行态 ───────────────────────────────────────────────────────
|
|
162
|
+
// DSH 的 Web 端口每次重启都可能变(同一台机器实测过 4845 / 1875 / 4260),
|
|
163
|
+
// 因此优先用显式指定,其次才试历史端口,最后直接问 DSH 的进程信息。
|
|
164
|
+
const explicitPort = process.env.QQ_DIGEST_PORT
|
|
165
|
+
|| (process.argv.find((arg) => arg.startsWith('--port=')) ?? '').split('=')[1]
|
|
166
|
+
|| '';
|
|
167
|
+
const candidatePorts = [
|
|
168
|
+
...(explicitPort === '' ? [] : [Number(explicitPort)]),
|
|
169
|
+
...detectDshPorts(),
|
|
170
|
+
];
|
|
171
|
+
let base = null;
|
|
172
|
+
for (const port of candidatePorts) {
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(`http://127.0.0.1:${port}/qq-digest-api/state`, {
|
|
175
|
+
headers: { accept: 'application/json' },
|
|
176
|
+
signal: AbortSignal.timeout(5000),
|
|
177
|
+
});
|
|
178
|
+
if (res.status === 200) { base = `http://127.0.0.1:${port}`; break; }
|
|
179
|
+
} catch { /* 下一个端口 */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (base === null) {
|
|
183
|
+
bad('DSH 里插件没在跑', `试过端口:${candidatePorts.join(', ') || '(无)'};可用 QQ_DIGEST_PORT=<端口> 指定`);
|
|
184
|
+
} else {
|
|
185
|
+
const state = await (await fetch(`${base}/qq-digest-api/state`, { headers: { accept: 'application/json' } })).json();
|
|
186
|
+
ok('宿主半在线', base);
|
|
187
|
+
if (typeof state.groupsEnabled === 'boolean') ok('群聊总开关在位', String(state.groupsEnabled));
|
|
188
|
+
else bad('宿主半是旧版本(缺 groupsEnabled)—— 需要重启 DSH');
|
|
189
|
+
if (Array.isArray(state.privateRooms)) {
|
|
190
|
+
ok('私聊列表中', `${state.privateRooms.length} 条,打开 ${state.privateRooms.filter((r) => r.enabled).length} 条`);
|
|
191
|
+
} else bad('宿主半是旧版本(缺 privateRooms)');
|
|
192
|
+
info('群数', String((state.rooms ?? []).length));
|
|
193
|
+
info('客户端心跳', state.clientBoot ? (state.clientBoot.mountedAt ? '已注册设置页' : `未注册(${state.clientBoot.lastError ?? state.clientBoot.lastPhase})`) : '(旧版宿主无此字段)');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
|
197
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 宿主半冒烟:不启动 DSH,直接用一个假 ctx 把 apply() 跑起来,
|
|
4
|
+
* 再对着它注册的 HTTP 路由发真实的 req/res 对象。
|
|
5
|
+
*
|
|
6
|
+
* 这能提前抓到「路由没注册 / 路径写错 / JSON 形状不对 / 私聊没被拒」,
|
|
7
|
+
* 不必等到重启 DSH 之后才在设置页里发现。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
|
|
15
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'qq-digest-host-'));
|
|
16
|
+
process.env.QQ_DIGEST_DATA_DIR = dataDir;
|
|
17
|
+
process.env.QQ_DIGEST_SOURCE = process.env.QQ_DIGEST_SOURCE || 'fixture';
|
|
18
|
+
|
|
19
|
+
const { apply } = await import('../lib/index.js');
|
|
20
|
+
|
|
21
|
+
let captured = null;
|
|
22
|
+
const ctx = {
|
|
23
|
+
webServer: {
|
|
24
|
+
register(options) {
|
|
25
|
+
captured = options;
|
|
26
|
+
return () => {};
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
effect(fn) {
|
|
30
|
+
const disposer = fn();
|
|
31
|
+
return () => {};
|
|
32
|
+
},
|
|
33
|
+
get() {
|
|
34
|
+
return undefined;
|
|
35
|
+
},
|
|
36
|
+
logger: { info() {}, warn() {} },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
apply(ctx);
|
|
40
|
+
|
|
41
|
+
if (captured === null) {
|
|
42
|
+
console.error('FAIL: 没有注册任何路由');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (captured.kind !== 'prefix' || captured.path !== '/qq-digest-api') {
|
|
46
|
+
console.error(`FAIL: 路由形状不对 ${JSON.stringify({ kind: captured.kind, path: captured.path })}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function request(handler, { method = 'GET', url, body, headers = {}, remoteAddress = '127.0.0.1' }) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const req = new Readable({ read() {} });
|
|
53
|
+
req.method = method;
|
|
54
|
+
req.url = url;
|
|
55
|
+
// 真实请求一定带 Host;写操作还带 content-type。
|
|
56
|
+
// 宿主半现在会校验 Host / Origin / Sec-Fetch-Site,所以这里要把「正常浏览器」的形状补齐。
|
|
57
|
+
req.headers = {
|
|
58
|
+
host: '127.0.0.1:4845',
|
|
59
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
60
|
+
...headers,
|
|
61
|
+
};
|
|
62
|
+
req.socket = { remoteAddress };
|
|
63
|
+
if (body !== undefined) req.push(JSON.stringify(body));
|
|
64
|
+
req.push(null);
|
|
65
|
+
|
|
66
|
+
const chunks = [];
|
|
67
|
+
const res = {
|
|
68
|
+
statusCode: 200,
|
|
69
|
+
headers: {},
|
|
70
|
+
writeHead(status, headers) {
|
|
71
|
+
this.statusCode = status;
|
|
72
|
+
this.headers = headers ?? {};
|
|
73
|
+
},
|
|
74
|
+
end(chunk) {
|
|
75
|
+
if (chunk) chunks.push(Buffer.from(chunk));
|
|
76
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
77
|
+
try {
|
|
78
|
+
resolve({ status: this.statusCode, json: JSON.parse(text) });
|
|
79
|
+
} catch {
|
|
80
|
+
reject(new Error(`非 JSON 响应:${text.slice(0, 200)}`));
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
handler(req, res);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const results = [];
|
|
90
|
+
function check(name, condition, detail) {
|
|
91
|
+
results.push({ name, ok: Boolean(condition), detail });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const state = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
95
|
+
check('GET /state 返回 200', state.status === 200, state.status);
|
|
96
|
+
check('状态里有 groups 数组', Array.isArray(state.json.rooms), typeof state.json.rooms);
|
|
97
|
+
check('只暴露群', (state.json.rooms ?? []).every((room) => /^\d+$/.test(String(room.groupId))), JSON.stringify(state.json.rooms?.slice(0, 2)));
|
|
98
|
+
check('fixture 下应看到 2 个群', (state.json.rooms ?? []).length === 2, (state.json.rooms ?? []).length);
|
|
99
|
+
check('默认群聊总开关是开的', state.json.groupsEnabled === true, state.json.groupsEnabled);
|
|
100
|
+
check('私聊单独成列且默认全关', Array.isArray(state.json.privateRooms) && state.json.privateRooms.length === 1 && state.json.privateRooms[0].enabled === false, JSON.stringify(state.json.privateRooms));
|
|
101
|
+
|
|
102
|
+
const saved = await request(captured.handler, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
url: '/qq-digest-api/config',
|
|
105
|
+
body: { groups: { '100000': { enabled: false } }, summarizer: 'heuristic', schedule: { enabled: true, kind: 'daily', time: '09:30', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
106
|
+
});
|
|
107
|
+
check('POST /config 保存成功', saved.status === 200 && saved.json.ok === true, saved.status);
|
|
108
|
+
check('关掉的群出现在配置里', saved.json.config?.groups?.['group:100000']?.enabled === false, JSON.stringify(saved.json.config?.groups));
|
|
109
|
+
check('定时时间被保存', saved.json.config?.schedule?.time === '09:30', saved.json.config?.schedule?.time);
|
|
110
|
+
|
|
111
|
+
const afterSave = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
112
|
+
const closed = (afterSave.json.rooms ?? []).find((room) => room.groupId === '100000');
|
|
113
|
+
check('关掉的群在列表里标记为关闭', closed?.enabled === false, JSON.stringify(closed));
|
|
114
|
+
|
|
115
|
+
const privateAttempt = await request(captured.handler, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: '/qq-digest-api/config',
|
|
118
|
+
body: { groups: { '88880001-private': { enabled: true }, 'private:88880001': { enabled: true } } },
|
|
119
|
+
});
|
|
120
|
+
check('私聊 id 被拒(400)', privateAttempt.status === 400, `${privateAttempt.status} ${JSON.stringify(privateAttempt.json)}`);
|
|
121
|
+
|
|
122
|
+
const badTime = await request(captured.handler, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: '/qq-digest-api/config',
|
|
125
|
+
body: { groups: {}, summarizer: 'heuristic', schedule: { enabled: true, kind: 'daily', time: '9:3', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
126
|
+
});
|
|
127
|
+
check('非法时间被拒(400)', badTime.status === 400, `${badTime.status} ${JSON.stringify(badTime.json)}`);
|
|
128
|
+
|
|
129
|
+
const privSave = await request(captured.handler, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
url: '/qq-digest-api/config',
|
|
132
|
+
body: { groupsEnabled: false, groups: {}, private: { '88880001': { enabled: true, name: '测试私聊' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
133
|
+
});
|
|
134
|
+
check('可以逐条打开私聊', privSave.status === 200, privSave.status);
|
|
135
|
+
check('总开关可以关掉', privSave.json.state?.groupsEnabled === false, privSave.json.state?.groupsEnabled);
|
|
136
|
+
check('私聊开关落盘', privSave.json.state?.privateRooms?.[0]?.enabled === true, JSON.stringify(privSave.json.state?.privateRooms));
|
|
137
|
+
check('私聊名字落盘', privSave.json.state?.privateRooms?.[0]?.name === '测试私聊', privSave.json.state?.privateRooms?.[0]?.name);
|
|
138
|
+
|
|
139
|
+
const blockedRun = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/run', body: {} });
|
|
140
|
+
const blockedDenied = (blockedRun.json?.result?.denied ?? []).filter((item) => item.reason === 'GROUP_DISABLED');
|
|
141
|
+
check('总开关关掉后所有群都被拒(单群覆盖也失效)', blockedDenied.length === 2, blockedDenied.length);
|
|
142
|
+
|
|
143
|
+
const restore = await request(captured.handler, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
url: '/qq-digest-api/config',
|
|
146
|
+
body: { groupsEnabled: true, groups: {}, private: { '88880001': { enabled: false } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
147
|
+
});
|
|
148
|
+
check('可以再恢复默认', restore.status === 200 && restore.json.state?.groupsEnabled === true, restore.status);
|
|
149
|
+
|
|
150
|
+
const run = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/run', body: {} });
|
|
151
|
+
check('POST /run 返回结果', run.status === 200 && typeof run.json.result?.status === 'string', JSON.stringify(run.json.result?.status));
|
|
152
|
+
|
|
153
|
+
const digest = await request(captured.handler, { url: '/qq-digest-api/digest?group=70001111' });
|
|
154
|
+
check('GET /digest 只认群号', digest.status === 200 || digest.json.digest === null, digest.status);
|
|
155
|
+
|
|
156
|
+
const privateDigest = await request(captured.handler, { url: '/qq-digest-api/digest?group=private%3A1' });
|
|
157
|
+
check('私聊句柄读不到摘要(400)', privateDigest.status === 400, privateDigest.status);
|
|
158
|
+
|
|
159
|
+
// 客户端半心跳:证明「设置页看不见」时能分清是哪一层的问题
|
|
160
|
+
const bootFactory = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/client-boot', body: { phase: 'factory' } });
|
|
161
|
+
check('POST /client-boot 接受 factory 心跳', bootFactory.status === 200, bootFactory.status);
|
|
162
|
+
const bootMounted = await request(captured.handler, { method: 'POST', url: '/qq-digest-api/client-boot', body: { phase: 'mounted', sectionId: 'qq-digest' } });
|
|
163
|
+
check('POST /client-boot 接受 mounted 心跳', bootMounted.status === 200, bootMounted.status);
|
|
164
|
+
const withBoot = await request(captured.handler, { url: '/qq-digest-api/state' });
|
|
165
|
+
check('状态里回显客户端心跳', Boolean(withBoot.json.clientBoot?.factoryAt && withBoot.json.clientBoot?.mountedAt), JSON.stringify(withBoot.json.clientBoot));
|
|
166
|
+
|
|
167
|
+
const notFound = await request(captured.handler, { url: '/qq-digest-api/nope' });
|
|
168
|
+
check('未知路径 404', notFound.status === 404, notFound.status);
|
|
169
|
+
|
|
170
|
+
// 重名:群和私聊同名必须被拒(放在最后,避免改动状态影响前面的用例)
|
|
171
|
+
const dupSave = await request(captured.handler, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
url: '/qq-digest-api/config',
|
|
174
|
+
body: { groupsEnabled: true, groups: { '100000': { enabled: true, name: '重名' } }, private: { '88880001': { enabled: false, name: '重名' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
175
|
+
});
|
|
176
|
+
check('群与私聊同名 → 400', dupSave.status === 400 && String(dupSave.json.error ?? '').includes('DUPLICATE_NAME'), `${dupSave.status} ${JSON.stringify(dupSave.json)}`);
|
|
177
|
+
|
|
178
|
+
const uniqueSave = await request(captured.handler, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
url: '/qq-digest-api/config',
|
|
181
|
+
body: { groupsEnabled: true, groups: { '100000': { enabled: true, name: '甲' } }, private: { '88880001': { enabled: false, name: '乙' } }, summarizer: 'heuristic', schedule: { enabled: false, kind: 'daily', time: '09:00', weekdays: ['MO'], timeZone: 'Asia/Shanghai' } },
|
|
182
|
+
});
|
|
183
|
+
check('名字互不相同 → 200', uniqueSave.status === 200, uniqueSave.status);
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
// ── 管理面来源校验(跨站 / rebinding / 隧道 / CSRF)──────────────────────
|
|
187
|
+
// 这些用例锁住的是「只判 remoteAddress 不够」这件事本身:
|
|
188
|
+
// 每一条的攻击前提都能让 remoteAddress 看起来是回环。
|
|
189
|
+
const evilOrigin = await request(captured.handler, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
url: '/qq-digest-api/config',
|
|
192
|
+
body: { groupsEnabled: false },
|
|
193
|
+
headers: { origin: 'https://evil.example' },
|
|
194
|
+
});
|
|
195
|
+
check('跨站 Origin → 403', evilOrigin.status === 403 && evilOrigin.json.error === 'BAD_ORIGIN', `${evilOrigin.status} ${JSON.stringify(evilOrigin.json)}`);
|
|
196
|
+
|
|
197
|
+
const evilHost = await request(captured.handler, { url: '/qq-digest-api/state', headers: { host: 'evil.example:4845' } });
|
|
198
|
+
check('伪造 Host(DNS rebinding)→ 403', evilHost.status === 403 && evilHost.json.error === 'BAD_HOST', `${evilHost.status} ${JSON.stringify(evilHost.json)}`);
|
|
199
|
+
|
|
200
|
+
const crossSite = await request(captured.handler, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
url: '/qq-digest-api/run',
|
|
203
|
+
body: {},
|
|
204
|
+
headers: { 'sec-fetch-site': 'cross-site' },
|
|
205
|
+
});
|
|
206
|
+
check('Sec-Fetch-Site: cross-site → 403', crossSite.status === 403 && crossSite.json.error === 'CROSS_SITE', `${crossSite.status} ${JSON.stringify(crossSite.json)}`);
|
|
207
|
+
|
|
208
|
+
const textPlain = await request(captured.handler, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
url: '/qq-digest-api/config',
|
|
211
|
+
body: { groupsEnabled: true },
|
|
212
|
+
headers: { 'content-type': 'text/plain', origin: 'https://evil.example' },
|
|
213
|
+
});
|
|
214
|
+
check('text/plain 简单请求(CSRF)→ 被来源校验挡住', textPlain.status === 403 || textPlain.status === 415, `${textPlain.status} ${JSON.stringify(textPlain.json)}`);
|
|
215
|
+
|
|
216
|
+
const sameOriginNoBody = await request(captured.handler, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
url: '/qq-digest-api/client-boot',
|
|
219
|
+
body: { phase: 'factory' },
|
|
220
|
+
headers: { origin: 'http://127.0.0.1:4845' },
|
|
221
|
+
});
|
|
222
|
+
check('同源 Origin + application/json → 放行', sameOriginNoBody.status === 200, `${sameOriginNoBody.status} ${JSON.stringify(sameOriginNoBody.json)}`);
|
|
223
|
+
|
|
224
|
+
const nonLoopback = await request(captured.handler, { url: '/qq-digest-api/state', remoteAddress: '192.168.1.10' });
|
|
225
|
+
check('非回环来源 → 403 LOCAL_ONLY', nonLoopback.status === 403 && nonLoopback.json.error === 'LOCAL_ONLY', `${nonLoopback.status} ${JSON.stringify(nonLoopback.json)}`);
|
|
226
|
+
|
|
227
|
+
for (const result of results) {
|
|
228
|
+
console.log(`${result.ok ? 'PASS' : 'FAIL'} ${result.name}${result.detail === undefined ? '' : ` (${result.detail})`}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
232
|
+
|
|
233
|
+
const failed = results.filter((result) => !result.ok).length;
|
|
234
|
+
console.log(`\n${results.length - failed}/${results.length} 通过`);
|
|
235
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 把 qq-digest 接进 DSH profile —— 只做**本机接线**,不改任何源码。
|
|
4
|
+
*
|
|
5
|
+
* 做三件事:
|
|
6
|
+
* 1) 在 profile 的 cordis.patch.yml 里维护一段带标记的区块,插入 MCP 行;
|
|
7
|
+
* 2) 把本仓库的真实路径替换进安装片段(模板里是 __PACKAGE_ROOT__ 占位符,
|
|
8
|
+
* 这样仓库本身不携带作者机器的绝对路径);
|
|
9
|
+
* 3) 把本机 qq-bridge 的位置写进 <DSH_HOME>/qq-digest/bridge-root.txt,
|
|
10
|
+
* 插件运行时靠它找到桥接(源码里不硬编码任何绝对路径)。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node scripts/install.mjs # 预览
|
|
14
|
+
* node scripts/install.mjs --apply # 写入(自动备份)
|
|
15
|
+
* node scripts/install.mjs --apply --profile desktop
|
|
16
|
+
* node scripts/install.mjs --apply --remove # 移除 MCP 行
|
|
17
|
+
* node scripts/install.mjs --apply --bridge-root=D:/path/to/qq-bridge
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const apply = args.includes('--apply');
|
|
29
|
+
const remove = args.includes('--remove');
|
|
30
|
+
const profileArg = args.find((arg) => arg.startsWith('--profile='));
|
|
31
|
+
const bridgeArg = args.find((arg) => arg.startsWith('--bridge-root='));
|
|
32
|
+
const profile = profileArg ? profileArg.split('=')[1] : process.env.DSH_PROFILE || 'desktop';
|
|
33
|
+
|
|
34
|
+
const BEGIN = '# === qq-digest MCP BEGIN ===';
|
|
35
|
+
const END = '# === qq-digest MCP END ===';
|
|
36
|
+
const BRIDGE_ROOT_FILE = 'bridge-root.txt';
|
|
37
|
+
|
|
38
|
+
function resolveDshHome(env = process.env) {
|
|
39
|
+
const fromEnv = env.DSH_HOME;
|
|
40
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
|
|
41
|
+
return join(homedir(), '.dsh');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 找一个真实存在的 qq-bridge 目录(有 state/console-token 才算)。 */
|
|
45
|
+
function detectBridgeRoot(explicit) {
|
|
46
|
+
const candidates = [
|
|
47
|
+
explicit,
|
|
48
|
+
process.env.QQ_DIGEST_BRIDGE_ROOT,
|
|
49
|
+
join(homedir(), 'AI', 'qq-bridge'),
|
|
50
|
+
process.env.USERNAME ? `D:/Users/${process.env.USERNAME}/AI/qq-bridge` : null,
|
|
51
|
+
process.env.USERNAME ? `C:/Users/${process.env.USERNAME}/AI/qq-bridge` : null,
|
|
52
|
+
].filter((value) => typeof value === 'string' && value.trim() !== '');
|
|
53
|
+
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (existsSync(join(candidate, 'state', 'console-token'))) return resolve(candidate);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dshHome = resolveDshHome();
|
|
61
|
+
const dataDir = join(dshHome, 'qq-digest');
|
|
62
|
+
const patchFile = join(dshHome, 'profiles', profile, 'cordis.patch.yml');
|
|
63
|
+
const bridgeRoot = detectBridgeRoot(bridgeArg ? bridgeArg.split('=').slice(1).join('=') : null);
|
|
64
|
+
|
|
65
|
+
if (!existsSync(patchFile)) {
|
|
66
|
+
console.error(`找不到 profile 配置:${patchFile}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fragment = readFileSync(join(root, 'install', 'cordis.patch.fragment.yml'), 'utf8');
|
|
71
|
+
const block = fragment
|
|
72
|
+
.replace(/__PACKAGE_ROOT__/g, root.replace(/\\/g, '/'))
|
|
73
|
+
// node 可执行文件必须取当前这台机器上的真实路径:写死任何一台机器的路径,
|
|
74
|
+
// 换机安装都会 spawn ENOENT,而症状是「工具全部消失、插件却像是装好了」。
|
|
75
|
+
.replace(/__NODE_EXE__/g, process.execPath.replace(/\\/g, '/'))
|
|
76
|
+
.trimEnd();
|
|
77
|
+
|
|
78
|
+
const current = readFileSync(patchFile, 'utf8');
|
|
79
|
+
|
|
80
|
+
function stripBlock(text) {
|
|
81
|
+
const begin = text.indexOf(BEGIN);
|
|
82
|
+
if (begin === -1) return text;
|
|
83
|
+
const end = text.indexOf(END);
|
|
84
|
+
if (end === -1) return text;
|
|
85
|
+
return `${text.slice(0, begin)}${text.slice(end + END.length)}`.replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const withoutBlock = stripBlock(current);
|
|
89
|
+
const next = remove ? `${withoutBlock}\n` : `${withoutBlock}\n\n${block}\n`;
|
|
90
|
+
const changed = next !== current;
|
|
91
|
+
|
|
92
|
+
console.log(`profile = ${profile}`);
|
|
93
|
+
console.log(`patch = ${patchFile}`);
|
|
94
|
+
console.log(`packageRoot = ${root.replace(/\\/g, '/')}`);
|
|
95
|
+
console.log(`bridgeRoot = ${bridgeRoot ?? '(没找到;插件会退回 ~/AI/qq-bridge)'}`);
|
|
96
|
+
console.log(`action = ${remove ? '移除 qq-digest 行' : '写入/更新 qq-digest 行'}`);
|
|
97
|
+
console.log(`patch 变化 = ${changed ? 'yes' : 'no(已是目标状态)'}`);
|
|
98
|
+
|
|
99
|
+
if (!apply) {
|
|
100
|
+
console.log('(预览模式:没有写入。加 --apply 才真正落盘。)');
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (changed) {
|
|
105
|
+
const backup = `${patchFile}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
106
|
+
copyFileSync(patchFile, backup);
|
|
107
|
+
writeFileSync(patchFile, next, 'utf8');
|
|
108
|
+
console.log(`已写入。备份:${backup}`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log('profile patch 无需改动。');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 本机桥接位置:只写一次,不覆盖用户已有设置
|
|
114
|
+
if (!remove) {
|
|
115
|
+
const bridgeFile = join(dataDir, BRIDGE_ROOT_FILE);
|
|
116
|
+
if (bridgeRoot === null) {
|
|
117
|
+
console.log(`跳过 ${BRIDGE_ROOT_FILE}:没检测到 qq-bridge(可用 --bridge-root=... 指定)`);
|
|
118
|
+
} else if (existsSync(bridgeFile)) {
|
|
119
|
+
console.log(`${BRIDGE_ROOT_FILE} 已存在,保持不动:${bridgeFile}`);
|
|
120
|
+
} else {
|
|
121
|
+
const { mkdirSync } = await import('node:fs');
|
|
122
|
+
mkdirSync(dataDir, { recursive: true });
|
|
123
|
+
writeFileSync(bridgeFile, `${bridgeRoot.replace(/\\/g, '/')}\n`, 'utf8');
|
|
124
|
+
console.log(`已记录桥接位置:${bridgeFile}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log('重启 DSH 后生效(MCP 行通常可热加载,bundle 行需要重启)。');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 把本包挂进 DSH profile —— 不经过 pnpm。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用 plugin_manager 的 install_bundle:本机 profile 的 node_modules 里有
|
|
6
|
+
* 文件被占用(@img/sharp-win32-x64 EPERM),pnpm 一跑就整批重装并中途失败。
|
|
7
|
+
* 这里只做两件不依赖网络的事:
|
|
8
|
+
* 1) 在 profile 的 node_modules 里建一个 junction 指向本仓库;
|
|
9
|
+
* 2) 在 profile 的 package.json 里登记依赖与 bundle 名单。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/link-profile.mjs # 预览
|
|
13
|
+
* node scripts/link-profile.mjs --apply # 写入(自动备份 package.json)
|
|
14
|
+
* node scripts/link-profile.mjs --apply --profile desktop
|
|
15
|
+
* node scripts/link-profile.mjs --apply --remove
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
19
|
+
import { writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
|
|
24
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
const PACKAGE_NAME = 'dsh-qq-digest';
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const apply = args.includes('--apply');
|
|
29
|
+
const remove = args.includes('--remove');
|
|
30
|
+
const profileArg = args.find((arg) => arg.startsWith('--profile='));
|
|
31
|
+
const profile = profileArg ? profileArg.split('=')[1] : process.env.DSH_PROFILE || 'desktop';
|
|
32
|
+
|
|
33
|
+
function resolveDshHome() {
|
|
34
|
+
const fromEnv = process.env.DSH_HOME;
|
|
35
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim());
|
|
36
|
+
return join(homedir(), '.dsh');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const profileDir = join(resolveDshHome(), 'profiles', profile);
|
|
40
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
41
|
+
const linkPath = join(profileDir, 'node_modules', PACKAGE_NAME);
|
|
42
|
+
|
|
43
|
+
if (!existsSync(manifestPath)) {
|
|
44
|
+
console.error(`找不到 profile:${manifestPath}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
49
|
+
const deps = manifest.dependencies ?? {};
|
|
50
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
51
|
+
|
|
52
|
+
const linkSpec = `link:${root.replace(/\\/g, '/')}`;
|
|
53
|
+
const linked = existsSync(linkPath) && lstatSync(linkPath).isSymbolicLink();
|
|
54
|
+
|
|
55
|
+
console.log(`profile = ${profile}`);
|
|
56
|
+
console.log(`package = ${root}`);
|
|
57
|
+
console.log(`junction = ${linkPath}${linked ? '(已存在)' : '(待创建)'}`);
|
|
58
|
+
console.log(`dep = ${deps[PACKAGE_NAME] ?? '(未登记)'} → ${remove ? '(移除)' : linkSpec}`);
|
|
59
|
+
console.log(`bundles = ${bundles.includes(PACKAGE_NAME) ? '已登记' : '未登记'}`);
|
|
60
|
+
|
|
61
|
+
if (!apply) {
|
|
62
|
+
console.log('(预览模式:没有写入。加 --apply 才真正落盘。)');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (remove) {
|
|
67
|
+
if (linked) rmSync(linkPath, { recursive: false, force: true });
|
|
68
|
+
delete deps[PACKAGE_NAME];
|
|
69
|
+
manifest.dependencies = deps;
|
|
70
|
+
if (manifest.dsh?.profile) {
|
|
71
|
+
manifest.dsh.profile.bundles = bundles.filter((name) => name !== PACKAGE_NAME);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
if (!linked) {
|
|
75
|
+
if (existsSync(linkPath)) {
|
|
76
|
+
console.error(`目标已存在且不是 junction:${linkPath}(先手动确认再处理)`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
symlinkSync(root, linkPath, 'junction');
|
|
80
|
+
console.log('已创建 junction');
|
|
81
|
+
}
|
|
82
|
+
deps[PACKAGE_NAME] = linkSpec;
|
|
83
|
+
manifest.dependencies = deps;
|
|
84
|
+
manifest.dsh = manifest.dsh ?? {};
|
|
85
|
+
manifest.dsh.profile = manifest.dsh.profile ?? {};
|
|
86
|
+
const nextBundles = bundles.includes(PACKAGE_NAME) ? bundles : [...bundles, PACKAGE_NAME];
|
|
87
|
+
manifest.dsh.profile.bundles = nextBundles;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const backup = `${manifestPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
91
|
+
copyFileSync(manifestPath, backup);
|
|
92
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
93
|
+
|
|
94
|
+
console.log(`已写入 ${manifestPath}`);
|
|
95
|
+
console.log(`备份:${backup}`);
|
|
96
|
+
console.log('重启 DSH 后生效(bundle 的客户端半在启动时随页加载)。');
|