@zhangfengshun/dsh-remote-ssh 2.1.5 → 2.1.6

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.
Files changed (2) hide show
  1. package/lib/index.js +416 -21
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -661,18 +661,40 @@ function apply(ctx, config) {
661
661
  if (!s) { s = new CommandSession(subprocess, p); sessions.set(key, s); }
662
662
  return s;
663
663
  }
664
- /** 池化执行:复用持久会话,失败时回退到一次性 runRemote。 */
664
+ /** SSH 常见失败翻译成带修复建议的中文提示。 */
665
+ function sshErrorHint(text) {
666
+ const t = String(text || "");
667
+ if (/remote port forwarding failed for listen port (\d+)/i.test(t)) {
668
+ const m = t.match(/remote port forwarding failed for listen port (\d+)/i);
669
+ return "SSH 端口转发失败(本地端口 " + m[1] + " 已被占用)。这通常由 ~/.ssh/config 里的 RemoteForward 造成:远端该端口被上次会话占用时,ExitOnForwardFailure yes 会让 ssh 直接退出。本插件已对该连接加 ClearAllForwardings,若仍出现请检查 ssh config。原始信息: " + t.trim().slice(0, 300);
670
+ }
671
+ if (/permission denied \(publickey/i.test(t)) return "公钥认证失败:请检查 keyPath 私钥路径是否正确、远端 ~/.ssh/authorized_keys 是否包含对应公钥。原始信息: " + t.trim().slice(0, 300);
672
+ if (/connection refused/i.test(t)) return "连接被拒绝:请确认远端 sshd 已启动且端口正确。原始信息: " + t.trim().slice(0, 300);
673
+ if (/connection timed out|timed out/i.test(t)) return "连接超时:请确认网络可达、端口开放,或检查 ProxyJump 配置。原始信息: " + t.trim().slice(0, 300);
674
+ if (/could not resolve hostname/i.test(t)) return "无法解析主机名:请检查连接配置中的 host 拼写。原始信息: " + t.trim().slice(0, 300);
675
+ return t.trim().slice(0, 500);
676
+ }
677
+
678
+ /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。 */
665
679
  async function runPooled(p, cmd, stdinData, maxBytes) {
680
+ const dropSession = () => {
681
+ const key = profileKey(p);
682
+ const old = sessions.get(key);
683
+ if (old) { old.close(); sessions.delete(key); }
684
+ };
666
685
  try {
667
686
  const s = getSession(p);
668
687
  const r = await s.exec(cmd, stdinData);
669
- return { ok: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: r.exitCode !== 0 ? String(r.stdout).trim().slice(0, 500) : "", truncated: false };
688
+ if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: "", error: "", truncated: false };
689
+ // exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
690
+ if (r.exitCode === 255) dropSession();
691
+ return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: sshErrorHint(r.exitCode === 255 ? r.stdout : String(r.stdout).trim().slice(0, 500)), truncated: false };
670
692
  } catch (e) {
671
- // 会话挂了 —— 清理并回退到一次性连接
672
- const key = profileKey(p);
673
- const old = sessions.get(key);
674
- if (old) { old.close(); sessions.delete(key); }
675
- return runRemote(subprocess, p, cmd, stdinData, maxBytes);
693
+ // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
694
+ dropSession();
695
+ const r2 = await runRemote(subprocess, p, cmd, stdinData, maxBytes);
696
+ if (!r2.ok && r2.exitCode === 255) r2.error = sshErrorHint(r2.stdout || r2.stderr || r2.error);
697
+ return r2;
676
698
  }
677
699
  }
678
700
  // 定期清理空闲会话
@@ -1692,6 +1714,286 @@ function apply(ctx, config) {
1692
1714
  return null;
1693
1715
  }
1694
1716
 
1717
+ // ---- P0: 远程工作区的 Git 面板重定向(git.* 走远端,本地照旧本机 git)----
1718
+ const GIT_METHODS = ["status", "diff", "log", "branch", "commit-diff", "show", "stage", "unstage", "commit", "checkout", "discard", "revert", "cherry-pick"];
1719
+
1720
+ /** 本机 git 执行(better-sidebar 原行为):失败抛 {code:'git-error',message}。 */
1721
+ async function runGitLocal(cwd, args) {
1722
+ let handle;
1723
+ try {
1724
+ handle = subprocess.spawn({
1725
+ argv: ["git", "-C", cwd, "--no-pager", "-c", "color.ui=false", ...args],
1726
+ cwd: process.cwd(),
1727
+ stdio: {
1728
+ stdin: "ignore",
1729
+ stdout: { maxBytes: 8 * 1024 * 1024, spill: { maxBytes: 8 * 1024 * 1024 } },
1730
+ stderr: { maxBytes: 512 * 1024, spill: { maxBytes: 512 * 1024 } }
1731
+ },
1732
+ graceMs: 30000
1733
+ });
1734
+ } catch (e) {
1735
+ throw { code: "git-error", message: "cannot run git: " + String(e && e.message ? e.message : e) };
1736
+ }
1737
+ const outcome = await handle.done.catch((e) => { throw { code: "git-error", message: String(e && e.message ? e.message : e) }; });
1738
+ const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1739
+ const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1740
+ if (outcome.exitCode !== 0) throw { code: "git-error", message: String(se.text || "").trim() || ("git exited with " + outcome.exitCode) };
1741
+ return so.text;
1742
+ }
1743
+
1744
+ /** 远端 git 执行(远程工作区):在 remoteDir 里跑同一 git 语义。 */
1745
+ async function runGitRemote(profile, remoteDir, args) {
1746
+ const quoted = args.map((a) => shellQuote(a)).join(" ");
1747
+ const r = await runPooled(profile, "git -C " + shellQuotePath(remoteDir) + " --no-pager -c color.ui=false " + quoted, undefined, 8 * 1024 * 1024);
1748
+ if (!r.ok) throw { code: "git-error", message: String(r.stdout || r.error || "").trim() || "remote git exited with " + r.exitCode };
1749
+ return r.stdout;
1750
+ }
1751
+
1752
+ function parsePorcelainZ(output) {
1753
+ const tokens = String(output).split("\0");
1754
+ const entries = [];
1755
+ let index = 0;
1756
+ while (index < tokens.length) {
1757
+ const token = tokens[index];
1758
+ index += 1;
1759
+ if (token === "") continue;
1760
+ const xy = token.slice(0, 2);
1761
+ const rest = token.slice(3);
1762
+ entries.push({ path: rest, xy: xy });
1763
+ if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== undefined && tokens[index] !== "") index += 1;
1764
+ }
1765
+ return entries;
1766
+ }
1767
+
1768
+ function parseLogLines(output) {
1769
+ const rows = [];
1770
+ for (const line of String(output).split("\n")) {
1771
+ if (line === "") continue;
1772
+ const [hash, subject, author, date, hashFull, refs] = line.split("\x1f");
1773
+ if (hash === undefined || subject === undefined) continue;
1774
+ rows.push({ hash, subject, author: author ?? "", date: date ?? "", hashFull: hashFull ?? hash, refs: refs ?? "" });
1775
+ }
1776
+ return rows;
1777
+ }
1778
+
1779
+ /** git.status 的完整实现(isRepo 检测 + 分支 + porcelain 解析),本地/远端共用。 */
1780
+ async function gitStatusImpl(run, dir) {
1781
+ let inside = "false";
1782
+ try { inside = String((await run(dir, ["rev-parse", "--is-inside-work-tree"])).trim()); } catch (e) { inside = "false"; }
1783
+ if (inside !== "true") return { isRepo: false, entries: [] };
1784
+ let branch = "HEAD";
1785
+ try { branch = String((await run(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()) || "HEAD"; } catch (e) {}
1786
+ let raw = "";
1787
+ try {
1788
+ raw = await run(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]);
1789
+ } catch (e) {
1790
+ return { isRepo: true, branch: branch, entries: [] };
1791
+ }
1792
+ return { isRepo: true, branch: branch, entries: parsePorcelainZ(raw) };
1793
+ }
1794
+
1795
+ /** 解析一次 /sidebar/api/git.* 请求的上下文:远程工作区 → {profile, remoteDir},否则 null。 */
1796
+ function gitContextOf(payload) {
1797
+ const sessionId = payload && payload.sessionId;
1798
+ const clientCwd = payload && payload.cwd;
1799
+ let cwd = clientCwd;
1800
+ if ((!cwd || cwd === "") && sessionId) {
1801
+ const sessions = ctx.get("sessions");
1802
+ const session = sessions ? sessions.get(sessionId) : null;
1803
+ cwd = session && session.header && session.header.cwd;
1804
+ }
1805
+ if (!cwd) return null;
1806
+ const ws = matchRemoteWorkspace(cwd, cwd);
1807
+ if (!ws) return null;
1808
+ const remoteInfo = readRemoteInfoSync(ws.mirrorPath);
1809
+ const profile = remoteInfo && getProfile(remoteInfo.profileId);
1810
+ if (!remoteInfo || !profile) return null;
1811
+ const remoteDir = localToRemote(cwd, ws.mirrorPath, remoteInfo.remotePath);
1812
+ return { profile: profile, remoteDir: remoteDir, cwd: cwd };
1813
+ }
1814
+
1815
+ async function interceptGitHandler(req, res, method) {
1816
+ if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
1817
+ if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
1818
+ let payload;
1819
+ try { payload = await readJsonBody(req); }
1820
+ catch (e) { writeJson(res, 400, { ok: false, error: { code: "bad-request", message: String(e && e.message ? e.message : e) } }); return; }
1821
+ try {
1822
+ const remote = gitContextOf(payload);
1823
+ // 本地会话没有 session/cwd 时无法判定工作目录 → 按本机 git 于 process.cwd() 处理。
1824
+ const cwd = remote ? remote.remoteDir : (payload && payload.cwd) || process.cwd();
1825
+ const run = remote
1826
+ ? (dir, args) => runGitRemote(remote.profile, remote.remoteDir, args)
1827
+ : (dir, args) => runGitLocal(cwd, args);
1828
+ let value;
1829
+ switch (method) {
1830
+ case "status":
1831
+ value = await gitStatusImpl(run, cwd);
1832
+ break;
1833
+ case "diff": {
1834
+ const p = payload;
1835
+ const path = p.path !== undefined ? String(p.path) : undefined;
1836
+ const staged = p.staged === true;
1837
+ const args = ["diff", "--no-ext-diff", "--no-color", "-U3"];
1838
+ if (staged) args.push("--cached");
1839
+ if (path !== undefined && path !== "") args.push("--", path);
1840
+ value = { diff: await run(cwd, args) };
1841
+ break;
1842
+ }
1843
+ case "log": {
1844
+ const p = payload;
1845
+ const count = typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0 ? p.count : 30;
1846
+ const skip = typeof p.skip === "number" && Number.isInteger(p.skip) && p.skip >= 0 ? p.skip : 0;
1847
+ value = parseLogLines(await run(cwd, ["log", "-n", String(count), "--skip", String(skip), "--decorate=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"]));
1848
+ break;
1849
+ }
1850
+ case "branch": {
1851
+ let current = "HEAD";
1852
+ try { current = String((await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()) || "HEAD"; } catch (e) {}
1853
+ const raw = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
1854
+ const names = String(raw).split("\n").filter((l) => l !== "");
1855
+ value = { current: current, names: names.includes(current) ? names : [current, ...names] };
1856
+ break;
1857
+ }
1858
+ case "commit-diff": {
1859
+ const hash = String(payload && payload.hash || "").trim();
1860
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1861
+ value = { diff: await run(cwd, ["show", "--no-ext-diff", "--no-color", "--format=", "-m", "--first-parent", hash]) };
1862
+ break;
1863
+ }
1864
+ case "show": {
1865
+ const p = payload;
1866
+ const rev = String(p && p.rev || "").trim();
1867
+ const path = String(p && p.path || "").trim();
1868
+ if (!rev || !path) throw { code: "bad-request", message: "rev and path are required" };
1869
+ let content = null;
1870
+ try { content = await run(cwd, ["show", rev + ":" + path]); } catch (e) { content = null; }
1871
+ value = { content: content };
1872
+ break;
1873
+ }
1874
+ case "stage": {
1875
+ const path = payload && payload.path !== undefined ? String(payload.path) : undefined;
1876
+ await run(cwd, ["add", "-A", ...(path !== undefined && path !== "" ? ["--", path] : [])]);
1877
+ value = { ok: true };
1878
+ break;
1879
+ }
1880
+ case "unstage": {
1881
+ const path = payload && payload.path !== undefined ? String(payload.path) : undefined;
1882
+ await run(cwd, ["reset", "-q", ...(path !== undefined && path !== "" ? ["--", path] : [])]);
1883
+ value = { ok: true };
1884
+ break;
1885
+ }
1886
+ case "commit": {
1887
+ const message = String(payload && payload.message || "").trim();
1888
+ if (!message) throw { code: "bad-request", message: "message is required" };
1889
+ await run(cwd, ["commit", "-m", message]);
1890
+ value = { ok: true };
1891
+ break;
1892
+ }
1893
+ case "checkout": {
1894
+ const branch = String(payload && payload.branch || "").trim();
1895
+ if (!branch) throw { code: "bad-request", message: "branch is required" };
1896
+ await run(cwd, ["checkout", branch]);
1897
+ value = { ok: true };
1898
+ break;
1899
+ }
1900
+ case "discard": {
1901
+ const path = String(payload && payload.path || "").trim();
1902
+ if (!path) throw { code: "bad-request", message: "path is required" };
1903
+ await run(cwd, ["checkout", "--", path]);
1904
+ value = { ok: true };
1905
+ break;
1906
+ }
1907
+ case "revert": {
1908
+ const hash = String(payload && payload.hash || "").trim();
1909
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1910
+ await run(cwd, ["revert", "--no-edit", hash]);
1911
+ value = { ok: true };
1912
+ break;
1913
+ }
1914
+ case "cherry-pick": {
1915
+ const hash = String(payload && payload.hash || "").trim();
1916
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1917
+ await run(cwd, ["cherry-pick", hash]);
1918
+ value = { ok: true };
1919
+ break;
1920
+ }
1921
+ default:
1922
+ writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown git method " + method } }); return;
1923
+ }
1924
+ if (value === undefined) value = { ok: true };
1925
+ writeOk(res, value);
1926
+ } catch (e) {
1927
+ const code = (e && e.code) || "git-error";
1928
+ const message = (e && e.message) || String(e);
1929
+ writeJson(res, code === "bad-request" ? 400 : 500, { ok: false, error: { code: code, message: message } });
1930
+ }
1931
+ }
1932
+
1933
+ GIT_METHODS.forEach(function (m) {
1934
+ ctx.effect(() => ctx.webServer.register({
1935
+ kind: "exact",
1936
+ path: "/sidebar/api/git." + m,
1937
+ handler: function (req, res) { return interceptGitHandler(req, res, m); }
1938
+ }), "dsh-remote-ssh: intercept /sidebar/api/git." + m);
1939
+ });
1940
+
1941
+ /** 等待可写流 drain(带回退:已销毁则即时返回)。 */
1942
+ function awaitOnce(emitter, event) {
1943
+ return new Promise((resolve, reject) => {
1944
+ emitter.once(event, () => resolve());
1945
+ emitter.once("error", reject);
1946
+ });
1947
+ }
1948
+
1949
+ /** 流式写入:把请求体直接管道到远端 `cat > target`(二进制安全、恒定内存)。 */
1950
+ async function streamRemoteUpload(profile, target, req, limit) {
1951
+ let handle;
1952
+ try {
1953
+ handle = subprocess.spawn({
1954
+ argv: sshArgv(profile, "cat > " + shellQuotePath(target), false),
1955
+ cwd: process.cwd(),
1956
+ stdio: {
1957
+ stdin: "pipe",
1958
+ stdout: { maxBytes: 64 * 1024, spill: { maxBytes: 64 * 1024 } },
1959
+ stderr: { maxBytes: 64 * 1024, spill: { maxBytes: 64 * 1024 } }
1960
+ },
1961
+ graceMs: 10000
1962
+ });
1963
+ } catch (e) {
1964
+ return { ok: false, code: "spawn-failed", message: "spawn 失败: " + String(e && e.message ? e.message : e), size: 0 };
1965
+ }
1966
+ let size = 0;
1967
+ let aborted = false;
1968
+ const onAbort = () => { aborted = true; try { handle.terminate(); } catch (e) {} };
1969
+ try {
1970
+ req.on("aborted", onAbort);
1971
+ req.on("close", () => { if (!req.complete) onAbort(); });
1972
+ for await (const chunk of req) {
1973
+ if (aborted) break;
1974
+ const b = Buffer.from(chunk);
1975
+ size += b.length;
1976
+ if (size > limit) { handle.terminate(); return { ok: false, code: "too-large", message: "upload exceeds the remote upload limit (" + limit + " bytes)", size: size }; }
1977
+ if (handle.stdin.destroyed) return { ok: false, code: "pipe-closed", message: "ssh stdin closed during upload", size: size };
1978
+ if (!handle.stdin.write(b)) await awaitOnce(handle.stdin, "drain");
1979
+ }
1980
+ if (aborted) return { ok: false, code: "aborted", message: "upload aborted", size: size };
1981
+ handle.stdin.end();
1982
+ const outcome = await handle.done;
1983
+ const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1984
+ const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1985
+ if (outcome.exitCode !== 0) {
1986
+ return { ok: false, code: "fs-error", message: String(se.text || "").trim() || ("ssh 退出码 " + outcome.exitCode), size: size };
1987
+ }
1988
+ return { ok: true, size: size, stdout: so.text, stderr: se.text };
1989
+ } catch (e) {
1990
+ try { handle.terminate(); } catch (e2) {}
1991
+ return { ok: false, code: "fs-error", message: String(e && e.message ? e.message : e), size: size };
1992
+ } finally {
1993
+ req.removeListener("aborted", onAbort);
1994
+ }
1995
+ }
1996
+
1695
1997
  async function handleRemoteUpload(req, res) {
1696
1998
  if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
1697
1999
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
@@ -1712,25 +2014,19 @@ function apply(ctx, config) {
1712
2014
  const remoteDir = localToRemote(dir, ws.mirrorPath, remoteInfo.remotePath);
1713
2015
  const target = remoteDir.replace(/\/+$/, "") + (rel ? "/" + rel : "");
1714
2016
  const parent = target.slice(0, Math.max(target.lastIndexOf("/"), 0));
1715
- // 读取原始字节流(File/Blob 直传,二进制安全)
1716
- const chunks = [];
1717
- let size = 0;
1718
- for await (const chunk of req) {
1719
- const b = Buffer.from(chunk);
1720
- size += b.length;
1721
- if (size > REMOTE_UPLOAD_LIMIT) { writeJson(res, 413, { ok: false, error: { code: "too-large", message: "upload exceeds the remote upload limit (" + REMOTE_UPLOAD_LIMIT + " bytes)" } }); return; }
1722
- chunks.push(b);
1723
- }
1724
- const payloadBase64 = Buffer.concat(chunks).toString("base64");
1725
2017
  // 远端建父目录
1726
2018
  if (parent && parent !== remoteDir.replace(/\/+$/, "")) {
1727
2019
  const mk = await runPooled(profile, "mkdir -p " + shellQuotePath(parent), undefined, 65536);
1728
2020
  if (!mk.ok) { writeJson(res, 500, { ok: false, error: { code: "fs-error", message: String(mk.error || mk.stdout || "mkdir failed").trim() } }); return; }
1729
2021
  }
1730
- // 二进制安全落地:base64 -d 还原后写入目标文件
1731
- const w = await runPooled(profile, "base64 -d > " + shellQuotePath(target), payloadBase64, REMOTE_UPLOAD_LIMIT);
1732
- if (!w.ok) { writeJson(res, 500, { ok: false, error: { code: "fs-error", message: String(w.error || w.stdout || "remote write failed").trim() } }); return; }
1733
- writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: size });
2022
+ // 流式上传(cat > target 直接吃 stdin,无 base64、无内存膨胀)
2023
+ const r = await streamRemoteUpload(profile, target, req, REMOTE_UPLOAD_LIMIT);
2024
+ if (!r.ok) {
2025
+ const status = r.code === "too-large" ? 413 : (r.code === "bad-request" ? 400 : 500);
2026
+ writeJson(res, status, { ok: false, error: { code: r.code, message: r.message } });
2027
+ return;
2028
+ }
2029
+ writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: r.size });
1734
2030
  } catch (e) {
1735
2031
  writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
1736
2032
  }
@@ -1742,6 +2038,105 @@ function apply(ctx, config) {
1742
2038
  handler: function (req, res) { return handleRemoteUpload(req, res); }
1743
2039
  }), "dsh-remote-ssh: /remote-ssh/upload route");
1744
2040
 
2041
+ // ---- P0: 远程文件下载 / 媒体预览(拦截 better-sidebar 的 /sidebar/file)----
2042
+ // better-sidebar 注册的是 prefix 路由,这里注册同路径 exact 路由优先匹配。
2043
+ const MEDIA_TYPES = {
2044
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
2045
+ ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon",
2046
+ ".avif": "image/avif", ".pdf": "application/pdf", ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8"
2047
+ };
2048
+ const REMOTE_DOWNLOAD_LIMIT = 64 * 1024 * 1024; // 远端内容走 base64 传输,设一个合理上限
2049
+
2050
+ function mediaTypeForPath(path) {
2051
+ const ext = String(path || "").toLowerCase();
2052
+ const dot = ext.lastIndexOf(".");
2053
+ const key = dot >= 0 ? ext.slice(dot) : "";
2054
+ return MEDIA_TYPES[key] || "application/octet-stream";
2055
+ }
2056
+
2057
+ /** 二进制安全地取回远端文件字节(base64 走 stdout,长度上限 maxBytes)。 */
2058
+ async function remoteFetchBytes(profile, remotePath, maxBytes) {
2059
+ // 先拿大小(stat),超限直接拒绝;base64 会膨胀 4/3,给 stdout 相应上限。
2060
+ const st = await runPooled(profile, "stat -c%s " + shellQuotePath(remotePath), undefined, 65536);
2061
+ if (!st.ok) return { ok: false, error: String(st.stdout || st.error || "stat failed").trim() };
2062
+ const size = parseInt(String(st.stdout).trim(), 10);
2063
+ if (isNaN(size)) return { ok: false, error: "cannot stat remote file size" };
2064
+ if (size > maxBytes) return { ok: false, error: "file too large (" + size + " bytes; limit " + maxBytes + ")" };
2065
+ const cap = Math.ceil(size * 1.5) + 4096;
2066
+ const r = await runPooled(profile, "base64 -w0 " + shellQuotePath(remotePath), undefined, cap);
2067
+ if (!r.ok) return { ok: false, error: String(r.stdout || r.error || "read failed").trim().slice(0, 500) };
2068
+ try {
2069
+ const buf = Buffer.from(String(r.stdout).replace(/\s+/g, ""), "base64");
2070
+ return { ok: true, buffer: buf };
2071
+ } catch (e) {
2072
+ return { ok: false, error: "decode failed: " + String(e) };
2073
+ }
2074
+ }
2075
+
2076
+ async function handleSidebarFile(req, res) {
2077
+ if (!isTrusted(req)) { res.writeHead(403); res.end("forbidden"); return; }
2078
+ if (req.method !== "GET") { res.writeHead(405); res.end(); return; }
2079
+ try {
2080
+ const url = new URL(req.url || "/", "http://dsh.internal");
2081
+ const sessionId = url.searchParams.get("sessionId");
2082
+ const rawPath = url.searchParams.get("path");
2083
+ const cwdParam = url.searchParams.get("cwd") || "";
2084
+ const download = url.searchParams.get("download") === "1";
2085
+ if (!sessionId || !rawPath) { res.writeHead(400); res.end("sessionId and path are required"); return; }
2086
+ const ws = matchRemoteWorkspace(rawPath, cwdParam);
2087
+ if (ws) {
2088
+ // ---- 远程工作区:SSH 拉取远程文件 ----
2089
+ const remoteInfo = readRemoteInfoSync(ws.mirrorPath);
2090
+ const profile = remoteInfo && getProfile(remoteInfo.profileId);
2091
+ if (!remoteInfo || !profile) {
2092
+ writeJson(res, 500, { ok: false, error: { code: "internal", message: "remote workspace profile not found" } });
2093
+ return;
2094
+ }
2095
+ const remotePath = localToRemote(rawPath, ws.mirrorPath, remoteInfo.remotePath);
2096
+ const r = await remoteFetchBytes(profile, remotePath, REMOTE_DOWNLOAD_LIMIT);
2097
+ if (!r.ok) {
2098
+ writeJson(res, 400, { ok: false, error: { code: "fs-error", message: r.error || "read failed" } });
2099
+ return;
2100
+ }
2101
+ const body = r.buffer;
2102
+ const headers = {
2103
+ "content-type": mediaTypeForPath(remotePath),
2104
+ "cache-control": "no-cache"
2105
+ };
2106
+ if (download) headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(String(remotePath).split("/").pop() || "download")}`;
2107
+ res.writeHead(200, headers);
2108
+ res.end(body);
2109
+ return;
2110
+ }
2111
+ // ---- 本地工作区:沿用 better-sidebar 原行为(读本地文件)----
2112
+ let cwd = cwdParam;
2113
+ if (!cwd && sessionId) {
2114
+ const sessions = ctx.get("sessions");
2115
+ const session = sessions ? sessions.get(sessionId) : null;
2116
+ cwd = session && session.header && session.header.cwd;
2117
+ }
2118
+ const info = await stat(rawPath).catch((e) => { throw new Error("cannot read: " + String(e && e.message ? e.message : e)); });
2119
+ if (!info.isFile() || info.size > 20 * 1024 * 1024) { res.writeHead(400); res.end("not a file or too large"); return; }
2120
+ const body = await readFile(rawPath);
2121
+ const headers = {
2122
+ "content-type": mediaTypeForPath(rawPath),
2123
+ "cache-control": "no-cache"
2124
+ };
2125
+ if (download) headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(rawPath.split(/[\\/]/).pop() || "download")}`;
2126
+ res.writeHead(200, headers);
2127
+ res.end(body);
2128
+ } catch (e) {
2129
+ res.writeHead(400);
2130
+ res.end(String(e && e.message ? e.message : e));
2131
+ }
2132
+ }
2133
+
2134
+ ctx.effect(() => ctx.webServer.register({
2135
+ kind: "exact",
2136
+ path: "/sidebar/file",
2137
+ handler: function (req, res) { return handleSidebarFile(req, res); }
2138
+ }), "dsh-remote-ssh: intercept /sidebar/file");
2139
+
1745
2140
  // ---- 清理 ----
1746
2141
  ctx.effect(() => () => {
1747
2142
  clearInterval(idleTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.1.5",
3
+ "version": "2.1.6",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",