@linxin666/dsh-ssh 0.1.14 → 0.1.16

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