terminal-bridge-setup 2.9.1 → 3.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.
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { WebSocketServer } from "ws";
15
15
  import { randomBytes } from "node:crypto";
16
+ import { decode as msgpackDecode } from "@msgpack/msgpack";
16
17
  import { writeFileSync, unlinkSync } from "node:fs";
17
18
  import { fileURLToPath } from "node:url";
18
19
 
@@ -53,6 +54,206 @@ function sendToExtension(obj) {
53
54
  return false;
54
55
  }
55
56
 
57
+ // ===================== WS 监听(tap)通道 =====================
58
+ // 非终端页面(Yearning SQL 结果等)没有 xterm/prompt,无法走命令配对。
59
+ // Agent 客户端发 {type:"tap-start", urlIncludes:"..."} 注册后,代理把
60
+ // URL 匹配的原始 ws-recv 帧以 {type:"tap-frame", ...} 转发给它,由 Agent
61
+ // 自行解析协议。与终端命令通道互不影响(终端配对只吃终端特征 URL)。
62
+ const tapClients = new Map(); // ws -> { urlIncludes, tabId? }
63
+ let activeYearningTabId = null; // background 选中的 Yearning tab
64
+
65
+ function broadcastTap(payload) {
66
+ // 内部 Yearning 编排等待者也吃一份(yr-run 等结果帧)
67
+ feedYearningWaiters(payload);
68
+ if (tapClients.size === 0) return;
69
+ const url = (payload && payload.url) || "";
70
+ for (const [client, filter] of tapClients) {
71
+ if (client.readyState !== client.OPEN) continue;
72
+ // url 匹配则转发;url 为空也转发(CDP 在 attach 前建立的连接会错过
73
+ // webSocketCreated,帧拿不到 url——宁可多送让客户端判断,不能静默丢弃)
74
+ if (filter.tabId != null && payload.tabId != null && filter.tabId !== payload.tabId) continue;
75
+ if (url && url.indexOf(filter.urlIncludes) === -1) continue;
76
+ try {
77
+ client.send(JSON.stringify({
78
+ type: "tap-frame",
79
+ tabId: payload.tabId,
80
+ url,
81
+ data: payload.data,
82
+ opcode: payload.opcode,
83
+ t: payload.t
84
+ }));
85
+ } catch {}
86
+ }
87
+ }
88
+
89
+ // ===================== Yearning SQL 自动化编排 =====================
90
+ // Agent 发 {type:"yr-run", sql, timeoutMs}:
91
+ // 1. yr-cmd sql-set → 插件把 SQL 注入 Yearning 编辑器(CodeMirror/monaco/DOM)
92
+ // 2. yr-cmd query-click → 插件点「查询」按钮
93
+ // 3. 等 tap 帧里出现 results != null 的结果帧(msgpack 解码)→ resolve 返回
94
+ // 前置条件:用户已在 Yearning 页面点「📡 监听当前页 WS」(tap tab 存在)。
95
+ const yrCmdWaiters = new Map(); // reqId -> resolve
96
+ const yrRunWaiters = new Set(); // { tryConsume(payload) -> boolean }
97
+
98
+ function sendYrCmd(sub, sql, tabId) {
99
+ const reqId = genReqId();
100
+ return new Promise((resolve) => {
101
+ const timer = setTimeout(() => {
102
+ yrCmdWaiters.delete(reqId);
103
+ resolve({ ok: false, error: `yr-cmd ${sub} timeout` });
104
+ }, 5000);
105
+ yrCmdWaiters.set(reqId, (res) => {
106
+ clearTimeout(timer);
107
+ resolve(res);
108
+ });
109
+ const ok = sendToExtension({ type: "yr-cmd", sub, sql, reqId, tabId });
110
+ if (!ok) {
111
+ yrCmdWaiters.delete(reqId);
112
+ clearTimeout(timer);
113
+ resolve({ ok: false, error: "extension not connected" });
114
+ }
115
+ });
116
+ }
117
+
118
+ // 插件回的 yr-result:唤醒对应的 yr-cmd 等待者
119
+ function handleYrResult(msg) {
120
+ const waiter = yrCmdWaiters.get(msg.reqId);
121
+ if (waiter) {
122
+ yrCmdWaiters.delete(msg.reqId);
123
+ waiter({ ok: !!msg.ok, via: msg.via, error: msg.error, info: msg.editor || msg.buttons });
124
+ }
125
+ }
126
+
127
+ // tap 帧喂给 yr-run 等待者:结果帧(msgpack 解码后 results 非空)被消费。
128
+ // 没有 yr-run 等待者时(用户在页面上手点「查 询」),同样生成 CSV 导出记录——
129
+ // 桥接查询和手动查询的产出统一进 popup 列表。
130
+ function feedYearningWaiters(payload) {
131
+ const url = (payload && payload.url) || "";
132
+ if (url && url.indexOf("sql.meiyunji.net") === -1) return;
133
+ const frameTabId = payload && payload.tabId;
134
+
135
+ const opcode = payload && payload.opcode;
136
+ const data = payload && payload.data;
137
+ if (opcode !== 2 || typeof data !== "string") return;
138
+ let obj = null;
139
+ try {
140
+ obj = msgpackDecode(Buffer.from(data, "base64"));
141
+ } catch { return; }
142
+ if (!obj || obj.results == null) return; // 心跳帧忽略
143
+
144
+ let consumed = false;
145
+ if (yrRunWaiters.size > 0) {
146
+ for (const waiter of [...yrRunWaiters]) {
147
+ // tabId 双向可识别时严格隔离;帧缺 tabId(attach 前建立的旧 WS 连接,
148
+ // CDP 拿不到 webSocketCreated)时:仅当恰好只有一个 waiter 才兜底消费——
149
+ // 多 waiter 场景宁可不消费走超时,也不能猜错页面串结果。
150
+ if (waiter.tabId != null && payload.tabId != null && waiter.tabId !== payload.tabId) continue;
151
+ if (waiter.tabId != null && payload.tabId == null && yrRunWaiters.size > 1) continue;
152
+ if (waiter.tryConsume(obj)) { consumed = true; break; }
153
+ }
154
+ }
155
+
156
+ // 未被 yr-run 消费的结果帧 = 手动查询(或 yr-run 已完成后的重复帧),
157
+ // 也生成 CSV 记录。同一帧 yr-run 路径已经发过 export,不重复。
158
+ if (!consumed) {
159
+ const rows = Array.isArray(obj.results)
160
+ ? obj.results.map(t => (t && t.data ? t.data.length : 0)).reduce((a, b) => a + b, 0)
161
+ : 0;
162
+ sendToExtension({
163
+ type: "yr-export-csv",
164
+ reqId: "manual-" + genReqId(),
165
+ tabId: frameTabId ?? activeYearningTabId,
166
+ sql: "manual-query",
167
+ rows,
168
+ payload: JSON.stringify(obj),
169
+ });
170
+ }
171
+ }
172
+
173
+ async function handleYrRun(ws, msg) {
174
+ const reqId = msg.reqId || genReqId();
175
+ const sql = (msg.sql || "").toString();
176
+ const tabId = msg.tabId != null ? Number(msg.tabId) : activeYearningTabId;
177
+ const timeoutMs = Math.min(Number(msg.timeoutMs || 60000), 300000);
178
+ if (!sql.trim()) {
179
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty sql" }));
180
+ return;
181
+ }
182
+
183
+ // 结果帧等待者:收到第一个 results 非空帧即完成
184
+ let settled = false;
185
+ const runEntry = {
186
+ tabId,
187
+ sentAt: Date.now(),
188
+ tryConsume: (obj) => {
189
+ if (settled) return true;
190
+ settled = true;
191
+ yrRunWaiters.delete(runEntry);
192
+ console.log(TAG, `[yr-run ${reqId}] 结果帧到达(query_time=${obj.query_time ?? "?"})`);
193
+ // 同步发给插件:浏览器侧生成 CSV 落下载(popup 可见、可重新下载)
194
+ sendToExtension({
195
+ type: "yr-export-csv",
196
+ reqId,
197
+ tabId: tabId ?? activeYearningTabId,
198
+ sql: sql.slice(0, 120),
199
+ rows: Array.isArray(obj.results)
200
+ ? obj.results.map(t => (t && t.data ? t.data.length : 0)).reduce((a, b) => a + b, 0)
201
+ : 0,
202
+ payload: JSON.stringify(obj),
203
+ });
204
+ ws.send(JSON.stringify({
205
+ type: "result", reqId, ok: true,
206
+ output: JSON.stringify(obj),
207
+ elapsedMs: Date.now() - runEntry.sentAt,
208
+ }));
209
+ return true;
210
+ },
211
+ };
212
+ yrRunWaiters.add(runEntry);
213
+
214
+ const timer = setTimeout(() => {
215
+ if (settled) return;
216
+ settled = true;
217
+ yrRunWaiters.delete(runEntry);
218
+ ws.send(JSON.stringify({
219
+ type: "result", reqId, ok: false, error: "timeout",
220
+ message: "Yearning 查询未在时限内返回结果帧(确认页面已点「监听当前页 WS」且查询能正常执行)",
221
+ elapsedMs: Date.now() - runEntry.sentAt,
222
+ }));
223
+ }, timeoutMs);
224
+
225
+ // 1. 注入 SQL
226
+ const setRes = await sendYrCmd("sql-set", sql, tabId);
227
+ if (!setRes.ok) {
228
+ if (!settled) {
229
+ settled = true;
230
+ yrRunWaiters.delete(runEntry);
231
+ clearTimeout(timer);
232
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "sql-set failed: " + (setRes.error || ""), message: "SQL 注入 Yearning 编辑器失败" }));
233
+ }
234
+ return;
235
+ }
236
+ console.log(TAG, `[yr-run ${reqId}] SQL 已注入(via ${setRes.via})`);
237
+
238
+ // 2. 点「查询」
239
+ const clickRes = await sendYrCmd("query-click", "", tabId);
240
+ if (!clickRes.ok) {
241
+ if (!settled) {
242
+ settled = true;
243
+ yrRunWaiters.delete(runEntry);
244
+ clearTimeout(timer);
245
+ ws.send(JSON.stringify({
246
+ type: "result", reqId, ok: false,
247
+ error: "query-click failed: " + (clickRes.error || ""),
248
+ message: "未找到「查询」按钮;页面按钮: " + JSON.stringify(clickRes.info || []).slice(0, 300),
249
+ }));
250
+ }
251
+ return;
252
+ }
253
+ console.log(TAG, `[yr-run ${reqId}] 已点「查询」(via ${clickRes.via}),等待结果帧...`);
254
+ // 3. 结果帧由 feedYearningWaiters 消费(timer 兜底)
255
+ }
256
+
56
257
  // ===================== 请求-响应配对 =====================
57
258
  // pending: reqId -> { resolve, timer, buffer, cmd, sentAt }
58
259
  //
@@ -676,9 +877,64 @@ wss.on("connection", (ws, req) => {
676
877
  return;
677
878
  }
678
879
 
880
+ // --- Agent 发来的 Yearning SQL 查询(注入编辑器 + 点查询 + 收 WS 结果)---
881
+ if (msg.type === "yr-run") {
882
+ handleYrRun(ws, msg);
883
+ return;
884
+ }
885
+ if (msg.type === "yr-result") {
886
+ handleYrResult(msg);
887
+ return;
888
+ }
889
+ if (msg.type === "yr-active-tab") {
890
+ activeYearningTabId = msg.tabId != null ? Number(msg.tabId) : null;
891
+ console.log(TAG, `[yr] active tab = ${activeYearningTabId}`);
892
+ return;
893
+ }
894
+ if (msg.type === "yr-ping") {
895
+ // 探测:编辑器类型 + 查询按钮(不执行任何操作)
896
+ sendYrCmd("ping", "", msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
897
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify({ via: r.via, error: r.error, info: r.info }, null, 1) }));
898
+ });
899
+ return;
900
+ }
901
+ if (msg.type === "yr-set") {
902
+ // 只注入 SQL 不点查询(用户手动点,配合 tap 探针收结果)
903
+ sendYrCmd("sql-set", msg.sql || "", msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
904
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify({ via: r.via, error: r.error }) }));
905
+ });
906
+ return;
907
+ }
908
+
909
+ // --- Agent 发来的 WS 监听(tap)请求:非终端页面(Yearning 等)的帧流 ---
910
+ if (msg.type === "tap-start") {
911
+ const urlIncludes = (msg.urlIncludes || "").toString();
912
+ if (!urlIncludes) {
913
+ ws.send(JSON.stringify({ type: "tap-error", error: "missing urlIncludes" }));
914
+ return;
915
+ }
916
+ const tapTabId = msg.tabId != null ? Number(msg.tabId) : null;
917
+ tapClients.set(ws, { urlIncludes, tabId: tapTabId });
918
+ console.log(TAG, `tap client 已注册 (urlIncludes=${urlIncludes}, tabId=${tapTabId}, total=${tapClients.size})`);
919
+ ws.send(JSON.stringify({ type: "tap-started", urlIncludes }));
920
+ return;
921
+ }
922
+ if (msg.type === "tap-stop") {
923
+ tapClients.delete(ws);
924
+ console.log(TAG, `tap client 已注销 (total=${tapClients.size})`);
925
+ ws.send(JSON.stringify({ type: "tap-stopped" }));
926
+ return;
927
+ }
928
+
679
929
  // --- 插件上报的 WS 帧 ---
680
930
  if (msg.type === "ws-recv") {
681
- handleWsRecv(msg.payload);
931
+ // 先喂 tap 通道(按 URL 过滤转发原始帧),再喂终端配对。
932
+ // 终端配对只吃终端特征的 URL(koko / arthas / 空 = 旧插件不带 url),
933
+ // 防止 tap 页面(Yearning JSON 帧)污染终端命令的输出配对。
934
+ broadcastTap(msg.payload);
935
+ const frameUrl = (msg.payload && msg.payload.url) || "";
936
+ const isTerminalUrl = !frameUrl || /\/koko\/ws|connectArthas/i.test(frameUrl);
937
+ if (isTerminalUrl) handleWsRecv(msg.payload);
682
938
  return;
683
939
  }
684
940
  if (msg.type === "ws-send") {
@@ -707,6 +963,9 @@ wss.on("connection", (ws, req) => {
707
963
 
708
964
  ws.on("close", () => {
709
965
  clients.delete(ws);
966
+ if (tapClients.delete(ws)) {
967
+ console.log(TAG, `tap client 已断开 (total=${tapClients.size})`);
968
+ }
710
969
  if (ws === extensionWs) {
711
970
  extensionWs = null;
712
971
  console.log(TAG, "extension 已断开");
@@ -0,0 +1,137 @@
1
+ // WS 监听(tap)探针客户端 —— 捕获非终端页面(Yearning 等)的 WebSocket 帧
2
+ //
3
+ // 用法:
4
+ // node tap-example.mjs <urlIncludes> [maxFrames] [maxSeconds] [--quiet] [--csv]
5
+ // node tap-example.mjs sql.meiyunji.net # 监听 URL 含该关键字的帧,直到 Ctrl+C
6
+ // node tap-example.mjs sql.meiyunji.net 20 60 # 收满 20 帧或 60 秒退出
7
+ // node tap-example.mjs sql.meiyunji.net 0 300 --quiet --csv # 只看有效帧并把每次查询结果存 CSV
8
+ //
9
+ // 工作原理:连本地代理发 tap-start,代理把插件 CDP 抓到的、URL 匹配的
10
+ // ws-recv 原始帧以 tap-frame 转发过来。opcode=2 的帧 data 是 base64,
11
+ // 本脚本自动解码后打印。配合插件 popup 的「📡 监听当前 Yearning 页」按钮使用。
12
+ // --csv:结果帧(results 数组)自动转 CSV 保存到当前目录(yearning-<时间戳>.csv,
13
+ // 多结果集会带 -1/-2 序号;带 BOM,Excel 直接打开中文不乱码)。
14
+
15
+ import WebSocket from "ws";
16
+ import { decode as msgpackDecode } from "@msgpack/msgpack";
17
+ import { writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { cwd } from "node:process";
20
+
21
+ const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
22
+ const urlIncludes = process.argv[2] || "";
23
+ const maxFrames = Number(process.argv[3] || 0); // 0 = 不限
24
+ const maxSeconds = Number(process.argv[4] || 0); // 0 = 不限
25
+ const quiet = process.argv.includes("--quiet"); // 跳过心跳帧,只打印有效负载
26
+ const csvExport = process.argv.includes("--csv"); // 结果帧自动保存 CSV
27
+
28
+ // CSV cell 转义:含逗号/引号/换行的值加引号,内部引号翻倍(RFC 4180)
29
+ function csvCell(value) {
30
+ if (value == null) return "";
31
+ const s = String(value);
32
+ if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
33
+ return s;
34
+ }
35
+
36
+ if (!urlIncludes) {
37
+ console.error("用法: node tap-example.mjs <urlIncludes> [maxFrames] [maxSeconds]");
38
+ process.exit(1);
39
+ }
40
+
41
+ const ws = new WebSocket(BRIDGE);
42
+ let frames = 0;
43
+ let bytes = 0;
44
+
45
+ ws.on("open", () => {
46
+ console.log(`→ tap-start (urlIncludes=${urlIncludes})`);
47
+ console.log(" 请在浏览器目标页面操作(如执行 SQL 查询),帧将实时打印:\n");
48
+ ws.send(JSON.stringify({ type: "tap-start", urlIncludes }));
49
+ });
50
+
51
+ ws.on("message", (raw) => {
52
+ let msg;
53
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
54
+
55
+ if (msg.type === "tap-started") {
56
+ console.log(`✓ 监听已建立(${msg.urlIncludes})\n`);
57
+ return;
58
+ }
59
+ if (msg.type === "tap-error") {
60
+ console.error("✗ tap-error:", msg.error);
61
+ process.exit(1);
62
+ }
63
+ if (msg.type !== "tap-frame") return;
64
+
65
+ let text = msg.data || "";
66
+ if (msg.opcode === 2 && text) {
67
+ // 二进制帧:CDP 给的是 base64。解码后若像 MessagePack(map/array/str 头)
68
+ // 自动 msgpack 解码成 JSON 展示(Yearning 走 msgpack 二进制帧)
69
+ const buf = Buffer.from(text, "base64");
70
+ const first = buf[0];
71
+ const looksMsgpack =
72
+ (first >= 0x80 && first <= 0x8f) || first === 0xde || first === 0xdf ||
73
+ first === 0x91 || (first >= 0x90 && first <= 0x9f);
74
+ if (looksMsgpack) {
75
+ try {
76
+ text = JSON.stringify(msgpackDecode(buf), null, 1);
77
+ } catch {
78
+ text = buf.toString("utf8"); // 解码失败退回原文(乱码但可见)
79
+ }
80
+ } else {
81
+ text = buf.toString("utf8");
82
+ }
83
+ }
84
+
85
+ // --quiet:跳过纯心跳帧(results 为 null 且 heartbeat=pong),只打印有效负载帧
86
+ if (quiet && text.includes('"results": null')) return;
87
+
88
+ // CSV 导出:结果帧(results 数组)自动落盘。
89
+ // 每个 result 元素是一张表(field=列定义 data=数据行),多表写多个文件。
90
+ if (csvExport) {
91
+ let obj = null;
92
+ try { obj = JSON.parse(text); } catch {}
93
+ if (obj && Array.isArray(obj.results) && obj.results.length > 0) {
94
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
95
+ obj.results.forEach((table, idx) => {
96
+ if (!table || !Array.isArray(table.field)) return;
97
+ const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
98
+ const rows = (table.data || []).map(row =>
99
+ table.field.map(f => csvCell(row[f.dataIndex])).join(",")
100
+ );
101
+ const csv = "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
102
+ const file = join(cwd(), `yearning-${stamp}${obj.results.length > 1 ? "-" + (idx + 1) : ""}.csv`);
103
+ writeFileSync(file, csv, "utf8");
104
+ console.log(`💾 CSV 已保存: ${file}(${rows.length} 行)`);
105
+ });
106
+ }
107
+ }
108
+
109
+ frames++;
110
+ bytes += text.length;
111
+
112
+ console.log(`--- frame #${frames} [${new Date(msg.t || Date.now()).toISOString()}] opcode=${msg.opcode} len=${text.length} url=${msg.url || "(unknown)"} ---`);
113
+ console.log(text.length > 6000 ? text.slice(0, 6000) + `\n...[截断,共 ${text.length} 字符]` : text);
114
+ console.log("");
115
+
116
+ if (maxFrames > 0 && frames >= maxFrames) {
117
+ console.log(`✓ 已收满 ${maxFrames} 帧(共 ${bytes} 字符),退出`);
118
+ process.exit(0);
119
+ }
120
+ });
121
+
122
+ ws.on("error", (err) => {
123
+ console.error("✗ ws error:", err.message);
124
+ process.exit(1);
125
+ });
126
+
127
+ if (maxSeconds > 0) {
128
+ setTimeout(() => {
129
+ console.log(`✓ 到达 ${maxSeconds}s 时限(收帧 ${frames} 个 / ${bytes} 字符),退出`);
130
+ process.exit(0);
131
+ }, maxSeconds * 1000);
132
+ }
133
+
134
+ process.on("SIGINT", () => {
135
+ console.log(`\n✓ 手动停止(收帧 ${frames} 个 / ${bytes} 字符)`);
136
+ process.exit(0);
137
+ });
@@ -0,0 +1,110 @@
1
+ // Yearning SQL 自动化客户端
2
+ //
3
+ // 用法:
4
+ // node yr-example.mjs ping # 探测编辑器/按钮
5
+ // node yr-example.mjs "SELECT 1" # 执行查询(注入+点查询+收结果)
6
+ // node yr-example.mjs "SELECT ..." 30000 # 指定超时 ms
7
+ // node yr-example.mjs "SELECT ..." 60000 --csv # 结果另存 CSV(当前目录)
8
+ //
9
+ // 多页面时用环境变量指定目标 tab:YEARNING_TAB_ID=123 node yr-example.mjs "..."
10
+ // 不指定时使用 popup「Yearning 监听」列表中选中的页面。
11
+ //
12
+ // 前置:Yearning 页面已开 + popup 已点「📡 监听当前 Yearning 页」;多页面时先选中目标 tab
13
+
14
+ import WebSocket from "ws";
15
+ import { randomBytes } from "node:crypto";
16
+ import { writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { cwd } from "node:process";
19
+
20
+ const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
21
+ const selectedTabId = process.env.YEARNING_TAB_ID ? Number(process.env.YEARNING_TAB_ID) : undefined;
22
+
23
+ function yrRun(msg, timeoutMs) {
24
+ return new Promise((resolve) => {
25
+ const ws = new WebSocket(BRIDGE);
26
+ const reqId = randomBytes(4).toString("hex");
27
+ let settled = false;
28
+ const finish = (r) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ try { ws.close(); } catch {}
32
+ resolve(r);
33
+ };
34
+ const fallback = setTimeout(
35
+ () => finish({ ok: false, error: "client timeout" }),
36
+ timeoutMs + 5000
37
+ );
38
+ ws.on("open", () => ws.send(JSON.stringify({ type: "yr-run", reqId, tabId: selectedTabId, ...msg })));
39
+ ws.on("message", (raw) => {
40
+ let m;
41
+ try { m = JSON.parse(raw.toString()); } catch { return; }
42
+ if (m.type === "result" && m.reqId === reqId) {
43
+ clearTimeout(fallback);
44
+ finish({ ok: !!m.ok, output: m.output, error: m.error, message: m.message, elapsedMs: m.elapsedMs });
45
+ }
46
+ });
47
+ ws.on("error", (err) => { clearTimeout(fallback); finish({ ok: false, error: "ws error: " + err.message }); });
48
+ });
49
+ }
50
+
51
+ // CSV cell 转义:含逗号/引号/换行的值加引号,内部引号翻倍(RFC 4180)
52
+ function csvCell(value) {
53
+ if (value == null) return "";
54
+ const s = String(value);
55
+ if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
56
+ return s;
57
+ }
58
+
59
+ // 结果 JSON(yr-run output)转 CSV 文件。多结果集写多个文件。
60
+ function saveResultCsv(jsonText) {
61
+ let obj;
62
+ try { obj = JSON.parse(jsonText); } catch { return; }
63
+ if (!Array.isArray(obj.results)) return;
64
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
65
+ obj.results.forEach((table, idx) => {
66
+ if (!table || !Array.isArray(table.field)) return;
67
+ const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
68
+ const rows = (table.data || []).map(row =>
69
+ table.field.map(f => csvCell(row[f.dataIndex])).join(",")
70
+ );
71
+ const csv = "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
72
+ const file = join(cwd(), `yearning-${stamp}${obj.results.length > 1 ? "-" + (idx + 1) : ""}.csv`);
73
+ writeFileSync(file, csv, "utf8");
74
+ console.log(`💾 CSV 已保存: ${file}(${rows.length} 行)`);
75
+ });
76
+ }
77
+
78
+ const mode = process.argv[2] || "ping";
79
+ const csvExport = process.argv.includes("--csv");
80
+
81
+ if (mode === "ping") {
82
+ const ws = new WebSocket(BRIDGE);
83
+ ws.on("open", () => ws.send(JSON.stringify({ type: "yr-ping", reqId: randomBytes(4).toString("hex"), tabId: selectedTabId })));
84
+ ws.on("message", (raw) => {
85
+ let m;
86
+ try { m = JSON.parse(raw.toString()); } catch { process.exit(1); }
87
+ if (m.type !== "result") return;
88
+ console.log(m.ok ? "✓ ping ok" : "✗ ping failed");
89
+ console.log(m.output || m.error || "");
90
+ process.exit(m.ok ? 0 : 1);
91
+ });
92
+ ws.on("error", (e) => { console.error("✗", e.message); process.exit(1); });
93
+ setTimeout(() => { console.error("✗ timeout"); process.exit(1); }, 8000);
94
+ } else {
95
+ const cliArgs = process.argv.slice(2).filter(a => a !== "--csv");
96
+ const sql = cliArgs[0];
97
+ const timeoutMs = Number(cliArgs[1] || 60000);
98
+ console.log(`→ yr-run${selectedTabId ? ` [tab ${selectedTabId}]` : ""}: ${sql.slice(0, 100)}`);
99
+ const r = await yrRun({ sql, timeoutMs }, timeoutMs);
100
+ if (r.ok) {
101
+ console.log(`✓ ok (${r.elapsedMs}ms)`);
102
+ if (csvExport) saveResultCsv(r.output);
103
+ console.log(r.output);
104
+ } else {
105
+ console.error(`✗ failed: ${r.error}`);
106
+ if (r.message) console.error(r.message);
107
+ if (r.output) console.log(r.output);
108
+ process.exit(1);
109
+ }
110
+ }
@@ -100,6 +100,8 @@ cd ~/.terminal-bridge/proxy && node server.js &
100
100
 
101
101
  多终端场景:如果同时开了 JumpServer 和 Arthas 两个 tab,代理命令只会发给 popup 里"当前选中"的那个 tab。切换用 popup 的 tab 选择器,或让用户在 popup 点选。
102
102
 
103
+ Yearning 场景:popup 的「Yearning 监听」会列出所有已监听页面,并显示数据库名、数据源、host 和当前标记。多个 Yearning 页面同时打开时,必须先点击列表项选择「当前 Yearning 页面」,SQL 只会注入该页面,结果也按 tab 隔离,不会串页。
104
+
103
105
  如果用户说"命令没反应"或"inject-failed":
104
106
  1. `chrome://extensions/` 刷新 Terminal Bridge 插件 ↻
105
107
  2. 让用户在终端页面按 F5 刷新(让 content script 重新识别 xterm)
@@ -13,7 +13,28 @@
13
13
  - **插件(Chrome extension background)**:唯一,连上后发 `hello{role:extension}` 声明身份。负责上报 WS 帧、接收注入指令。
14
14
  - **Agent**:可有多个。发 `run` 请求,收 `result` 响应。
15
15
 
16
- ## 完整消息列表
16
+ ## Yearning 多页面监听
17
+
18
+ Yearning 页面必须先在 popup 的「Yearning 监听」列表中监听并选择一个页面。
19
+ 每个监听项包含页面标题、数据库名/数据源、host 和 tabId;`✓ 当前 Yearning 页面`
20
+ 表示 SQL 自动化的目标页面,`● 当前浏览器页`表示用户当前正在看的 tab。
21
+
22
+ - 多个 Yearning 页面可以同时监听,但 SQL 只会注入到选中的 active tab。
23
+ - 结果帧带 `tabId`,代理只消费与目标 tab 相同的结果,避免多个页面串结果。
24
+ - 手动客户端可通过环境变量显式指定:`YEARNING_TAB_ID=123 node yr-example.mjs "show index from t_dk_message__8;"`
25
+ - `yr-run` 未传 `tabId` 时使用 popup 当前选中的 Yearning 页面。
26
+
27
+ ### Yearning 自动化消息
28
+
29
+ ```json
30
+ { "type": "yr-run", "reqId": "abc123", "sql": "show index from t_dk_message__8;", "timeoutMs": 60000, "tabId": 123 }
31
+ ```
32
+
33
+ `yr-run` 会注入 SQL、点击 Yearning 的「查 询」按钮,并等待该 tab 的 MessagePack
34
+ WebSocket 结果帧。Yearning 结果帧是 opcode=2 二进制帧,解码后通常为
35
+ `{ export, error, results, query_time, status, heartbeat, is_only }`。
36
+
37
+ ### 完整消息列表
17
38
 
18
39
  ### Agent → 代理
19
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "2.9.1",
3
+ "version": "3.0.0",
4
4
  "description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
5
5
  "license": "MIT",
6
6
  "author": "encorearon",