terminal-bridge-setup 3.0.0 → 3.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.
@@ -135,6 +135,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
135
135
  });
136
136
  return true; // 异步
137
137
  }
138
+ if (msg.type === "CSV_CLEAR") {
139
+ chrome.storage.session.remove(CSV_STORAGE_KEY).catch(() => {});
140
+ sendResponse({ ok: true });
141
+ return false;
142
+ }
138
143
  if (msg.type === "CSV_DOWNLOAD") {
139
144
  getCsvExports().then(list => {
140
145
  const record = list.find(e => e.id === msg.id);
@@ -143,13 +148,38 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
143
148
  return;
144
149
  }
145
150
  chrome.downloads.download(
146
- { url: record.dataUrl, filename: record.name, saveAs: true },
151
+ { url: record.dataUrl, filename: "yearning-csv/" + record.name, saveAs: false },
147
152
  (downloadId) => {
148
153
  if (chrome.runtime.lastError) {
149
154
  sendResponse({ ok: false, msg: chrome.runtime.lastError.message });
150
155
  return;
151
156
  }
152
- sendResponse({ ok: true, downloadId });
157
+ sendResponse({ ok: true, downloadId, path: "~/Downloads/yearning-csv/" + record.name });
158
+ }
159
+ );
160
+ });
161
+ return true; // 异步
162
+ }
163
+ if (msg.type === "CSV_PROMPT") {
164
+ // 🤖 按钮:静默下载该文件到固定目录,返回让 Agent 读文件的 prompt
165
+ getCsvExports().then(list => {
166
+ const record = list.find(e => e.id === msg.id);
167
+ if (!record) {
168
+ sendResponse({ ok: false, msg: "导出记录不存在" });
169
+ return;
170
+ }
171
+ chrome.downloads.download(
172
+ { url: record.dataUrl, filename: "yearning-csv/" + record.name, saveAs: false },
173
+ (downloadId) => {
174
+ if (chrome.runtime.lastError) {
175
+ sendResponse({ ok: false, msg: chrome.runtime.lastError.message });
176
+ return;
177
+ }
178
+ const table = record.table || record.name.split("_")[0];
179
+ sendResponse({
180
+ ok: true,
181
+ prompt: `表名${table}的查询结果请读这个文件 ~/Downloads/yearning-csv/${record.name}`,
182
+ });
153
183
  }
154
184
  );
155
185
  });
@@ -672,20 +702,47 @@ function csvCell(value) {
672
702
  return s;
673
703
  }
674
704
 
675
- // Yearning 结果 JSON → CSV 文本。多结果集时只取第一个非空表
676
- // (Yearning SHOW INDEX 会推两份相同结果,取一即可)
705
+ // Yearning 结果 JSON → CSV 文本。多结果集全部写入同一个 CSV,不能只取第一张表:
706
+ // 一次执行多条 SQL results 有多项,popup 行数是总和,文件也必须包含总和。
707
+ // 每组结果独立表头,组间用 Result Set 标记和空行分隔,兼容不同 SQL 的列结构。
677
708
  function resultJsonToCsv(jsonText) {
678
709
  let obj;
679
710
  try { obj = JSON.parse(jsonText); } catch { return null; }
680
711
  if (!Array.isArray(obj.results)) return null;
681
- const table = obj.results.find(t => t && Array.isArray(t.field) && t.field.length > 0)
682
- || obj.results[0];
683
- if (!table || !Array.isArray(table.field)) return null;
684
- const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
685
- const rows = (table.data || []).map(row =>
686
- table.field.map(f => csvCell(row[f.dataIndex])).join(",")
687
- );
688
- return "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
712
+
713
+ const blocks = [];
714
+ obj.results.forEach((table, index) => {
715
+ if (!table || !Array.isArray(table.field) || table.field.length === 0) return;
716
+ const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
717
+ const rows = (table.data || []).map(row =>
718
+ table.field.map(f => csvCell(row[f.dataIndex])).join(",")
719
+ );
720
+ // 多结果集时保留组标记;单结果集不额外增加噪音
721
+ if (obj.results.filter(t => t && Array.isArray(t.field) && t.field.length > 0).length > 1) {
722
+ blocks.push(`-- Result Set ${index + 1} --`);
723
+ }
724
+ blocks.push(headers.join(","), ...rows, "");
725
+ });
726
+ return blocks.length > 0 ? "\uFEFF" + blocks.join("\r\n") : null;
727
+ }
728
+
729
+ // 文件名:表名_库_数据源_日期。表名从 SQL 提取(from/into/update 后的词),
730
+ // 库和数据源从目标 tab 的 meta(tapTabMeta)取。
731
+ // 手动查询(sql 为 manual-query)时先读编辑器里的实际 SQL 再提取表名。
732
+ async function buildCsvFileName(sql, tabId) {
733
+ let sqlText = sql || "";
734
+ if (!sqlText || sqlText === "manual-query") {
735
+ const editor = await sendFrameMessage(tabId, { type: "yr-sql-get" }, 0);
736
+ if (editor?.ok && editor.sql) sqlText = editor.sql; // 手动查询后编辑器里就是刚执行的 SQL
737
+ }
738
+ const tableMatch = sqlText.match(/\b(?:from|into|update|join)\s+[`"]?(\w+)[`"]?/i);
739
+ const table = (tableMatch ? tableMatch[1] : "").slice(0, 40) || "query";
740
+ const meta = tapTabMeta.get(tabId) || {};
741
+ const database = (meta.database || "").replace(/[^\w-]+/g, "") || "nodb";
742
+ const source = (meta.dataSource || "").split(" · ")[0].replace(/[^\w.-]+/g, "") || "nosrc";
743
+ const date = new Date().toISOString().slice(0, 10);
744
+ const time = new Date().toTimeString().slice(0, 5).replace(":", "");
745
+ return { name: `${table}_${database}_${source}_${date}_${time}.csv`, table };
689
746
  }
690
747
 
691
748
  async function handleYrExportCsv(frame) {
@@ -694,13 +751,12 @@ async function handleYrExportCsv(frame) {
694
751
  console.warn("[bg] yr-export-csv: 结果 JSON 解析失败或无表结构");
695
752
  return;
696
753
  }
697
- const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
698
- const sqlHead = (frame.sql || "query").slice(0, 30).replace(/[^\w-]+/g, "_");
699
- const name = `yearning-${sqlHead}-${stamp}.csv`;
754
+ const { name, table } = await buildCsvFileName(frame.sql, frame.tabId);
700
755
  const dataUrl = "data:text/csv;charset=utf-8," + encodeURIComponent(csv);
701
756
  await addCsvExport({
702
757
  id: Date.now(),
703
758
  name,
759
+ table,
704
760
  rows: frame.rows || 0,
705
761
  sql: frame.sql || "",
706
762
  time: Date.now(),
@@ -107,7 +107,8 @@
107
107
  if (idc && idc !== dataSource) dataSource = dataSource ? `${dataSource} · ${idc}` : idc;
108
108
  } catch {}
109
109
 
110
- // 3. 兜底:form 启发式
110
+ // 3. 兜底:form 启发式(排除与 dataSource 重叠/相似的值,防止把
111
+ // 数据源名误认成数据库名——文件名第二段曾因此错成 dk_shard)
111
112
  if (!database || !dataSource) {
112
113
  const form = document.querySelector("form") || xpathNode(FORM_XPATH);
113
114
  if (form) {
@@ -117,9 +118,11 @@
117
118
  const value = el.tagName === "SELECT" ? el.options[el.selectedIndex]?.textContent : el.value;
118
119
  if (value?.trim()) values.push(value.trim());
119
120
  });
120
- const unique = [...new Set(values)].filter(v => !/^(查询|执行|取消|确定|SQL)$/i.test(v));
121
+ const unique = [...new Set(values)].filter(v =>
122
+ !/^(查询|执行|取消|确定|SQL)$/i.test(v) &&
123
+ v !== dataSource && !dataSource.includes(v) && !v.includes("shard"));
121
124
  if (!dataSource) dataSource = unique.find(v => /source|实例|数据源|tdsql|mysql|prod|test/i.test(v)) || "";
122
- if (!database) database = unique.find(v => /database|db|库|schema/i.test(v)) || "";
125
+ if (!database) database = unique.find(v => /database|schema|^\w+_dk\b/i.test(v)) || "";
123
126
  }
124
127
  }
125
128
 
@@ -293,6 +296,11 @@
293
296
  sendResponse({ ok: true });
294
297
  return true;
295
298
  }
299
+ if (msg.type === "yr-sql-get") {
300
+ // 读当前编辑器完整 SQL(手动查询结果帧到达时,编辑器里就是刚执行的 SQL)
301
+ sendResponse({ ok: true, sql: readMonacoText() });
302
+ return true;
303
+ }
296
304
  if (msg.type === "yr-verify-sql") {
297
305
  // CDP 注入后验证:读回 monaco view-lines 内容比对
298
306
  const current = readMonacoText();
@@ -131,6 +131,10 @@
131
131
  .csv-item:hover { background: #dcebfd; }
132
132
  .csv-item .csv-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133
133
  .csv-item .csv-rows { color: #888; font-size: 10px; flex-shrink: 0; }
134
+ .csv-prompt-btn {
135
+ width: auto !important; padding: 2px 7px !important; margin: 0 !important;
136
+ font-size: 11px; background: #7c4dff; flex-shrink: 0;
137
+ }
134
138
  .guide {
135
139
  font-size: 11px;
136
140
  color: #666;
@@ -200,10 +204,14 @@
200
204
  <div class="hint" id="wsTapStatus">未监听 Yearning 页面</div>
201
205
  <div id="yearningTabList" style="margin-top:6px;"></div>
202
206
  <div id="csvSection" style="margin-top:8px;display:none;">
203
- <div class="section-title" style="margin-bottom:4px;">CSV 导出记录</div>
207
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
208
+ <span class="section-title" style="margin:0;">CSV 导出记录</span>
209
+ <button class="copy-btn" id="btnCsvClear" style="background:#e53935;">清空</button>
210
+ </div>
204
211
  <div id="csvList"></div>
205
- <div class="hint">点击记录可重新下载(仅保留最近 20 条,插件刷新后清空)</div>
212
+ <div class="hint">点击记录下载 · 表名_库_数据源_日期.csv</div>
206
213
  </div>
214
+ <button class="scan" id="btnCopyPrompt" style="margin-top:8px;background:#7c4dff;">📋 复制 Agent 使用 Prompt</button>
207
215
  </div>
208
216
 
209
217
  <div class="section">
@@ -144,17 +144,22 @@ const csvList = document.getElementById('csvList');
144
144
  function refreshCsvList() {
145
145
  chrome.runtime.sendMessage({ type: 'CSV_LIST' }, (res) => {
146
146
  if (chrome.runtime.lastError || !res || !res.ok) return;
147
- const exports = res.exports || [];
148
- csvSection.style.display = exports.length ? 'block' : 'none';
147
+ const all = res.exports || [];
148
+ csvSection.style.display = all.length ? 'block' : 'none';
149
+ // 只显示最近 5 条,popup 不超长(存储仍保留 20 条)
150
+ const exports = all.slice(0, 5);
149
151
  csvList.innerHTML = exports.map(e => {
150
152
  const time = new Date(e.time).toLocaleTimeString();
151
153
  return `<div class="csv-item" data-id="${e.id}" title="${escapeHtml(e.sql || e.name)}">
152
154
  <span class="csv-name">📄 ${escapeHtml(e.name)}</span>
153
155
  <span class="csv-rows">${e.rows} 行 · ${time}</span>
156
+ <button class="copy-btn csv-prompt-btn" data-name="${escapeHtml(e.name)}" title="复制让 Agent 读这个文件的 Prompt">🤖</button>
154
157
  </div>`;
155
158
  }).join('');
156
159
  csvList.querySelectorAll('.csv-item').forEach(el => {
157
- el.onclick = () => {
160
+ el.onclick = (ev) => {
161
+ // 点复制按钮不触发行点击(下载)
162
+ if (ev.target.closest('.csv-prompt-btn')) return;
158
163
  chrome.runtime.sendMessage({ type: 'CSV_DOWNLOAD', id: Number(el.dataset.id) }, (r) => {
159
164
  if (chrome.runtime.lastError || !r || !r.ok) {
160
165
  console.warn('重新下载失败:', r && r.msg);
@@ -162,10 +167,61 @@ function refreshCsvList() {
162
167
  });
163
168
  };
164
169
  });
170
+ csvList.querySelectorAll('.csv-prompt-btn').forEach(btn => {
171
+ btn.onclick = (ev) => {
172
+ ev.stopPropagation();
173
+ const id = Number(btn.closest('.csv-item').dataset.id);
174
+ chrome.runtime.sendMessage({ type: 'CSV_PROMPT', id }, (r) => {
175
+ if (chrome.runtime.lastError || !r || !r.ok) {
176
+ console.warn('prompt 失败:', r && r.msg);
177
+ return;
178
+ }
179
+ navigator.clipboard.writeText(r.prompt).then(() => {
180
+ btn.textContent = '✓';
181
+ setTimeout(() => { btn.textContent = '🤖'; }, 1500);
182
+ }).catch(() => {});
183
+ });
184
+ };
185
+ });
165
186
  });
166
187
  }
167
188
  refreshCsvList();
168
189
 
190
+ // 清空 CSV 导出记录
191
+ document.getElementById('btnCsvClear').onclick = () => {
192
+ chrome.runtime.sendMessage({ type: 'CSV_CLEAR' }, () => refreshCsvList());
193
+ };
194
+
195
+ // 复制 Agent 使用 Prompt(粘给任意 AI 助手即可用桥接查 Yearning)
196
+ const AGENT_PROMPT = `你的机器上已装好 Terminal Bridge(Yearning SQL 桥接)。请按以下方式查询数据库:
197
+
198
+ ## 查询(Yearning)
199
+ cd ~/.terminal-bridge/proxy && node yr-example.mjs "SELECT ...;" [超时ms]
200
+
201
+ 返回 JSON:{ ok, output(结果 JSON: field 列定义 + data 数据行), error }。
202
+
203
+ ## 说明
204
+ - SQL 会自动注入到用户浏览器里选中的 Yearning 页面并点「查询」,结果自动返回
205
+ - 前置:用户已在 Yearning 页面选择数据库,且插件 popup 已「监听当前 Yearning 页」
206
+ - 多个 Yearning 页面时:用户在 popup 列表点选目标页面(显示 数据源 · 数据库)
207
+ - 报 database-not-selected = 页面未选数据库,请让用户先选
208
+ - 报 timeout = 查询超时或页面未监听,让用户确认 popup 状态
209
+ - 只读查询即可;每次查询的结果会自动出现在插件 popup 的「CSV 导出记录」里供用户下载
210
+
211
+ ## 终端命令(JumpServer/Arthas,同一代理)
212
+ cd ~/.terminal-bridge/proxy && node client-example.mjs "linux 命令" [超时ms]
213
+ 多层引号命令加 --b64 参数下发。`;
214
+
215
+ document.getElementById('btnCopyPrompt').onclick = () => {
216
+ navigator.clipboard.writeText(AGENT_PROMPT).then(() => {
217
+ const btn = document.getElementById('btnCopyPrompt');
218
+ btn.textContent = '✓ 已复制,粘贴给 Agent 即可';
219
+ setTimeout(() => { btn.textContent = '📋 复制 Agent 使用 Prompt'; }, 2000);
220
+ }).catch(() => {
221
+ console.warn('复制失败');
222
+ });
223
+ };
224
+
169
225
  // ============== 代理控制 ==============
170
226
  const proxyDot = document.getElementById('proxyDot');
171
227
  const proxyText = document.getElementById('proxyStatusText');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
5
5
  "license": "MIT",
6
6
  "author": "encorearon",