@linxin666/dsh-ssh 0.1.14 → 0.1.15

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/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
- import { createServer } from "node:net";
3
2
  import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, join, relative, resolve } from "node:path";
5
3
  import { Client } from "ssh2";
6
4
  import { homedir, tmpdir } from "node:os";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { createServer } from "node:net";
7
7
  import { unlink } from "node:fs/promises";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { WebSocket, WebSocketServer } from "ws";
@@ -1312,13 +1312,12 @@ function expandHome(path) {
1312
1312
  return path;
1313
1313
  }
1314
1314
  //#endregion
1315
- //#region src/engine.ts
1315
+ //#region src/engine/connection-pool.ts
1316
1316
  /**
1317
- * The SSH engine: a per-alias persistent connection pool (ssh2) with
1318
- * multi-hop jump support, command execution, PTY shells, SFTP transfers,
1319
- * local port-forward tunnels and cluster execution — the DSH counterpart of
1320
- * ssh-skill's daemon + scripts, living entirely in the host process.
1317
+ * Connection pool: per-alias persistent ssh2 connections with multi-hop jump
1318
+ * support, the acquire / dispose / sweep lifecycle, and the pooled exec path.
1321
1319
  */
1320
+ /** Default engine knobs (applied when an option is omitted). */
1322
1321
  const DEFAULTS = {
1323
1322
  idleTimeoutMs: 30 * 6e4,
1324
1323
  connectTimeoutMs: 15e3,
@@ -1342,7 +1341,7 @@ function buildConnectConfig(entry, sock) {
1342
1341
  if (entry.auth.kind === "password") config.password = entry.auth.password;
1343
1342
  else {
1344
1343
  const keyPath = entry.auth.keyPath === void 0 ? void 0 : expandHome(entry.auth.keyPath);
1345
- if (keyPath === void 0 || !existsSync(keyPath)) throw new Error(`private key not found: '${entry.auth.keyPath ?? "(unset)"}'`);
1344
+ if (keyPath === void 0 || !existsSync(keyPath)) throw new Error("private key not found: " + (entry.auth.keyPath ?? "(unset)"));
1346
1345
  config.privateKey = readFileSync(keyPath, "utf8");
1347
1346
  if (entry.auth.passphrase !== void 0 && entry.auth.passphrase !== "") config.passphrase = entry.auth.passphrase;
1348
1347
  }
@@ -1385,6 +1384,283 @@ function appendOutput(target, chunk, maxBytes) {
1385
1384
  }
1386
1385
  target.text += chunk.toString("utf8");
1387
1386
  }
1387
+ /**
1388
+ * Build one full jump chain for an entry: hop clients connected through in
1389
+ * order, each forwarding a stream to the next destination, ending with the
1390
+ * target client. Shared by the pool and standalone shell sessions.
1391
+ */
1392
+ async function connectChain(engine, entry) {
1393
+ const hops = [];
1394
+ let sock;
1395
+ const chain = entry.proxyJump;
1396
+ for (let index = 0; index < chain.length; index += 1) {
1397
+ const hopAlias = chain[index];
1398
+ const hop = engine.store.find(hopAlias);
1399
+ if (hop === void 0) {
1400
+ for (const client of hops) client.end();
1401
+ throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1402
+ }
1403
+ const hopClient = await connectClient(buildConnectConfig(hop, sock));
1404
+ hops.push(hopClient);
1405
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1406
+ const nextHost = next !== void 0 ? next.host : entry.host;
1407
+ const nextPort = next !== void 0 ? next.port : entry.port;
1408
+ sock = await new Promise((resolve, reject) => {
1409
+ hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1410
+ if (error !== void 0) {
1411
+ for (const client of hops) client.end();
1412
+ reject(error);
1413
+ } else resolve(stream);
1414
+ });
1415
+ });
1416
+ }
1417
+ try {
1418
+ return {
1419
+ client: await connectClient(buildConnectConfig(entry, sock)),
1420
+ hops
1421
+ };
1422
+ } catch (error) {
1423
+ for (const client of hops) client.end();
1424
+ throw error;
1425
+ }
1426
+ }
1427
+ /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
1428
+ async function acquire(engine, alias) {
1429
+ const pending = engine.acquireQueue.get(alias);
1430
+ if (pending !== void 0) return pending;
1431
+ const task = doAcquire(engine, alias);
1432
+ engine.acquireQueue.set(alias, task);
1433
+ try {
1434
+ return await task;
1435
+ } finally {
1436
+ if (engine.acquireQueue.get(alias) === task) engine.acquireQueue.delete(alias);
1437
+ }
1438
+ }
1439
+ async function doAcquire(engine, alias) {
1440
+ const entry = engine.store.find(alias);
1441
+ if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
1442
+ const { client, hops } = await connectChain(engine, entry);
1443
+ const record = {
1444
+ client,
1445
+ hops,
1446
+ idleAt: Date.now(),
1447
+ pinned: false,
1448
+ broken: false,
1449
+ inFlight: 0
1450
+ };
1451
+ client.on("error", () => {
1452
+ record.broken = true;
1453
+ });
1454
+ client.on("close", () => {
1455
+ record.broken = true;
1456
+ });
1457
+ engine.pool.set(alias, record);
1458
+ return record;
1459
+ }
1460
+ /**
1461
+ * Tear down one alias's record. When `record` is given and no longer the
1462
+ * pooled record for the alias (a concurrent acquire replaced it), nothing
1463
+ * is torn down — the connection belongs to someone else now.
1464
+ */
1465
+ function disposeRecord(engine, alias, record) {
1466
+ const current = engine.pool.get(alias);
1467
+ if (record !== void 0 && current !== record) return;
1468
+ if (current === void 0) return;
1469
+ engine.pool.delete(alias);
1470
+ try {
1471
+ current.client.end();
1472
+ } catch {}
1473
+ for (const hop of current.hops) try {
1474
+ hop.end();
1475
+ } catch {}
1476
+ }
1477
+ /** Close connections idle beyond the threshold (skips pinned and in-flight). */
1478
+ function sweepPool(engine) {
1479
+ const cutoff = Date.now() - engine.opts.idleTimeoutMs;
1480
+ for (const [alias, record] of engine.pool) if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) disposeRecord(engine, alias, record);
1481
+ }
1482
+ /**
1483
+ * Run `fn` with a live client for `alias`, reconnecting (up to the
1484
+ * attempt budget) when the connection broke mid-flight.
1485
+ */
1486
+ async function withClient(engine, alias, fn, attempts = 3) {
1487
+ let lastError;
1488
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1489
+ let record = engine.pool.get(alias);
1490
+ if (record === void 0 || record.broken) {
1491
+ if (record !== void 0) disposeRecord(engine, alias, record);
1492
+ record = await acquire(engine, alias);
1493
+ }
1494
+ record.idleAt = Date.now();
1495
+ record.inFlight += 1;
1496
+ try {
1497
+ const result = await fn(record.client);
1498
+ record.idleAt = Date.now();
1499
+ return result;
1500
+ } finally {
1501
+ record.inFlight -= 1;
1502
+ }
1503
+ }
1504
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1505
+ }
1506
+ /** Run one command on `alias` (reusing the pooled connection). */
1507
+ async function execCommand(engine, alias, command, timeoutMs) {
1508
+ const started = Date.now();
1509
+ const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs;
1510
+ return withClient(engine, alias, async (client) => {
1511
+ return await new Promise((resolve, reject) => {
1512
+ client.exec(command, (error, stream) => {
1513
+ if (error !== void 0) {
1514
+ reject(error);
1515
+ return;
1516
+ }
1517
+ const stdout = {
1518
+ text: "",
1519
+ truncated: false
1520
+ };
1521
+ const stderr = {
1522
+ text: "",
1523
+ truncated: false
1524
+ };
1525
+ let timedOut = false;
1526
+ let settled = false;
1527
+ const finish = () => {
1528
+ if (settled) return;
1529
+ settled = true;
1530
+ clearTimeout(timer);
1531
+ resolve({
1532
+ success: false,
1533
+ exitCode: null,
1534
+ timedOut,
1535
+ stdout: stdout.text,
1536
+ stderr: stderr.text,
1537
+ durationMs: Date.now() - started,
1538
+ error: timedOut ? "command timed out after " + budget + " ms" : void 0
1539
+ });
1540
+ };
1541
+ const timer = setTimeout(() => {
1542
+ timedOut = true;
1543
+ try {
1544
+ stream.signal("KILL");
1545
+ } catch {}
1546
+ try {
1547
+ stream.close();
1548
+ } catch {}
1549
+ finish();
1550
+ }, budget);
1551
+ stream.on("data", (chunk) => appendOutput(stdout, chunk, engine.opts.maxOutputBytes));
1552
+ stream.stderr.on("data", (chunk) => appendOutput(stderr, chunk, engine.opts.maxOutputBytes));
1553
+ stream.on("close", (code) => {
1554
+ if (settled) return;
1555
+ settled = true;
1556
+ clearTimeout(timer);
1557
+ resolve({
1558
+ success: code === 0 && !timedOut,
1559
+ exitCode: code,
1560
+ timedOut,
1561
+ stdout: stdout.text,
1562
+ stderr: stderr.text,
1563
+ durationMs: Date.now() - started
1564
+ });
1565
+ });
1566
+ stream.on("error", (streamError) => {
1567
+ if (settled) return;
1568
+ settled = true;
1569
+ clearTimeout(timer);
1570
+ reject(streamError);
1571
+ });
1572
+ });
1573
+ });
1574
+ });
1575
+ }
1576
+ //#endregion
1577
+ //#region src/engine/pty.ts
1578
+ /**
1579
+ * Open a PTY shell session for the web terminal (standalone connection).
1580
+ * The shell is a long-lived exclusive stream: it uses its own connection so
1581
+ * closing it can never tear down a pooled exec/tunnel sharing the alias.
1582
+ */
1583
+ async function openShell(engine, alias, size) {
1584
+ const entry = engine.store.find(alias);
1585
+ if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
1586
+ const { client, hops } = await connectChain(engine, entry);
1587
+ return await new Promise((resolve, reject) => {
1588
+ client.shell({
1589
+ term: "xterm-256color",
1590
+ cols: size.cols,
1591
+ rows: size.rows
1592
+ }, (error, stream) => {
1593
+ if (error !== void 0) {
1594
+ try {
1595
+ client.end();
1596
+ } catch {}
1597
+ for (const hop of hops) try {
1598
+ hop.end();
1599
+ } catch {}
1600
+ reject(error);
1601
+ return;
1602
+ }
1603
+ let tornDown = false;
1604
+ const teardown = () => {
1605
+ if (tornDown) return;
1606
+ tornDown = true;
1607
+ try {
1608
+ client.end();
1609
+ } catch {}
1610
+ for (const hop of hops) try {
1611
+ hop.end();
1612
+ } catch {}
1613
+ };
1614
+ const session = {
1615
+ send: (data) => {
1616
+ try {
1617
+ stream.write(data);
1618
+ } catch {}
1619
+ },
1620
+ resize: (cols, rows) => {
1621
+ try {
1622
+ stream.setWindow(rows, cols, rows, cols);
1623
+ } catch {}
1624
+ },
1625
+ close: () => {
1626
+ try {
1627
+ stream.close();
1628
+ } catch {}
1629
+ teardown();
1630
+ },
1631
+ pause: () => {
1632
+ try {
1633
+ stream.pause();
1634
+ } catch {}
1635
+ },
1636
+ resume: () => {
1637
+ try {
1638
+ stream.resume();
1639
+ } catch {}
1640
+ }
1641
+ };
1642
+ stream.on("data", (chunk) => {
1643
+ session.onData?.(chunk);
1644
+ });
1645
+ stream.on("close", (code) => {
1646
+ teardown();
1647
+ session.onExit?.(code);
1648
+ });
1649
+ stream.on("error", (streamError) => {
1650
+ teardown();
1651
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError));
1652
+ });
1653
+ resolve(session);
1654
+ });
1655
+ });
1656
+ }
1657
+ //#endregion
1658
+ //#region src/engine/sftp.ts
1659
+ /**
1660
+ * SFTP transfers: upload (file or recursive tree), single-file download, and
1661
+ * remote directory listing. Every channel is opened once per operation and
1662
+ * released exactly once so sshd's MaxSessions cap is never exhausted.
1663
+ */
1388
1664
  /** Walk a local directory, collecting relative paths of every file. */
1389
1665
  function walkLocalDir(root) {
1390
1666
  const files = [];
@@ -1399,6 +1675,366 @@ function walkLocalDir(root) {
1399
1675
  visit(root);
1400
1676
  return files;
1401
1677
  }
1678
+ /** Upload one local file (or directory tree) to a remote path. */
1679
+ async function upload(engine, alias, localPath, remotePath, recursive, onProgress) {
1680
+ if (!remotePath.startsWith("/")) throw new Error("remotePath must be an absolute path (got '" + remotePath + "')");
1681
+ const local = resolve(localPath);
1682
+ if (!existsSync(local)) throw new Error("local path not found: '" + localPath + "'");
1683
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
1684
+ const stat = statSync(local);
1685
+ let files;
1686
+ if (stat.isDirectory()) {
1687
+ if (!recursive) throw new Error("'" + localPath + "' is a directory — enable recursive upload");
1688
+ files = walkLocalDir(local);
1689
+ await ensureRemoteDir(sftp, remotePath);
1690
+ } else {
1691
+ files = [""];
1692
+ await ensureRemoteDir(sftp, dirname(remotePath));
1693
+ }
1694
+ let bytes = 0;
1695
+ for (const rel of files) {
1696
+ const src = rel === "" ? local : join(local, rel);
1697
+ const remoteRel = rel.split(/[\\/]/).join("/");
1698
+ await fastPut(sftp, src, rel === "" ? remotePath : remotePath.replace(/\/$/, "") + "/" + remoteRel, engine.opts.sftpConcurrency, onProgress);
1699
+ bytes += statSync(src).size;
1700
+ }
1701
+ return {
1702
+ bytes,
1703
+ files: files.length
1704
+ };
1705
+ }));
1706
+ }
1707
+ /** Download one remote file to a local path. */
1708
+ async function download(engine, alias, remotePath, localPath, onProgress) {
1709
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
1710
+ if ((await new Promise((resolve, reject) => {
1711
+ sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
1712
+ })).isDirectory()) throw new Error("'" + remotePath + "' is a directory — directory download is not supported yet (download individual files)");
1713
+ const local = resolve(localPath);
1714
+ if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
1715
+ await fastGet(sftp, remotePath, local, engine.opts.sftpConcurrency, onProgress);
1716
+ return { bytes: statSync(local).size };
1717
+ }));
1718
+ }
1719
+ /** List a remote directory (file browser). */
1720
+ async function ls(engine, alias, path) {
1721
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
1722
+ return await new Promise((resolve, reject) => {
1723
+ sftp.readdir(path, (error, list) => {
1724
+ if (error !== void 0) {
1725
+ reject(error);
1726
+ return;
1727
+ }
1728
+ resolve(list.map((item) => ({
1729
+ name: item.filename,
1730
+ type: item.attrs.isDirectory() ? "dir" : item.attrs.isFile() ? "file" : "other",
1731
+ size: item.attrs.size,
1732
+ mtimeMs: item.attrs.mtime * 1e3,
1733
+ mode: item.attrs.mode
1734
+ })));
1735
+ });
1736
+ });
1737
+ }));
1738
+ }
1739
+ /**
1740
+ * Open one SFTP channel, run the operation, and release the channel exactly
1741
+ * once when the operation settles (success or error). ssh2 keeps each
1742
+ * subsystem channel open until end(); without this, every transfer leaks a
1743
+ * channel until sshd's MaxSessions cap makes all later opens fail.
1744
+ */
1745
+ async function withSftp(client, run) {
1746
+ const sftp = await sftpChannel(client);
1747
+ let ended = false;
1748
+ const endOnce = () => {
1749
+ if (ended) return;
1750
+ ended = true;
1751
+ try {
1752
+ sftp.end();
1753
+ } catch {}
1754
+ };
1755
+ sftp.once("close", endOnce);
1756
+ try {
1757
+ return await run(sftp);
1758
+ } finally {
1759
+ endOnce();
1760
+ }
1761
+ }
1762
+ function sftpChannel(client) {
1763
+ return new Promise((resolve, reject) => {
1764
+ client.sftp((error, sftp) => error !== void 0 ? reject(error) : resolve(sftp));
1765
+ });
1766
+ }
1767
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
1768
+ function ensureRemoteDir(sftp, remote) {
1769
+ return new Promise((resolve, reject) => {
1770
+ const segments = remote.replace(/^\/+/, "").split("/").filter((segment) => segment !== "");
1771
+ const walk = (index) => {
1772
+ if (index >= segments.length) {
1773
+ resolve();
1774
+ return;
1775
+ }
1776
+ const current = "/" + segments.slice(0, index + 1).join("/");
1777
+ sftp.stat(current, (statError) => {
1778
+ if (statError === void 0) {
1779
+ walk(index + 1);
1780
+ return;
1781
+ }
1782
+ sftp.mkdir(current, (mkdirError) => {
1783
+ if (mkdirError !== void 0) {
1784
+ reject(mkdirError);
1785
+ return;
1786
+ }
1787
+ walk(index + 1);
1788
+ });
1789
+ });
1790
+ };
1791
+ walk(0);
1792
+ });
1793
+ }
1794
+ function fastPut(sftp, src, dst, concurrency, onProgress) {
1795
+ return new Promise((resolve, reject) => {
1796
+ let last = 0;
1797
+ let lastEmit = 0;
1798
+ const started = Date.now();
1799
+ onProgress?.({
1800
+ phase: "transferring",
1801
+ file: dst,
1802
+ transferred: 0,
1803
+ total: statSync(src).size,
1804
+ percent: 0
1805
+ });
1806
+ sftp.fastPut(src, dst, {
1807
+ concurrency,
1808
+ step: (transferred, _chunk, total) => {
1809
+ const now = Date.now();
1810
+ if (now - lastEmit < 100 && transferred < total) return;
1811
+ lastEmit = now;
1812
+ const elapsed = (now - started) / 1e3;
1813
+ onProgress?.({
1814
+ phase: "transferring",
1815
+ file: dst,
1816
+ transferred,
1817
+ total,
1818
+ percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
1819
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
1820
+ });
1821
+ last = transferred;
1822
+ }
1823
+ }, (error) => {
1824
+ if (error !== void 0) {
1825
+ onProgress?.({
1826
+ phase: "error",
1827
+ file: dst,
1828
+ transferred: 0,
1829
+ total: 0,
1830
+ percent: 0,
1831
+ error: String(error)
1832
+ });
1833
+ reject(error);
1834
+ } else {
1835
+ onProgress?.({
1836
+ phase: "done",
1837
+ file: dst,
1838
+ transferred: statSync(src).size,
1839
+ total: statSync(src).size,
1840
+ percent: 100
1841
+ });
1842
+ resolve();
1843
+ }
1844
+ });
1845
+ });
1846
+ }
1847
+ function fastGet(sftp, src, dst, concurrency, onProgress) {
1848
+ return new Promise((resolve, reject) => {
1849
+ let last = 0;
1850
+ let lastEmit = 0;
1851
+ const started = Date.now();
1852
+ sftp.fastGet(src, dst, {
1853
+ concurrency,
1854
+ step: (transferred, _chunk, total) => {
1855
+ const now = Date.now();
1856
+ if (now - lastEmit < 100 && transferred < total) return;
1857
+ lastEmit = now;
1858
+ const elapsed = (now - started) / 1e3;
1859
+ onProgress?.({
1860
+ phase: "transferring",
1861
+ file: src,
1862
+ transferred,
1863
+ total,
1864
+ percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
1865
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
1866
+ });
1867
+ last = transferred;
1868
+ }
1869
+ }, (error) => {
1870
+ if (error !== void 0) {
1871
+ onProgress?.({
1872
+ phase: "error",
1873
+ file: src,
1874
+ transferred: 0,
1875
+ total: 0,
1876
+ percent: 0,
1877
+ error: String(error)
1878
+ });
1879
+ reject(error);
1880
+ } else {
1881
+ onProgress?.({
1882
+ phase: "done",
1883
+ file: src,
1884
+ transferred: statSync(dst).size,
1885
+ total: statSync(dst).size,
1886
+ percent: 100
1887
+ });
1888
+ resolve();
1889
+ }
1890
+ });
1891
+ });
1892
+ }
1893
+ //#endregion
1894
+ //#region src/engine/tunnel.ts
1895
+ /**
1896
+ * Local port-forward tunnels: one loopback-only listener per tunnel, pinned
1897
+ * to a pooled connection so an idle sweep never closes it, with per-tunnel
1898
+ * socket tracking and stop (single / alias-scoped / all).
1899
+ */
1900
+ /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
1901
+ async function startTunnel(engine, alias, options) {
1902
+ if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
1903
+ if (options.localPort !== void 0 && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) throw new Error("localPort must be an integer in 1..65535");
1904
+ if (engine.store.find(alias) === void 0) throw new Error("alias '" + alias + "' not found — add it first");
1905
+ const remoteHost = options.remoteHost ?? "127.0.0.1";
1906
+ const id = "tun-" + engine.nextTunnelId;
1907
+ engine.nextTunnelId += 1;
1908
+ const info = {
1909
+ id,
1910
+ alias,
1911
+ localPort: 0,
1912
+ remoteHost,
1913
+ remotePort: options.remotePort,
1914
+ state: "connecting",
1915
+ startedAt: Date.now()
1916
+ };
1917
+ const record = await acquire(engine, alias);
1918
+ const client = record.client;
1919
+ const sockets = /* @__PURE__ */ new Set();
1920
+ const server = createServer((socket) => {
1921
+ sockets.add(socket);
1922
+ socket.on("close", () => {
1923
+ sockets.delete(socket);
1924
+ });
1925
+ client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1926
+ if (error !== void 0) {
1927
+ socket.destroy();
1928
+ return;
1929
+ }
1930
+ const destroy = () => {
1931
+ try {
1932
+ socket.destroy();
1933
+ } catch {}
1934
+ try {
1935
+ stream.close();
1936
+ } catch {}
1937
+ };
1938
+ stream.on("error", destroy);
1939
+ socket.on("error", destroy);
1940
+ stream.on("close", destroy);
1941
+ socket.on("close", destroy);
1942
+ stream.pipe(socket).pipe(stream);
1943
+ });
1944
+ });
1945
+ try {
1946
+ await new Promise((resolve, reject) => {
1947
+ server.once("error", reject);
1948
+ server.listen(options.localPort ?? 0, "127.0.0.1", () => {
1949
+ server.removeListener("error", reject);
1950
+ resolve();
1951
+ });
1952
+ });
1953
+ } catch (error) {
1954
+ if (!record.pinned && record.inFlight === 0) disposeRecord(engine, alias, record);
1955
+ throw error;
1956
+ }
1957
+ record.pinned = true;
1958
+ const address = server.address();
1959
+ info.localPort = typeof address === "object" && address !== null ? address.port : 0;
1960
+ info.state = "forwarding";
1961
+ engine.tunnels.set(id, {
1962
+ info,
1963
+ server,
1964
+ alias,
1965
+ sockets
1966
+ });
1967
+ return info;
1968
+ }
1969
+ /** All active tunnels. */
1970
+ function listTunnels(engine) {
1971
+ return [...engine.tunnels.values()].map((tunnel) => ({ ...tunnel.info }));
1972
+ }
1973
+ /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
1974
+ function stopTunnel(engine, id) {
1975
+ const tunnel = engine.tunnels.get(id);
1976
+ if (tunnel === void 0) return false;
1977
+ engine.tunnels.delete(id);
1978
+ try {
1979
+ tunnel.server.close();
1980
+ } catch {}
1981
+ for (const socket of tunnel.sockets) try {
1982
+ socket.destroy();
1983
+ } catch {}
1984
+ tunnel.sockets.clear();
1985
+ disposeRecord(engine, tunnel.alias);
1986
+ return true;
1987
+ }
1988
+ /** Stop all tunnels (optionally for one alias). */
1989
+ function stopAllTunnels(engine, alias) {
1990
+ let count = 0;
1991
+ for (const [id, tunnel] of [...engine.tunnels]) if (alias === void 0 || tunnel.alias === alias) {
1992
+ stopTunnel(engine, id);
1993
+ count += 1;
1994
+ }
1995
+ return count;
1996
+ }
1997
+ //#endregion
1998
+ //#region src/engine/cluster.ts
1999
+ /** Run one command against many hosts concurrently. */
2000
+ async function cluster(engine, options) {
2001
+ let targets = engine.store.list();
2002
+ if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
2003
+ if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
2004
+ if (options.tags !== void 0 && options.tags.length > 0) targets = targets.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
2005
+ if (targets.length === 0) return [];
2006
+ if (options.maxWorkers !== void 0 && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) throw new Error("maxWorkers must be a positive integer");
2007
+ const workers = Math.min(engine.opts.defaultMaxWorkers, options.maxWorkers ?? engine.opts.defaultMaxWorkers, targets.length);
2008
+ const results = [];
2009
+ const queue = [...targets];
2010
+ const run = async () => {
2011
+ while (queue.length > 0) {
2012
+ const entry = queue.shift();
2013
+ try {
2014
+ const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs);
2015
+ results.push({
2016
+ alias: entry.alias,
2017
+ ok: result.success,
2018
+ exitCode: result.exitCode,
2019
+ timedOut: result.timedOut,
2020
+ stdout: result.stdout,
2021
+ stderr: result.stderr,
2022
+ durationMs: result.durationMs
2023
+ });
2024
+ } catch (error) {
2025
+ results.push({
2026
+ alias: entry.alias,
2027
+ ok: false,
2028
+ error: error instanceof Error ? error.message : String(error)
2029
+ });
2030
+ }
2031
+ }
2032
+ };
2033
+ await Promise.all(Array.from({ length: workers }, () => run()));
2034
+ return results;
2035
+ }
2036
+ //#endregion
2037
+ //#region src/engine.ts
1402
2038
  /**
1403
2039
  * The engine. Owns the pool, tunnels, and all operations. One instance per
1404
2040
  * plugin apply; dispose() closes every connection.
@@ -1407,9 +2043,10 @@ var SshEngine = class {
1407
2043
  store;
1408
2044
  opts;
1409
2045
  pool = /* @__PURE__ */ new Map();
2046
+ acquireQueue = /* @__PURE__ */ new Map();
1410
2047
  tunnels = /* @__PURE__ */ new Map();
1411
- sweepTimer;
1412
2048
  nextTunnelId = 1;
2049
+ sweepTimer;
1413
2050
  /**
1414
2051
  * @param store - the host config store.
1415
2052
  * @param options - engine knobs (defaults applied).
@@ -1420,7 +2057,7 @@ var SshEngine = class {
1420
2057
  ...DEFAULTS,
1421
2058
  ...options
1422
2059
  };
1423
- this.sweepTimer = setInterval(() => this.sweep(), Math.max(1e4, this.opts.idleTimeoutMs / 4));
2060
+ this.sweepTimer = setInterval(() => sweepPool(this), Math.max(1e4, this.opts.idleTimeoutMs / 4));
1424
2061
  this.sweepTimer.unref?.();
1425
2062
  }
1426
2063
  /** Secret-free host list (filtered by the optional query). */
@@ -1433,620 +2070,45 @@ var SshEngine = class {
1433
2070
  const entry = this.store.find(alias);
1434
2071
  return entry === void 0 ? void 0 : this.store.summarize(entry);
1435
2072
  }
1436
- /**
1437
- * Run `fn` with a live client for `alias`, reconnecting (up to the
1438
- * attempt budget) when the connection broke mid-flight.
1439
- */
1440
- async withClient(alias, fn, attempts = 3) {
1441
- let lastError;
1442
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
1443
- let record = this.pool.get(alias);
1444
- if (record === void 0 || record.broken) {
1445
- if (record !== void 0) this.disposeRecord(alias, record);
1446
- record = await this.acquire(alias);
1447
- }
1448
- record.idleAt = Date.now();
1449
- record.inFlight += 1;
1450
- try {
1451
- const result = await fn(record.client);
1452
- record.idleAt = Date.now();
1453
- return result;
1454
- } finally {
1455
- record.inFlight -= 1;
1456
- }
1457
- }
1458
- throw lastError instanceof Error ? lastError : new Error(String(lastError));
1459
- }
1460
- /**
1461
- * Build one full jump chain for an entry: hop clients connected through in
1462
- * order, each forwarding a stream to the next destination, ending with the
1463
- * target client. Shared by the pool and standalone shell sessions.
1464
- */
1465
- async connectChain(entry) {
1466
- const hops = [];
1467
- let sock;
1468
- const chain = entry.proxyJump;
1469
- for (let index = 0; index < chain.length; index += 1) {
1470
- const hopAlias = chain[index];
1471
- const hop = this.store.find(hopAlias);
1472
- if (hop === void 0) {
1473
- for (const client of hops) client.end();
1474
- throw new Error(`proxyJump alias '${hopAlias}' not found — create it first`);
1475
- }
1476
- const hopClient = await connectClient(buildConnectConfig(hop, sock));
1477
- hops.push(hopClient);
1478
- const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : void 0;
1479
- const nextHost = next !== void 0 ? next.host : entry.host;
1480
- const nextPort = next !== void 0 ? next.port : entry.port;
1481
- sock = await new Promise((resolve, reject) => {
1482
- hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1483
- if (error !== void 0) {
1484
- for (const client of hops) client.end();
1485
- reject(error);
1486
- } else resolve(stream);
1487
- });
1488
- });
1489
- }
1490
- try {
1491
- return {
1492
- client: await connectClient(buildConnectConfig(entry, sock)),
1493
- hops
1494
- };
1495
- } catch (error) {
1496
- for (const client of hops) client.end();
1497
- throw error;
1498
- }
1499
- }
1500
- /** In-flight acquire promises, deduped per alias (concurrent first use). */
1501
- acquireQueue = /* @__PURE__ */ new Map();
1502
- /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
1503
- async acquire(alias) {
1504
- const pending = this.acquireQueue.get(alias);
1505
- if (pending !== void 0) return pending;
1506
- const task = this.doAcquire(alias);
1507
- this.acquireQueue.set(alias, task);
1508
- try {
1509
- return await task;
1510
- } finally {
1511
- if (this.acquireQueue.get(alias) === task) this.acquireQueue.delete(alias);
1512
- }
1513
- }
1514
- async doAcquire(alias) {
1515
- const entry = this.store.find(alias);
1516
- if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
1517
- const { client, hops } = await this.connectChain(entry);
1518
- const record = {
1519
- client,
1520
- hops,
1521
- idleAt: Date.now(),
1522
- pinned: false,
1523
- broken: false,
1524
- inFlight: 0
1525
- };
1526
- client.on("error", () => {
1527
- record.broken = true;
1528
- });
1529
- client.on("close", () => {
1530
- record.broken = true;
1531
- });
1532
- this.pool.set(alias, record);
1533
- return record;
1534
- }
1535
- /**
1536
- * Tear down one alias's record. When `record` is given and no longer the
1537
- * pooled record for the alias (a concurrent acquire replaced it), nothing
1538
- * is torn down — the connection belongs to someone else now.
1539
- */
1540
- disposeRecord(alias, record) {
1541
- const current = this.pool.get(alias);
1542
- if (record !== void 0 && current !== record) return;
1543
- if (current === void 0) return;
1544
- this.pool.delete(alias);
1545
- try {
1546
- current.client.end();
1547
- } catch {}
1548
- for (const hop of current.hops) try {
1549
- hop.end();
1550
- } catch {}
1551
- }
1552
- /** Close connections idle beyond the threshold (skips pinned and in-flight). */
1553
- sweep() {
1554
- const cutoff = Date.now() - this.opts.idleTimeoutMs;
1555
- for (const [alias, record] of this.pool) if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) this.disposeRecord(alias, record);
1556
- }
1557
2073
  /** Run one command on `alias` (reusing the pooled connection). */
1558
2074
  async exec(alias, command, timeoutMs) {
1559
- const started = Date.now();
1560
- const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : this.opts.defaultExecTimeoutMs;
1561
- return this.withClient(alias, async (client) => {
1562
- return await new Promise((resolve, reject) => {
1563
- client.exec(command, (error, stream) => {
1564
- if (error !== void 0) {
1565
- reject(error);
1566
- return;
1567
- }
1568
- const stdout = {
1569
- text: "",
1570
- truncated: false
1571
- };
1572
- const stderr = {
1573
- text: "",
1574
- truncated: false
1575
- };
1576
- let timedOut = false;
1577
- let settled = false;
1578
- const finish = () => {
1579
- if (settled) return;
1580
- settled = true;
1581
- clearTimeout(timer);
1582
- resolve({
1583
- success: false,
1584
- exitCode: null,
1585
- timedOut,
1586
- stdout: stdout.text,
1587
- stderr: stderr.text,
1588
- durationMs: Date.now() - started,
1589
- error: timedOut ? `command timed out after ${budget} ms` : void 0
1590
- });
1591
- };
1592
- const timer = setTimeout(() => {
1593
- timedOut = true;
1594
- try {
1595
- stream.signal("KILL");
1596
- } catch {}
1597
- try {
1598
- stream.close();
1599
- } catch {}
1600
- finish();
1601
- }, budget);
1602
- stream.on("data", (chunk) => appendOutput(stdout, chunk, this.opts.maxOutputBytes));
1603
- stream.stderr.on("data", (chunk) => appendOutput(stderr, chunk, this.opts.maxOutputBytes));
1604
- stream.on("close", (code) => {
1605
- if (settled) return;
1606
- settled = true;
1607
- clearTimeout(timer);
1608
- resolve({
1609
- success: code === 0 && !timedOut,
1610
- exitCode: code,
1611
- timedOut,
1612
- stdout: stdout.text,
1613
- stderr: stderr.text,
1614
- durationMs: Date.now() - started
1615
- });
1616
- });
1617
- stream.on("error", (streamError) => {
1618
- if (settled) return;
1619
- settled = true;
1620
- clearTimeout(timer);
1621
- reject(streamError);
1622
- });
1623
- });
1624
- });
1625
- });
2075
+ return execCommand(this, alias, command, timeoutMs);
1626
2076
  }
1627
2077
  /** Run one command against many hosts concurrently. */
1628
2078
  async cluster(options) {
1629
- let targets = this.store.list();
1630
- if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
1631
- if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
1632
- if (options.tags !== void 0 && options.tags.length > 0) targets = targets.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
1633
- if (targets.length === 0) return [];
1634
- if (options.maxWorkers !== void 0 && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) throw new Error("maxWorkers must be a positive integer");
1635
- const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length);
1636
- const results = [];
1637
- const queue = [...targets];
1638
- const run = async () => {
1639
- while (queue.length > 0) {
1640
- const entry = queue.shift();
1641
- try {
1642
- const result = await this.exec(entry.alias, options.command, options.timeoutMs);
1643
- results.push({
1644
- alias: entry.alias,
1645
- ok: result.success,
1646
- exitCode: result.exitCode,
1647
- timedOut: result.timedOut,
1648
- stdout: result.stdout,
1649
- stderr: result.stderr,
1650
- durationMs: result.durationMs
1651
- });
1652
- } catch (error) {
1653
- results.push({
1654
- alias: entry.alias,
1655
- ok: false,
1656
- error: error instanceof Error ? error.message : String(error)
1657
- });
1658
- }
1659
- }
1660
- };
1661
- await Promise.all(Array.from({ length: workers }, () => run()));
1662
- return results;
2079
+ return cluster(this, options);
1663
2080
  }
1664
2081
  /** Open a PTY shell session for the web terminal (standalone connection). */
1665
2082
  async openShell(alias, size) {
1666
- const entry = this.store.find(alias);
1667
- if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
1668
- const { client, hops } = await this.connectChain(entry);
1669
- return await new Promise((resolve, reject) => {
1670
- client.shell({
1671
- term: "xterm-256color",
1672
- cols: size.cols,
1673
- rows: size.rows
1674
- }, (error, stream) => {
1675
- if (error !== void 0) {
1676
- try {
1677
- client.end();
1678
- } catch {}
1679
- for (const hop of hops) try {
1680
- hop.end();
1681
- } catch {}
1682
- reject(error);
1683
- return;
1684
- }
1685
- let tornDown = false;
1686
- const teardown = () => {
1687
- if (tornDown) return;
1688
- tornDown = true;
1689
- try {
1690
- client.end();
1691
- } catch {}
1692
- for (const hop of hops) try {
1693
- hop.end();
1694
- } catch {}
1695
- };
1696
- const session = {
1697
- send: (data) => {
1698
- try {
1699
- stream.write(data);
1700
- } catch {}
1701
- },
1702
- resize: (cols, rows) => {
1703
- try {
1704
- stream.setWindow(rows, cols, rows, cols);
1705
- } catch {}
1706
- },
1707
- close: () => {
1708
- try {
1709
- stream.close();
1710
- } catch {}
1711
- teardown();
1712
- },
1713
- pause: () => {
1714
- try {
1715
- stream.pause();
1716
- } catch {}
1717
- },
1718
- resume: () => {
1719
- try {
1720
- stream.resume();
1721
- } catch {}
1722
- }
1723
- };
1724
- stream.on("data", (chunk) => {
1725
- session.onData?.(chunk);
1726
- });
1727
- stream.on("close", (code) => {
1728
- teardown();
1729
- session.onExit?.(code);
1730
- });
1731
- stream.on("error", (streamError) => {
1732
- teardown();
1733
- session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError));
1734
- });
1735
- resolve(session);
1736
- });
1737
- });
2083
+ return openShell(this, alias, size);
1738
2084
  }
1739
2085
  /** Upload one local file (or directory tree) to a remote path. */
1740
2086
  async upload(alias, localPath, remotePath, recursive, onProgress) {
1741
- if (!remotePath.startsWith("/")) throw new Error(`remotePath must be an absolute path (got '${remotePath}')`);
1742
- const local = resolve(localPath);
1743
- if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`);
1744
- return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1745
- const stat = statSync(local);
1746
- let files;
1747
- if (stat.isDirectory()) {
1748
- if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`);
1749
- files = walkLocalDir(local);
1750
- await this.ensureRemoteDir(sftp, remotePath);
1751
- } else {
1752
- files = [""];
1753
- await this.ensureRemoteDir(sftp, dirname(remotePath));
1754
- }
1755
- let bytes = 0;
1756
- for (const rel of files) {
1757
- const src = rel === "" ? local : join(local, rel);
1758
- const remoteRel = rel.split(/[\\/]/).join("/");
1759
- const dst = rel === "" ? remotePath : remotePath.replace(/\/$/, "") + "/" + remoteRel;
1760
- await this.fastPut(sftp, src, dst, onProgress);
1761
- bytes += statSync(src).size;
1762
- }
1763
- return {
1764
- bytes,
1765
- files: files.length
1766
- };
1767
- }));
2087
+ return upload(this, alias, localPath, remotePath, recursive, onProgress);
1768
2088
  }
1769
2089
  /** Download one remote file to a local path. */
1770
2090
  async download(alias, remotePath, localPath, onProgress) {
1771
- return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1772
- if ((await new Promise((resolve, reject) => {
1773
- sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
1774
- })).isDirectory()) throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`);
1775
- const local = resolve(localPath);
1776
- if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
1777
- await this.fastGet(sftp, remotePath, local, onProgress);
1778
- return { bytes: statSync(local).size };
1779
- }));
2091
+ return download(this, alias, remotePath, localPath, onProgress);
1780
2092
  }
1781
2093
  /** List a remote directory (file browser). */
1782
2094
  async ls(alias, path) {
1783
- return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
1784
- return await new Promise((resolve, reject) => {
1785
- sftp.readdir(path, (error, list) => {
1786
- if (error !== void 0) {
1787
- reject(error);
1788
- return;
1789
- }
1790
- resolve(list.map((item) => ({
1791
- name: item.filename,
1792
- type: item.attrs.isDirectory() ? "dir" : item.attrs.isFile() ? "file" : "other",
1793
- size: item.attrs.size,
1794
- mtimeMs: item.attrs.mtime * 1e3,
1795
- mode: item.attrs.mode
1796
- })));
1797
- });
1798
- });
1799
- }));
1800
- }
1801
- /**
1802
- * Open one SFTP channel, run the operation, and release the channel exactly
1803
- * once when the operation settles (success or error). ssh2 keeps each
1804
- * subsystem channel open until end(); without this, every transfer leaks a
1805
- * channel until sshd's MaxSessions cap makes all later opens fail.
1806
- */
1807
- async withSftp(client, run) {
1808
- const sftp = await this.sftp(client);
1809
- let ended = false;
1810
- const endOnce = () => {
1811
- if (ended) return;
1812
- ended = true;
1813
- try {
1814
- sftp.end();
1815
- } catch {}
1816
- };
1817
- sftp.once("close", endOnce);
1818
- try {
1819
- return await run(sftp);
1820
- } finally {
1821
- endOnce();
1822
- }
1823
- }
1824
- sftp(client) {
1825
- return new Promise((resolve, reject) => {
1826
- client.sftp((error, sftp) => error !== void 0 ? reject(error) : resolve(sftp));
1827
- });
1828
- }
1829
- /** Create a remote directory chain (stat-then-mkdir per segment). */
1830
- ensureRemoteDir(sftp, remote) {
1831
- return new Promise((resolve, reject) => {
1832
- const segments = remote.replace(/^\/+/, "").split("/").filter((segment) => segment !== "");
1833
- const walk = (index) => {
1834
- if (index >= segments.length) {
1835
- resolve();
1836
- return;
1837
- }
1838
- const current = "/" + segments.slice(0, index + 1).join("/");
1839
- sftp.stat(current, (statError) => {
1840
- if (statError === void 0) {
1841
- walk(index + 1);
1842
- return;
1843
- }
1844
- sftp.mkdir(current, (mkdirError) => {
1845
- if (mkdirError !== void 0) {
1846
- reject(mkdirError);
1847
- return;
1848
- }
1849
- walk(index + 1);
1850
- });
1851
- });
1852
- };
1853
- walk(0);
1854
- });
1855
- }
1856
- fastPut(sftp, src, dst, onProgress) {
1857
- return new Promise((resolve, reject) => {
1858
- let last = 0;
1859
- let lastEmit = 0;
1860
- const started = Date.now();
1861
- onProgress?.({
1862
- phase: "transferring",
1863
- file: dst,
1864
- transferred: 0,
1865
- total: statSync(src).size,
1866
- percent: 0
1867
- });
1868
- sftp.fastPut(src, dst, {
1869
- concurrency: this.opts.sftpConcurrency,
1870
- step: (transferred, _chunk, total) => {
1871
- const now = Date.now();
1872
- if (now - lastEmit < 100 && transferred < total) return;
1873
- lastEmit = now;
1874
- const elapsed = (now - started) / 1e3;
1875
- onProgress?.({
1876
- phase: "transferring",
1877
- file: dst,
1878
- transferred,
1879
- total,
1880
- percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
1881
- speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
1882
- });
1883
- last = transferred;
1884
- }
1885
- }, (error) => {
1886
- if (error !== void 0) {
1887
- onProgress?.({
1888
- phase: "error",
1889
- file: dst,
1890
- transferred: 0,
1891
- total: 0,
1892
- percent: 0,
1893
- error: String(error)
1894
- });
1895
- reject(error);
1896
- } else {
1897
- onProgress?.({
1898
- phase: "done",
1899
- file: dst,
1900
- transferred: statSync(src).size,
1901
- total: statSync(src).size,
1902
- percent: 100
1903
- });
1904
- resolve();
1905
- }
1906
- });
1907
- });
1908
- }
1909
- fastGet(sftp, src, dst, onProgress) {
1910
- return new Promise((resolve, reject) => {
1911
- let last = 0;
1912
- let lastEmit = 0;
1913
- const started = Date.now();
1914
- sftp.fastGet(src, dst, {
1915
- concurrency: this.opts.sftpConcurrency,
1916
- step: (transferred, _chunk, total) => {
1917
- const now = Date.now();
1918
- if (now - lastEmit < 100 && transferred < total) return;
1919
- lastEmit = now;
1920
- const elapsed = (now - started) / 1e3;
1921
- onProgress?.({
1922
- phase: "transferring",
1923
- file: src,
1924
- transferred,
1925
- total,
1926
- percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
1927
- speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
1928
- });
1929
- last = transferred;
1930
- }
1931
- }, (error) => {
1932
- if (error !== void 0) {
1933
- onProgress?.({
1934
- phase: "error",
1935
- file: src,
1936
- transferred: 0,
1937
- total: 0,
1938
- percent: 0,
1939
- error: String(error)
1940
- });
1941
- reject(error);
1942
- } else {
1943
- onProgress?.({
1944
- phase: "done",
1945
- file: src,
1946
- transferred: statSync(dst).size,
1947
- total: statSync(dst).size,
1948
- percent: 100
1949
- });
1950
- resolve();
1951
- }
1952
- });
1953
- });
2095
+ return ls(this, alias, path);
1954
2096
  }
1955
2097
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
1956
2098
  async startTunnel(alias, options) {
1957
- if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
1958
- if (options.localPort !== void 0 && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) throw new Error("localPort must be an integer in 1..65535");
1959
- if (this.store.find(alias) === void 0) throw new Error(`alias '${alias}' not found — add it first`);
1960
- const remoteHost = options.remoteHost ?? "127.0.0.1";
1961
- const id = `tun-${this.nextTunnelId++}`;
1962
- const info = {
1963
- id,
1964
- alias,
1965
- localPort: 0,
1966
- remoteHost,
1967
- remotePort: options.remotePort,
1968
- state: "connecting",
1969
- startedAt: Date.now()
1970
- };
1971
- const record = await this.acquire(alias);
1972
- const client = record.client;
1973
- const sockets = /* @__PURE__ */ new Set();
1974
- const server = createServer((socket) => {
1975
- sockets.add(socket);
1976
- socket.on("close", () => {
1977
- sockets.delete(socket);
1978
- });
1979
- client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1980
- if (error !== void 0) {
1981
- socket.destroy();
1982
- return;
1983
- }
1984
- const destroy = () => {
1985
- try {
1986
- socket.destroy();
1987
- } catch {}
1988
- try {
1989
- stream.close();
1990
- } catch {}
1991
- };
1992
- stream.on("error", destroy);
1993
- socket.on("error", destroy);
1994
- stream.on("close", destroy);
1995
- socket.on("close", destroy);
1996
- stream.pipe(socket).pipe(stream);
1997
- });
1998
- });
1999
- try {
2000
- await new Promise((resolve, reject) => {
2001
- server.once("error", reject);
2002
- server.listen(options.localPort ?? 0, "127.0.0.1", () => {
2003
- server.removeListener("error", reject);
2004
- resolve();
2005
- });
2006
- });
2007
- } catch (error) {
2008
- if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record);
2009
- throw error;
2010
- }
2011
- record.pinned = true;
2012
- const address = server.address();
2013
- info.localPort = typeof address === "object" && address !== null ? address.port : 0;
2014
- info.state = "forwarding";
2015
- this.tunnels.set(id, {
2016
- info,
2017
- server,
2018
- alias,
2019
- sockets
2020
- });
2021
- return info;
2099
+ return startTunnel(this, alias, options);
2022
2100
  }
2023
2101
  /** All active tunnels. */
2024
2102
  listTunnels() {
2025
- return [...this.tunnels.values()].map((tunnel) => ({ ...tunnel.info }));
2103
+ return listTunnels(this);
2026
2104
  }
2027
2105
  /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
2028
2106
  stopTunnel(id) {
2029
- const tunnel = this.tunnels.get(id);
2030
- if (tunnel === void 0) return false;
2031
- this.tunnels.delete(id);
2032
- try {
2033
- tunnel.server.close();
2034
- } catch {}
2035
- for (const socket of tunnel.sockets) try {
2036
- socket.destroy();
2037
- } catch {}
2038
- tunnel.sockets.clear();
2039
- this.disposeRecord(tunnel.alias);
2040
- return true;
2107
+ return stopTunnel(this, id);
2041
2108
  }
2042
2109
  /** Stop all tunnels (optionally for one alias). */
2043
2110
  stopAllTunnels(alias) {
2044
- let count = 0;
2045
- for (const [id, tunnel] of [...this.tunnels]) if (alias === void 0 || tunnel.alias === alias) {
2046
- this.stopTunnel(id);
2047
- count += 1;
2048
- }
2049
- return count;
2111
+ return stopAllTunnels(this, alias);
2050
2112
  }
2051
2113
  /** Probe connectivity: connect, run `true`, close. */
2052
2114
  async test(alias) {
@@ -2059,7 +2121,7 @@ var SshEngine = class {
2059
2121
  } : {
2060
2122
  ok: false,
2061
2123
  latencyMs: result.durationMs,
2062
- error: `remote exit code ${result.exitCode}`
2124
+ error: "remote exit code " + result.exitCode
2063
2125
  };
2064
2126
  } catch (error) {
2065
2127
  return {
@@ -2072,8 +2134,8 @@ var SshEngine = class {
2072
2134
  /** Close every pooled connection and tunnel. */
2073
2135
  dispose() {
2074
2136
  if (this.sweepTimer !== void 0) clearInterval(this.sweepTimer);
2075
- for (const id of [...this.tunnels.keys()]) this.stopTunnel(id);
2076
- for (const alias of [...this.pool.keys()]) this.disposeRecord(alias);
2137
+ for (const id of [...this.tunnels.keys()]) stopTunnel(this, id);
2138
+ for (const alias of [...this.pool.keys()]) disposeRecord(this, alias);
2077
2139
  }
2078
2140
  };
2079
2141
  //#endregion