dsh-pad-kit 0.0.0-stage → 1.0.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/lib/plugin.js ADDED
@@ -0,0 +1,696 @@
1
+ /**
2
+ * dsh-pad-kit — 在任何电脑上装这个插件,就能把 DSH 装到平板并远程管起来
3
+ *
4
+ * 一条链路:
5
+ * 1) 插件自己生成一对 SSH 钥匙(没有就生成)
6
+ * 2) 插件起局域网安装页,页面给出「复制一行命令」
7
+ * 3) 平板 Termux 里粘那一行 → 自动装 openssh + 写入公钥 + 起 sshd + 向电脑报到
8
+ * → 再自动装 node/clang/dsh/原生绑定/补丁 → 起固定入口
9
+ * 4) 插件收到报到(IP + 用户名)后,后续全部走 SSH 远程操作:查状态、重装、取网址
10
+ *
11
+ * 为什么用「平板主动报到」:Termux 的用户名是随机的 u0_aXXX,电脑没法猜;
12
+ * 报到请求会带上 Host 头与来源 IP,插件据此自动补齐 host/user/port。
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { createServer } from 'node:http';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ export const name = 'pad-kit';
22
+ export const inject = ['tools'];
23
+
24
+ // ---- 宿主版本自检:不支持插件机制的旧版 DSH 直接给结论,不必等装完才发现 ----
25
+ const MIN_HOST_VERSION = '0.1.7-rc.2';
26
+
27
+ function parseVersion(text) {
28
+ const m = String(text ?? '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
29
+ if (!m) return null;
30
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] };
31
+ }
32
+
33
+ function compareVersions(a, b) {
34
+ const x = parseVersion(a);
35
+ const y = parseVersion(b);
36
+ if (!x || !y) return 0;
37
+ for (let i = 0; i < 3; i += 1) if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1;
38
+ if (!x.pre.length && !y.pre.length) return 0;
39
+ if (!x.pre.length) return 1;
40
+ if (!y.pre.length) return -1;
41
+ const n = Math.max(x.pre.length, y.pre.length);
42
+ for (let i = 0; i < n; i += 1) {
43
+ const a1 = x.pre[i];
44
+ const b1 = y.pre[i];
45
+ if (a1 === undefined) return -1;
46
+ if (b1 === undefined) return 1;
47
+ const an = /^\d+$/.test(a1);
48
+ const bn = /^\d+$/.test(b1);
49
+ if (an && bn) { if (Number(a1) !== Number(b1)) return Number(a1) > Number(b1) ? 1 : -1; continue; }
50
+ if (an !== bn) return an ? -1 : 1;
51
+ if (a1 !== b1) return a1 > b1 ? 1 : -1;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ function readDshPackage(startDir) {
57
+ let dir = startDir;
58
+ for (let i = 0; i < 8; i += 1) {
59
+ const file = path.join(dir, 'package.json');
60
+ if (existsSync(file)) {
61
+ try {
62
+ const json = JSON.parse(readFileSync(file, 'utf8'));
63
+ if (typeof json.name === 'string' && json.name.startsWith('@deepseek-ai/dsh')) {
64
+ return { name: json.name, version: json.version, file };
65
+ }
66
+ } catch { /* 坏 json 直接忽略 */ }
67
+ }
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir) break;
70
+ dir = parent;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function detectHost() {
76
+ let found = null;
77
+ if (process.env.DSH_VERSION) found = { version: process.env.DSH_VERSION, source: 'env' };
78
+ const entries = [process.argv[1], process.argv[0]].filter(Boolean);
79
+ for (const entry of entries) {
80
+ for (const start of [path.dirname(entry), process.cwd()]) {
81
+ const pkg = readDshPackage(start);
82
+ if (pkg && pkg.version) { found = { version: pkg.version, source: pkg.file }; break; }
83
+ }
84
+ if (found) break;
85
+ }
86
+ if (!found) return { version: '', source: '', supported: true, detected: false, message: '' };
87
+ const okVersion = compareVersions(found.version, MIN_HOST_VERSION) >= 0;
88
+ return {
89
+ version: found.version,
90
+ source: found.source,
91
+ supported: okVersion,
92
+ detected: true,
93
+ message: okVersion
94
+ ? ''
95
+ : `当前 DSH 版本不支持(检测到 ${found.version},需要 ≥ ${MIN_HOST_VERSION})。` +
96
+ '\n这个插件依赖 DSH 的用户插件机制与工具注册契约,旧版本没有。' +
97
+ '\n请先升级 DSH 并重启,再重新调用这些工具。',
98
+ };
99
+ }
100
+
101
+ const HOST = detectHost();
102
+
103
+ function hostSummary() {
104
+ return `宿主 DSH:${HOST.version || '未识别'}${HOST.supported ? '' : `(低于 ${MIN_HOST_VERSION},不支持)`}`;
105
+ }
106
+
107
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
108
+ const PKG_ROOT = path.dirname(HERE);
109
+ const KIT_DIR = path.join(PKG_ROOT, 'kit');
110
+ // 状态目录可用 DSH_TABLET_KIT_STATE 覆盖(自测时避免污染真实登记)
111
+ const STATE_DIR = process.env.DSH_TABLET_KIT_STATE || path.join(os.homedir(), '.dsh', 'tablet-kit');
112
+ const TABLETS_FILE = path.join(STATE_DIR, 'tablets.json');
113
+
114
+ const DEFAULT_CONFIG = {
115
+ sshHost: '',
116
+ sshPort: 8022,
117
+ sshUser: '',
118
+ keyPath: path.join(STATE_DIR, 'id_ed25519'),
119
+ sshPath: process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : 'ssh',
120
+ scpPath: process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\scp.exe' : 'scp',
121
+ sshKeygenPath: process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh-keygen.exe' : 'ssh-keygen',
122
+ httpPort: 8090,
123
+ apkName: 'termux.apk',
124
+ timeoutMs: 120000,
125
+ };
126
+
127
+ const OUTPUT = {
128
+ schema: {
129
+ type: 'object',
130
+ additionalProperties: false,
131
+ properties: {
132
+ ok: { type: 'boolean' },
133
+ text: { type: 'string' },
134
+ path: { type: 'string' },
135
+ },
136
+ required: ['ok', 'text'],
137
+ },
138
+ render(_args, value) {
139
+ return [{ type: 'text', text: String(value?.text ?? '') }];
140
+ },
141
+ };
142
+
143
+ function defineTool(def) {
144
+ const parameters = def.parameters ?? {};
145
+ const properties = {};
146
+ const required = [];
147
+ for (const [key, spec] of Object.entries(parameters)) {
148
+ const { required: isRequired, ...rest } = spec;
149
+ properties[key] = rest;
150
+ if (isRequired) required.push(key);
151
+ }
152
+ return {
153
+ name: def.name,
154
+ description: def.description,
155
+ parameters: { type: 'object', properties, additionalProperties: false, required },
156
+ output: OUTPUT,
157
+ execute(args) {
158
+ if (!HOST.supported) return { ok: false, text: HOST.message };
159
+ for (const key of required) {
160
+ if (args?.[key] === undefined || args?.[key] === '') throw new Error(`${def.name}: 缺少必需参数 "${key}"`);
161
+ }
162
+ return def.execute(args ?? {});
163
+ },
164
+ };
165
+ }
166
+
167
+ let currentConfig = {};
168
+ function cfgOf(overrides = {}) {
169
+ return { ...DEFAULT_CONFIG, ...currentConfig, ...overrides };
170
+ }
171
+
172
+ // ------------------------------------------------------------- 钥匙 & 平板登记
173
+ function ensureStateDir() {
174
+ try {
175
+ mkdirSync(STATE_DIR, { recursive: true });
176
+ } catch (error) {
177
+ throw new Error(`创建状态目录失败 ${STATE_DIR}: ${error.message}`);
178
+ }
179
+ }
180
+
181
+ function ensureKeyPair(cfg) {
182
+ ensureStateDir();
183
+ if (existsSync(cfg.keyPath)) {
184
+ const pubPath = `${cfg.keyPath}.pub`;
185
+ return { privateKey: cfg.keyPath, publicKey: existsSync(pubPath) ? readFileSync(pubPath, 'utf8').trim() : '' };
186
+ }
187
+ const result = spawnSync(
188
+ cfg.sshKeygenPath,
189
+ ['-t', 'ed25519', '-N', '', '-C', 'dsh-pad-kit', '-f', cfg.keyPath],
190
+ { encoding: 'utf8', timeout: 30000, windowsHide: true },
191
+ );
192
+ if (!existsSync(cfg.keyPath)) {
193
+ throw new Error(`生成 SSH 钥匙失败:${(result.stderr || result.error?.message || '').trim()}`);
194
+ }
195
+ const pubPath = `${cfg.keyPath}.pub`;
196
+ return { privateKey: cfg.keyPath, publicKey: existsSync(pubPath) ? readFileSync(pubPath, 'utf8').trim() : '' };
197
+ }
198
+
199
+ function readTablets() {
200
+ try {
201
+ if (!existsSync(TABLETS_FILE)) return [];
202
+ const raw = JSON.parse(readFileSync(TABLETS_FILE, 'utf8'));
203
+ return Array.isArray(raw) ? raw : [];
204
+ } catch (error) {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ function recordTablet(entry) {
210
+ ensureStateDir();
211
+ const list = readTablets().filter((item) => !(item.host === entry.host && item.user === entry.user));
212
+ list.unshift(entry);
213
+ writeFileSync(TABLETS_FILE, JSON.stringify(list.slice(0, 20), null, 2), 'utf8');
214
+ return list;
215
+ }
216
+
217
+ /** 目标解析优先级:调用参数 > 最近一次报到 > 配置默认 */
218
+ function resolveTarget(args = {}) {
219
+ const cfg = cfgOf();
220
+ if (args.host) {
221
+ return {
222
+ ...cfg,
223
+ sshHost: args.host,
224
+ sshUser: args.user || cfg.sshUser || readTablets()[0]?.user || '',
225
+ sshPort: Number(args.port || cfg.sshPort),
226
+ };
227
+ }
228
+ if (cfg.sshHost) {
229
+ return { ...cfg, sshHost: cfg.sshHost, sshUser: cfg.sshUser || readTablets()[0]?.user || '', sshPort: Number(cfg.sshPort) };
230
+ }
231
+ const latest = readTablets()[0];
232
+ if (!latest) return null;
233
+ return { ...cfg, sshHost: latest.host, sshUser: latest.user, sshPort: Number(latest.port || 8022) };
234
+ }
235
+
236
+ // ---------------------------------------------------------------- SSH 通道
237
+ const TRANSIENT = /timed out during banner exchange|Connection reset by peer|client_loop: send disconnect|Connection timed out|kex_exchange_identification|Operation timed out/i;
238
+
239
+ function sleep(ms) {
240
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
241
+ }
242
+
243
+ function explain(stderr) {
244
+ if (TRANSIENT.test(stderr)) return '平板上的 Termux 被系统冻结了(vivo/ColorOS 常见)。把 Termux 切到前台、屏幕别锁,再试一次。';
245
+ if (/Permission denied \(publickey\)/.test(stderr)) return '公钥被拒:平板上 ~/.ssh/authorized_keys 里没有本插件这把钥匙,重跑一次安装命令即可。';
246
+ if (/Connection refused/i.test(stderr)) return '平板 SSH 端口没监听:Termux 里执行 nohup sshd 或重跑安装命令。';
247
+ if (/Could not resolve hostname|nodename nor servname/i.test(stderr)) return '主机名/IP 解析不了,检查平板与电脑是否同一 Wi-Fi。';
248
+ return '';
249
+ }
250
+
251
+ function ssh(target, command, { retries = 3 } = {}) {
252
+ if (!target?.sshHost) return { ok: false, stdout: '', stderr: 'MISSING_TARGET' };
253
+ if (!target.sshUser) return { ok: false, stdout: '', stderr: 'MISSING_USER' };
254
+ let stderr = '';
255
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
256
+ const result = spawnSync(
257
+ target.sshPath,
258
+ [
259
+ '-i', target.keyPath,
260
+ '-p', String(target.sshPort),
261
+ '-o', 'StrictHostKeyChecking=no',
262
+ '-o', 'UserKnownHostsFile=NUL',
263
+ '-o', 'BatchMode=yes',
264
+ '-o', 'ConnectTimeout=10',
265
+ `${target.sshUser}@${target.sshHost}`,
266
+ command,
267
+ ],
268
+ { encoding: 'utf8', timeout: target.timeoutMs, windowsHide: true },
269
+ );
270
+ stderr = result.stderr ?? '';
271
+ if (result.status === 0) return { ok: true, stdout: result.stdout ?? '', stderr };
272
+ if (!TRANSIENT.test(stderr)) break;
273
+ if (attempt < retries) sleep(2000);
274
+ }
275
+ return { ok: false, stdout: '', stderr };
276
+ }
277
+
278
+ function scp(target, local, remote) {
279
+ const result = spawnSync(
280
+ target.scpPath,
281
+ [
282
+ '-i', target.keyPath,
283
+ '-P', String(target.sshPort),
284
+ '-o', 'StrictHostKeyChecking=no',
285
+ '-o', 'UserKnownHostsFile=NUL',
286
+ local,
287
+ `${target.sshUser}@${target.sshHost}:${remote}`,
288
+ ],
289
+ { encoding: 'utf8', timeout: target.timeoutMs, windowsHide: true },
290
+ );
291
+ return { ok: result.status === 0, stderr: result.stderr ?? '' };
292
+ }
293
+
294
+ // ------------------------------------------------------------ 局域网安装页
295
+ let lanServer = null;
296
+ let lastServerError = null;
297
+
298
+ function lanAddresses() {
299
+ const out = [];
300
+ for (const list of Object.values(os.networkInterfaces())) {
301
+ for (const item of list ?? []) {
302
+ if (item.family === 'IPv4' && !item.internal) out.push(item.address);
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function bootstrapScript(baseUrl, publicKey, sshOnly = false) {
309
+ const template = readFileSync(path.join(KIT_DIR, 'bootstrap.tmpl'), 'utf8');
310
+ const skip = sshOnly
311
+ ? 'echo "[dsh-kit] SSH 通道已就绪,回电脑执行 tablet_targets 即可登记"\nexit 0\n'
312
+ : '';
313
+ return template
314
+ .replace('__BASE__', baseUrl)
315
+ .replace('__PUBKEY__', publicKey)
316
+ .replace('__SKIP_DSH__', skip);
317
+ }
318
+
319
+ function pageHtml(baseUrl) {
320
+ return `<!doctype html>
321
+ <html lang="zh-CN"><head><meta charset="utf-8">
322
+ <meta name="viewport" content="width=device-width,initial-scale=1">
323
+ <title>DSH 平板端安装</title>
324
+ <style>
325
+ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;padding:20px;background:#0f1115;color:#e8eaed;line-height:1.7;max-width:760px}
326
+ h1{font-size:22px;margin:0 0 4px}
327
+ .sub{color:#9aa0a6;font-size:13px;margin-bottom:18px}
328
+ .card{background:#1a1d23;border:1px solid #2a2f38;border-radius:12px;padding:16px;margin-bottom:14px}
329
+ .step{font-size:15px;font-weight:600;margin-bottom:8px}
330
+ code{display:block;background:#0b0d11;border:1px solid #2a2f38;border-radius:8px;padding:10px;font-size:13px;word-break:break-all;color:#8ab4f8;user-select:all;margin:6px 0}
331
+ a.btn{display:inline-block;background:#2b6cff;color:#fff;text-decoration:none;padding:11px 18px;border-radius:9px;font-weight:600}
332
+ .tip{color:#9aa0a6;font-size:12.5px;margin-top:8px}
333
+ .q{font-size:13px;color:#f9ab00;font-weight:600;margin:22px 0 8px}
334
+ b{color:#e8eaed}
335
+ </style></head>
336
+ <body>
337
+ <h1>DSH 平板端安装</h1>
338
+ <div class="sub">先分岔:平板有没有 Termux?两条路装的是同一个东西。</div>
339
+
340
+ <div class="q">① 平板「没有」Termux</div>
341
+ <div class="card">
342
+ <div class="step">1.1 下载并安装 Termux</div>
343
+ <a class="btn" href="/termux.apk">下载 Termux APK(0.118.3)</a>
344
+ <div class="tip">安装时允许「安装未知应用」。装完打开 Termux,再走下面 ② 的那一行命令。</div>
345
+ <div class="tip">别用应用商店的旧版(0.101):它会被系统冻结、pkg 也会崩。</div>
346
+ </div>
347
+
348
+ <div class="q">② 平板「已经有」Termux</div>
349
+ <div class="card">
350
+ <div class="step">2.1 打开 Termux,粘这一行</div>
351
+ <code>curl -fL ${baseUrl}/bootstrap.sh | bash</code>
352
+ <div class="tip">脚本自己判断环境:架构 / Android 版本 / 空间 → 修 curl → 装 openssh → 起 sshd → <b>向电脑报到</b> → 装 DSH 本体(node / dsh / 原生绑定 / 两个安卓补丁 / 起服务)。</div>
353
+ <div class="tip">约 1~3 分钟,期间<b>别锁屏</b>,Termux 保持前台。</div>
354
+ </div>
355
+
356
+ <div class="card">
357
+ <div class="step">装完以后</div>
358
+ <div class="tip">安装脚本最后会打印一个地址,用<b>平板自己的浏览器</b>打开就是 DSH 网页。以后重启平板,在 Termux 里跑 <code>bash ~/start-dsh.sh</code> 再打开同一个地址。</div>
359
+ </div>
360
+
361
+ <div class="card">
362
+ <div class="step">想开机自动起来</div>
363
+ <a class="btn" href="/termux-boot.apk">下载 Termux:Boot APK(0.8.1)</a>
364
+ <div class="tip">装它之前/之后都行,安装脚本已经把开机脚本放在 <code>~/.termux/boot/start-dsh.sh</code>;只要装了 Termux:Boot,下次开机就会自己把 DSH 起好。</div>
365
+ <div class="tip">装 Termux:Boot 后打开它一次(让它启动一次服务),之后再重启平板验证。</div>
366
+ </div>
367
+
368
+ <div class="card">
369
+ <div class="step">电脑端:一行装好插件(可选)</div>
370
+ <div class="tip">在<b>电脑</b>的 PowerShell 里跑这一行,自动下载插件包、放到 <code>~\.dsh\plugins</code>、登记好、写激活行:</div>
371
+ <code>irm ${baseUrl}/install.ps1 | iex</code>
372
+ <div class="tip">跑完重启一次 DSH,17 个 <code>tablet_*</code> 工具就出现在工具列表里。已经有压缩包的话,直接跑里面的 <code>install-windows.ps1</code> 效果一样。</div>
373
+ </div>
374
+
375
+ <div class="card">
376
+ <div class="step">卡住了怎么办</div>
377
+ <div class="tip">如果 Termux 报 <code>CANNOT LINK EXECUTABLE "curl"</code>,先跑下面这条(它不依赖 curl),再重跑 2.1:</div>
378
+ <code>apt update &amp;&amp; apt full-upgrade -y -o Dpkg::Options::="--force-confnew"</code>
379
+ <div class="tip">如果报 <code>E: Unable to locate package openssh</code>,脚本会自己切官方源重试;仍失败就手动跑:</div>
380
+ <code>echo "deb https://packages.termux.dev/apt/termux-main stable main" &gt; $PREFIX/etc/apt/sources.list &amp;&amp; apt update</code>
381
+ </div>
382
+ </body></html>`;
383
+ }
384
+
385
+ function ensureLanServer(cfg) {
386
+ if (lanServer) return lanServer;
387
+ const { publicKey } = ensureKeyPair(cfg);
388
+
389
+ const server = createServer((req, res) => {
390
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
391
+ const host = req.headers.host ?? `127.0.0.1:${cfg.httpPort}`;
392
+ const baseUrl = `http://${host}`;
393
+
394
+ const sendFile = (file, type) => {
395
+ if (!existsSync(file)) {
396
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
397
+ res.end('not found');
398
+ return;
399
+ }
400
+ res.writeHead(200, { 'content-type': type, 'content-length': String(statSync(file).size) });
401
+ createReadStream(file).pipe(res);
402
+ };
403
+
404
+ if (url.pathname === '/' || url.pathname === '/index.html') {
405
+ const body = pageHtml(baseUrl);
406
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(body) });
407
+ res.end(body);
408
+ return;
409
+ }
410
+ if (url.pathname === '/bootstrap.sh' || url.pathname === '/install.sh') {
411
+ const body = bootstrapScript(baseUrl, publicKey, url.searchParams.get('sshonly') === '1');
412
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) });
413
+ res.end(body);
414
+ return;
415
+ }
416
+ if (url.pathname === '/register') {
417
+ const remote = (req.socket.remoteAddress ?? '').replace(/^::ffff:/, '');
418
+ const user = url.searchParams.get('user') ?? '';
419
+ const port = Number(url.searchParams.get('port') ?? 8022) || 8022;
420
+ const entry = { host: remote, user, port, at: new Date().toISOString() };
421
+ try {
422
+ recordTablet(entry);
423
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
424
+ res.end(`OK ${user}@${remote}:${port}\n`);
425
+ } catch (error) {
426
+ res.writeHead(500);
427
+ res.end(`register failed: ${error.message}`);
428
+ }
429
+ return;
430
+ }
431
+ if (url.pathname === '/kit.tgz') return sendFile(path.join(PKG_ROOT, 'dsh-android-kit.tgz'), 'application/gzip');
432
+ if (url.pathname === '/termux.apk') return sendFile(path.join(KIT_DIR, cfg.apkName), 'application/vnd.android.package-archive');
433
+ if (url.pathname === '/termux-boot.apk') return sendFile(path.join(KIT_DIR, 'termux-boot.apk'), 'application/vnd.android.package-archive');
434
+ if (url.pathname === '/install.ps1') {
435
+ const body = renderInstallScript(baseUrl);
436
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(body) });
437
+ res.end(body);
438
+ return;
439
+ }
440
+ if (url.pathname === '/plugin.zip') {
441
+ const zip = path.join(PKG_ROOT, 'dist', 'dsh-pad-kit.zip');
442
+ if (existsSync(zip)) return sendFile(zip, 'application/zip');
443
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
444
+ res.end('这里没有 plugin.zip;请在电脑上运行 dist 目录里的 install-windows.ps1');
445
+ return;
446
+ }
447
+ if (url.pathname === '/healthz') {
448
+ res.writeHead(200, { 'content-type': 'text/plain' });
449
+ res.end('ok');
450
+ return;
451
+ }
452
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
453
+ res.end('not found');
454
+ });
455
+
456
+ server.on('error', (error) => {
457
+ lastServerError = error;
458
+ });
459
+ server.listen(cfg.httpPort, '0.0.0.0');
460
+ lanServer = { server, port: cfg.httpPort, publicKey };
461
+ return lanServer;
462
+ }
463
+
464
+ function missingTargetText() {
465
+ return [
466
+ '还没认到平板。先做这一步:',
467
+ ' tablet_install_page → 拿到安装页地址',
468
+ ' 平板浏览器打开该地址 → 装 Termux → 在 Termux 里粘页面给的那一行命令',
469
+ '那一行命令末尾会让平板向本机报到,之后这些工具才能远程操作它。',
470
+ ].join('\n');
471
+ }
472
+
473
+ // ------------------------------------------------------------------- tools
474
+ const tools = [
475
+ defineTool({
476
+ name: 'tablet_install_page',
477
+ description: '起局域网安装页(平板扫码/打开即可装 Termux 并一键安装 DSH + SSH 免密),返回地址与一行命令。',
478
+ parameters: {},
479
+ async execute() {
480
+ const cfg = cfgOf();
481
+ const { port } = ensureLanServer(cfg);
482
+ await new Promise((resolve) => setTimeout(resolve, 250));
483
+ if (lastServerError) {
484
+ const hint =
485
+ lastServerError.code === 'EADDRINUSE'
486
+ ? `端口 ${port} 已被占用。改配置 httpPort(例如 8091)再试,或先关掉占用该端口的程序。`
487
+ : `安装页启动失败:${lastServerError.code || ''} ${lastServerError.message}`;
488
+ return { ok: false, text: hint };
489
+ }
490
+ const addrs = lanAddresses();
491
+ if (addrs.length === 0) return { ok: false, text: '本机没有局域网 IPv4 地址,平板无法访问。' };
492
+ const urls = addrs.map((ip) => `http://${ip}:${port}/`);
493
+ return {
494
+ ok: true,
495
+ text: [
496
+ '安装页已启动(防火墙需放行一次):',
497
+ ...urls.map((line) => ' ' + line),
498
+ '',
499
+ '平板步骤:打开上面地址 → 下载并安装 Termux → 打开 Termux 粘这一行:',
500
+ ` curl -fL ${urls[0]}bootstrap.sh | bash`,
501
+ '',
502
+ '这条命令会:装 openssh 免密 → 向本机报到 → 装 node/clang/dsh → 编译原生绑定 → 打补丁 → 起固定入口 `http://127.0.0.1:3081/`',
503
+ '装完可运行 tablet_targets 看是否已认到平板,再用 tablet_kit_url 取地址。',
504
+ ].join('\n'),
505
+ };
506
+ },
507
+ }),
508
+
509
+ defineTool({
510
+ name: 'tablet_bootstrap',
511
+ description: '只做「让电脑能远程登录平板」这件事:返回要粘进 Termux 的 SSH 配置命令与公钥(适用于已装 Termux 但没开 SSH 的平板)。',
512
+ parameters: {},
513
+ async execute() {
514
+ const cfg = cfgOf();
515
+ const { publicKey } = ensureKeyPair(cfg);
516
+ const addrs = lanAddresses();
517
+ const base = addrs.length ? `http://${addrs[0]}:${cfg.httpPort}` : 'http://<电脑IP>:8090';
518
+ return {
519
+ ok: true,
520
+ path: `${cfg.keyPath}.pub`,
521
+ text: [
522
+ '在平板 Termux 里粘这一行(只建 SSH 通道,不装 DSH):',
523
+ '',
524
+ ` curl -fsSL "${base}/bootstrap.sh?sshonly=1" | bash`,
525
+ '',
526
+ '或者手动执行这三条:',
527
+ ' pkg install -y openssh && mkdir -p ~/.ssh && chmod 700 ~/.ssh',
528
+ ` echo "${publicKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
529
+ ' ssh-keygen -A && pkill sshd; nohup sshd >/dev/null 2>&1; echo "user=$(whoami) port=8022"',
530
+ '',
531
+ '执行完把最后打印的 user 和 IP 告诉 tablet_targets,或直接重跑完整安装命令让它自动报到。',
532
+ ].join('\n'),
533
+ };
534
+ },
535
+ }),
536
+
537
+ defineTool({
538
+ name: 'tablet_targets',
539
+ description: '列出已经向本机报到过的平板(IP/用户名/端口/时间),可手动登记一台:tablet_targets(host, user, port)。',
540
+ parameters: {
541
+ host: { type: 'string', description: '手动登记:平板 IP' },
542
+ user: { type: 'string', description: '手动登记:Termux 用户名(如 u0_a303)' },
543
+ port: { type: 'number', description: '手动登记:SSH 端口,默认 8022' },
544
+ },
545
+ async execute(args) {
546
+ if (args.host && args.user) {
547
+ const list = recordTablet({ host: args.host, user: args.user, port: Number(args.port || 8022), at: new Date().toISOString() });
548
+ return { ok: true, text: `已登记,共 ${list.length} 台:\n` + list.map((t) => ` ${t.user}@${t.host}:${t.port}`).join('\n') };
549
+ }
550
+ const list = readTablets();
551
+ if (list.length === 0) return { ok: false, text: missingTargetText() };
552
+ return {
553
+ ok: true,
554
+ text: list.map((t, i) => `${i === 0 ? '*' : ' '} ${t.user}@${t.host}:${t.port} 报到时间 ${t.at}`).join('\n'),
555
+ };
556
+ },
557
+ }),
558
+
559
+ defineTool({
560
+ name: 'tablet_kit_status',
561
+ description: '通过 SSH 检查平板上的 DSH 状态:node/dsh 版本、原生绑定、两个补丁、3080/3081 服务。',
562
+ parameters: {
563
+ host: { type: 'string', description: '平板 IP,省略用最近报到的平板' },
564
+ user: { type: 'string', description: 'Termux 用户名,省略自动用报到的用户名' },
565
+ port: { type: 'number', description: 'SSH 端口,默认 8022' },
566
+ },
567
+ async execute(args) {
568
+ const target = resolveTarget(args);
569
+ if (!target) return { ok: false, text: missingTargetText() };
570
+ const cmd = [
571
+ 'echo NODE=$(node -v 2>/dev/null)',
572
+ 'echo NPM=$(npm -v 2>/dev/null)',
573
+ 'echo DSH=$(dsh --version 2>/dev/null || echo none)',
574
+ 'PKG=$(npm root -g 2>/dev/null)',
575
+ 'echo BINDING=$(ls "$PKG/@deepseek-ai/dsh/node_modules/node-addon-require-builtin-android-arm64/prebuilt/android-arm64-napi-v9.node" 2>/dev/null || echo none)',
576
+ 'grep -q PATCHED-ANDROID-FLOCK "$PKG/@deepseek-ai/dsh/node_modules/@deepseek-ai/node-addon-system/lib/flock.js" 2>/dev/null && echo FLOCK=patched || echo FLOCK=no',
577
+ 'grep -q "COPYFILE_EXCL" "$PKG/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js" 2>/dev/null && echo LINK=patched || echo LINK=no',
578
+ 'echo HOME_3081=$(curl -s -o /dev/null -w "%{http_code}" --max-time 4 http://127.0.0.1:3081/ 2>/dev/null)',
579
+ 'echo WEB_3080=$(curl -s -o /dev/null -w "%{http_code}" --max-time 4 http://127.0.0.1:3080/ 2>/dev/null)',
580
+ 'echo URL=$(cat ~/dshpad/url.txt 2>/dev/null)',
581
+ ].join('; ');
582
+ const result = ssh(target, cmd);
583
+ if (!result.ok) {
584
+ return { ok: false, text: `连不上 ${target.sshUser}@${target.sshHost}:${target.sshPort}:${explain(result.stderr) || result.stderr.trim() || '未知错误'}` };
585
+ }
586
+ return { ok: true, text: hostSummary() + "\n" + `${target.sshUser}@${target.sshHost}:${target.sshPort}\n` + result.stdout.trim() };
587
+ },
588
+ }),
589
+
590
+ defineTool({
591
+ name: 'tablet_kit_setup',
592
+ description: '通过 SSH 把安装包推到平板并执行 setup.sh(幂等:已装的跳过,只重启服务),返回固定网址。',
593
+ parameters: {
594
+ host: { type: 'string', description: '平板 IP,省略用最近报到的平板' },
595
+ user: { type: 'string', description: 'Termux 用户名,省略自动用报到的用户名' },
596
+ port: { type: 'number', description: 'SSH 端口,默认 8022' },
597
+ },
598
+ async execute(args) {
599
+ const target = resolveTarget(args);
600
+ if (!target) return { ok: false, text: missingTargetText() };
601
+ const archive = path.join(PKG_ROOT, 'dsh-android-kit.tgz');
602
+ if (!existsSync(archive)) return { ok: false, text: `缺少安装包:${archive}` };
603
+ const pushed = scp(target, archive, '~/dsh-android-kit.tgz');
604
+ if (!pushed.ok) {
605
+ return { ok: false, text: `推包失败:${explain(pushed.stderr) || pushed.stderr.trim()}` };
606
+ }
607
+ const cmd = 'cd ~ && rm -rf dsh-android-kit && tar -xzf dsh-android-kit.tgz && bash dsh-android-kit/setup.sh 2>&1 | tail -n 40';
608
+ const result = ssh({ ...target, timeoutMs: 900000 }, cmd, { retries: 2 });
609
+ if (!result.ok) {
610
+ return { ok: false, text: `安装脚本失败:${explain(result.stderr) || '见输出'}\n${result.stdout}\n${result.stderr}` };
611
+ }
612
+ return { ok: true, text: result.stdout.trim() };
613
+ },
614
+ }),
615
+
616
+ defineTool({
617
+ name: 'tablet_kit_url',
618
+ description: '读取平板当前的 DSH 入口地址(固定入口 3081 + 本次 token 直连地址)。',
619
+ parameters: {
620
+ host: { type: 'string', description: '平板 IP,省略用最近报到的平板' },
621
+ user: { type: 'string', description: 'Termux 用户名,省略自动用报到的用户名' },
622
+ port: { type: 'number', description: 'SSH 端口,默认 8022' },
623
+ },
624
+ async execute(args) {
625
+ const target = resolveTarget(args);
626
+ if (!target) return { ok: false, text: missingTargetText() };
627
+ const result = ssh(target, 'cat ~/dshpad/url.txt 2>/dev/null; echo; curl -s -o /dev/null -w "%{http_code}" --max-time 4 http://127.0.0.1:3081/ 2>/dev/null', { retries: 2 });
628
+ if (!result.ok) return { ok: false, text: `连不上 ${target.sshUser}@${target.sshHost}:${explain(result.stderr) || result.stderr.trim()}` };
629
+ const lines = result.stdout.trim().split('\n').map((line) => line.trim()).filter(Boolean);
630
+ const urlLine = lines.find((line) => line.startsWith('http')) ?? '';
631
+ const code = lines.filter((line) => /^\d{3}$/.test(line)).pop() ?? '?';
632
+ return {
633
+ ok: true,
634
+ text: [
635
+ `平板:${target.sshUser}@${target.sshHost}`,
636
+ `固定入口:http://127.0.0.1:3081/ (当前返回 ${code})`,
637
+ urlLine ? `token 直连:${urlLine}` : 'token 直连:未取到(服务可能没起,跑 tablet_kit_setup 或平板上 bash ~/start-dsh.sh)',
638
+ ].join('\n'),
639
+ };
640
+ },
641
+ }),
642
+
643
+ defineTool({
644
+ name: 'tablet_kit_apk',
645
+ description: '返回 Termux APK 的局域网下载地址与本地路径(平板上点一下即可安装)。',
646
+ parameters: {},
647
+ async execute() {
648
+ const cfg = cfgOf();
649
+ const apk = path.join(KIT_DIR, cfg.apkName);
650
+ if (!existsSync(apk)) return { ok: false, text: `缺少 Termux APK:${apk}` };
651
+ const { port } = ensureLanServer(cfg);
652
+ const addrs = lanAddresses();
653
+ const url = addrs.length ? `http://${addrs[0]}:${port}/termux.apk` : '(本机没有局域网地址)';
654
+ return { ok: true, path: apk, text: `Termux APK:${apk}\n下载地址:${url}\n大小:${(statSync(apk).size / 1048576).toFixed(1)} MB` };
655
+ },
656
+ }),
657
+ ];
658
+
659
+ export function apply(ctx, config = {}) {
660
+ currentConfig = config ?? {};
661
+ for (const tool of tools) ctx.tools.register(tool);
662
+ }
663
+
664
+
665
+
666
+ // ---- 供独立安装页复用同一份渲染逻辑(installer-page.js 会 import 这几个)----
667
+ export function renderPage(baseUrl) { return pageHtml(baseUrl); }
668
+ export function renderBootstrap(baseUrl, publicKey, sshOnly = false) { return bootstrapScript(baseUrl, publicKey, sshOnly); }
669
+
670
+ /**
671
+ * 电脑端「一条命令安装」脚本:下载本机的 plugin.zip → 解压 → 跑里面的 install-windows.ps1。
672
+ * 用法(Windows PowerShell):irm http://<电脑IP>:8090/install.ps1 | iex
673
+ */
674
+ export function renderInstallScript(baseUrl) {
675
+ const base = String(baseUrl).replace(/'/g, "''");
676
+ return [
677
+ "$ErrorActionPreference = 'Stop'",
678
+ `$base = '${base}'`,
679
+ 'Write-Host "==> 从 $base 安装 dsh-pad-kit" -ForegroundColor Cyan',
680
+ "$tmp = Join-Path $env:TEMP ('dsh-pad-kit-' + [guid]::NewGuid().ToString('N').Substring(0,8))",
681
+ 'New-Item -ItemType Directory -Path $tmp -Force | Out-Null',
682
+ "$zip = Join-Path $tmp 'dsh-pad-kit.zip'",
683
+ "Write-Host ' 下载插件包…'",
684
+ 'Invoke-WebRequest "$base/plugin.zip" -OutFile $zip -UseBasicParsing',
685
+ "Write-Host (' 已下载 ' + [math]::Round((Get-Item $zip).Length/1MB,2) + ' MB')",
686
+ 'Add-Type -AssemblyName System.IO.Compression.FileSystem',
687
+ '[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $tmp)',
688
+ "$installer = Get-ChildItem -Path $tmp -Recurse -Filter 'install-windows.ps1' | Select-Object -First 1",
689
+ "if (-not $installer) { throw '压缩包里找不到 install-windows.ps1' }",
690
+ 'Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force',
691
+ '& $installer.FullName',
692
+ '',
693
+ "Write-Host ''",
694
+ "Write-Host '重启 DSH 后,17 个 tablet_* 工具就会出现。' -ForegroundColor Yellow",
695
+ ].join('\r\n');
696
+ }