novac 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -0
  3. package/demo.nv +0 -0
  4. package/demo_builtins.nv +0 -0
  5. package/demo_http.nv +0 -0
  6. package/examples/bf.nv +69 -0
  7. package/examples/math.nv +21 -0
  8. package/kits/birdAPI/kitdef.js +954 -0
  9. package/kits/kitRNG/kitdef.js +740 -0
  10. package/kits/kitSSH/kitdef.js +1272 -0
  11. package/kits/kitadb/kitdef.js +606 -0
  12. package/kits/kitai/kitdef.js +2185 -0
  13. package/kits/kitcanvas/kitdef.js +914 -0
  14. package/kits/kitclippy/kitdef.js +925 -0
  15. package/kits/kitgps/kitdef.js +1862 -0
  16. package/kits/kitlibproc/kitdef.js +3 -2
  17. package/kits/kitmorse/kitdef.js +229 -0
  18. package/kits/kitmpatch/kitdef.js +906 -0
  19. package/kits/kitnet/kitdef.js +1401 -0
  20. package/kits/kitproto/kitdef.js +613 -0
  21. package/kits/kitqr/kitdef.js +637 -0
  22. package/kits/kitrequire/kitdef.js +1599 -0
  23. package/kits/libtea/kitdef.js +2691 -0
  24. package/kits/libterm/kitdef.js +2 -0
  25. package/novac/LICENSE +21 -0
  26. package/novac/README.md +1823 -0
  27. package/novac/bin/novac +950 -0
  28. package/novac/bin/nvc +522 -0
  29. package/novac/bin/nvml +542 -0
  30. package/novac/demo.nv +245 -0
  31. package/novac/demo_builtins.nv +209 -0
  32. package/novac/demo_http.nv +62 -0
  33. package/novac/examples/bf.nv +69 -0
  34. package/novac/examples/math.nv +21 -0
  35. package/novac/kits/kitai/kitdef.js +2185 -0
  36. package/novac/kits/kitansi/kitdef.js +1402 -0
  37. package/novac/kits/kitformat/kitdef.js +1485 -0
  38. package/novac/kits/kitgps/kitdef.js +1862 -0
  39. package/novac/kits/kitlibfs/kitdef.js +231 -0
  40. package/{examples/example-project/nova_modules → novac/kits}/kitlibproc/kitdef.js +3 -2
  41. package/novac/kits/kitmatrix/ex.js +19 -0
  42. package/novac/kits/kitmatrix/kitdef.js +960 -0
  43. package/novac/kits/kitmpatch/kitdef.js +906 -0
  44. package/novac/kits/kitnovacweb/README.md +1572 -0
  45. package/novac/kits/kitnovacweb/demo.nv +12 -0
  46. package/novac/kits/kitnovacweb/demo.nvml +71 -0
  47. package/novac/kits/kitnovacweb/index.nova +12 -0
  48. package/novac/kits/kitnovacweb/kitdef.js +692 -0
  49. package/novac/kits/kitnovacweb/nova.kit.json +8 -0
  50. package/novac/kits/kitnovacweb/nvml/executor.js +739 -0
  51. package/novac/kits/kitnovacweb/nvml/index.js +67 -0
  52. package/novac/kits/kitnovacweb/nvml/lexer.js +263 -0
  53. package/novac/kits/kitnovacweb/nvml/parser.js +508 -0
  54. package/novac/kits/kitnovacweb/nvml/renderer.js +924 -0
  55. package/novac/kits/kitparse/kitdef.js +1688 -0
  56. package/novac/kits/kitregex++/kitdef.js +1353 -0
  57. package/novac/kits/kitrequire/kitdef.js +1599 -0
  58. package/novac/kits/kitx11/kitdef.js +1 -0
  59. package/novac/kits/kitx11/kitx11.js +2472 -0
  60. package/novac/kits/kitx11/kitx11_conn.js +948 -0
  61. package/novac/kits/kitx11/kitx11_worker.js +121 -0
  62. package/novac/kits/libterm/ex.js +285 -0
  63. package/novac/kits/libterm/kitdef.js +1927 -0
  64. package/novac/node_modules/chalk/license +9 -0
  65. package/novac/node_modules/chalk/package.json +83 -0
  66. package/novac/node_modules/chalk/readme.md +297 -0
  67. package/novac/node_modules/chalk/source/index.d.ts +325 -0
  68. package/novac/node_modules/chalk/source/index.js +225 -0
  69. package/novac/node_modules/chalk/source/utilities.js +33 -0
  70. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  71. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  72. package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  73. package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  74. package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  75. package/novac/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  76. package/novac/node_modules/commander/LICENSE +22 -0
  77. package/novac/node_modules/commander/Readme.md +1176 -0
  78. package/novac/node_modules/commander/esm.mjs +16 -0
  79. package/novac/node_modules/commander/index.js +24 -0
  80. package/novac/node_modules/commander/lib/argument.js +150 -0
  81. package/novac/node_modules/commander/lib/command.js +2777 -0
  82. package/novac/node_modules/commander/lib/error.js +39 -0
  83. package/novac/node_modules/commander/lib/help.js +747 -0
  84. package/novac/node_modules/commander/lib/option.js +380 -0
  85. package/novac/node_modules/commander/lib/suggestSimilar.js +101 -0
  86. package/novac/node_modules/commander/package-support.json +19 -0
  87. package/novac/node_modules/commander/package.json +82 -0
  88. package/novac/node_modules/commander/typings/esm.d.mts +3 -0
  89. package/novac/node_modules/commander/typings/index.d.ts +1113 -0
  90. package/novac/node_modules/node-addon-api/LICENSE.md +9 -0
  91. package/novac/node_modules/node-addon-api/README.md +95 -0
  92. package/novac/node_modules/node-addon-api/common.gypi +21 -0
  93. package/novac/node_modules/node-addon-api/except.gypi +25 -0
  94. package/novac/node_modules/node-addon-api/index.js +14 -0
  95. package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  96. package/novac/node_modules/node-addon-api/napi-inl.h +7165 -0
  97. package/novac/node_modules/node-addon-api/napi.h +3364 -0
  98. package/novac/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  99. package/novac/node_modules/node-addon-api/node_api.gyp +9 -0
  100. package/novac/node_modules/node-addon-api/noexcept.gypi +26 -0
  101. package/novac/node_modules/node-addon-api/package-support.json +21 -0
  102. package/novac/node_modules/node-addon-api/package.json +480 -0
  103. package/novac/node_modules/node-addon-api/tools/README.md +73 -0
  104. package/novac/node_modules/node-addon-api/tools/check-napi.js +99 -0
  105. package/novac/node_modules/node-addon-api/tools/clang-format.js +71 -0
  106. package/novac/node_modules/node-addon-api/tools/conversion.js +301 -0
  107. package/novac/node_modules/serialize-javascript/LICENSE +27 -0
  108. package/novac/node_modules/serialize-javascript/README.md +149 -0
  109. package/novac/node_modules/serialize-javascript/index.js +297 -0
  110. package/novac/node_modules/serialize-javascript/package.json +33 -0
  111. package/novac/package.json +27 -0
  112. package/novac/scripts/update-bin.js +24 -0
  113. package/novac/src/core/bstd.js +1035 -0
  114. package/novac/src/core/config.js +155 -0
  115. package/novac/src/core/describe.js +187 -0
  116. package/novac/src/core/emitter.js +499 -0
  117. package/novac/src/core/error.js +86 -0
  118. package/novac/src/core/executor.js +5606 -0
  119. package/novac/src/core/formatter.js +686 -0
  120. package/novac/src/core/lexer.js +1026 -0
  121. package/novac/src/core/nova_builtins.js +717 -0
  122. package/novac/src/core/nova_thread_worker.js +166 -0
  123. package/novac/src/core/parser.js +2181 -0
  124. package/novac/src/core/types.js +112 -0
  125. package/novac/src/index.js +28 -0
  126. package/novac/src/runtime/stdlib.js +244 -0
  127. package/package.json +3 -2
  128. package/scripts/update-bin.js +0 -0
  129. package/src/core/bstd.js +835 -361
  130. package/src/core/executor.js +427 -246
  131. package/src/core/lexer.js +19 -2
  132. package/src/core/parser.js +13 -0
  133. package/src/index.js +0 -0
  134. package/examples/example-project/README.md +0 -3
  135. package/examples/example-project/src/main.nova +0 -3
  136. package/src/core/environment.js +0 -0
  137. /package/{kits → novac/kits}/libtea/tf.js +0 -0
  138. /package/{examples/example-project/bin/example-project.nv → novac/node_modules/node-addon-api/nothing.c} +0 -0
@@ -0,0 +1,1272 @@
1
+ // kitdef.js — kitSSH
2
+ // A complete OpenSSH-equivalent kit for the standard library.
3
+ // Covers: connections, sessions, command execution, key management,
4
+ // tunneling, port forwarding, SCP/SFTP, agent forwarding,
5
+ // multiplexing, jump hosts, known_hosts, authorized_keys, and more.
6
+ //
7
+ // Backed by the `ssh2` npm package for real execution.
8
+ // Every method returns a structured result object.
9
+ //
10
+ // const { kitSSH } = require('./kitdef').kitdef;
11
+
12
+ // ── Dependencies (real execution layer) ─────────────────────────────────────
13
+ // npm install ssh2
14
+
15
+ let _ssh2;
16
+ try { _ssh2 = require("ssh2"); } catch { _ssh2 = null; }
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const os = require("os");
21
+ const { execSync, spawn } = require("child_process");
22
+ const crypto = require("crypto");
23
+ const net = require("net");
24
+
25
+ // ── Internal state ───────────────────────────────────────────────────────────
26
+
27
+ const _connections = new Map(); // id → { client, config, tunnels, sftp }
28
+ const _knownHosts = new Map(); // "host:port" → { fingerprint, addedAt }
29
+ const _agentSockets = new Map(); // id → socketPath
30
+ const _pool = new Map(); // poolKey → Connection[]
31
+ let _connectionCounter = 1;
32
+
33
+ function _connId() { return `ssh-conn-${_connectionCounter++}`; }
34
+ function _poolKey(config) { return `${config.username}@${config.host}:${config.port || 22}`; }
35
+
36
+ function _requireSsh2() {
37
+ if (!_ssh2) throw new Error("ssh2 package not installed. Run: npm install ssh2");
38
+ }
39
+
40
+ function _getConn(id) {
41
+ const c = _connections.get(id);
42
+ if (!c) throw new Error(`No active connection "${id}". Call connect() first.`);
43
+ return c;
44
+ }
45
+
46
+ function _fingerprint(key) {
47
+ return crypto.createHash("sha256").update(key).digest("base64");
48
+ }
49
+
50
+ function _sshDir() {
51
+ return path.join(os.homedir(), ".ssh");
52
+ }
53
+
54
+ // ── Export ───────────────────────────────────────────────────────────────────
55
+
56
+ module.exports = {
57
+ kitdef: {
58
+
59
+ name: "kitSSH",
60
+ version: "1.0.0",
61
+ description: "Complete OpenSSH kit: connections, sessions, exec, keys, tunnels, SFTP, SCP, agent, multiplexing, jump hosts, and more.",
62
+
63
+ // ──────────────────────────────────────────────────────────────────────
64
+ // CONNECTION MANAGEMENT
65
+ // ──────────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Open an SSH connection to a remote host.
69
+ * Returns a connection ID used by all subsequent operations.
70
+ *
71
+ * @param {object} config
72
+ * @param {string} config.host
73
+ * @param {number} [config.port=22]
74
+ * @param {string} config.username
75
+ * @param {string} [config.password]
76
+ * @param {string} [config.privateKey] - Path to private key file, or raw key string.
77
+ * @param {string} [config.passphrase] - Passphrase for encrypted private key.
78
+ * @param {number} [config.timeout=10000] - Connection timeout ms.
79
+ * @param {boolean} [config.agentForward=false]
80
+ * @param {string} [config.jumpHost] - ProxyJump host connection ID.
81
+ * @returns {Promise<{ id: string, host: string, port: number, username: string, status: string }>}
82
+ *
83
+ * @example
84
+ * const { id } = await kitSSH.connect({
85
+ * host: "192.168.1.10",
86
+ * username: "admin",
87
+ * privateKey: "~/.ssh/id_ed25519",
88
+ * });
89
+ */
90
+ connect(config) {
91
+ _requireSsh2();
92
+ return new Promise((resolve, reject) => {
93
+ const id = _connId();
94
+ const client = new _ssh2.Client();
95
+ const port = config.port || 22;
96
+ const timeout = config.timeout || 10000;
97
+
98
+ let privateKey = config.privateKey;
99
+ if (privateKey && !privateKey.includes("-----")) {
100
+ const expanded = privateKey.replace(/^~/, os.homedir());
101
+ privateKey = fs.readFileSync(expanded, "utf8");
102
+ }
103
+
104
+ const sshConfig = {
105
+ host: config.host,
106
+ port,
107
+ username: config.username,
108
+ password: config.password,
109
+ privateKey: privateKey || undefined,
110
+ passphrase: config.passphrase,
111
+ readyTimeout: timeout,
112
+ agentForward: config.agentForward || false,
113
+ agent: config.agentForward ? (process.env.SSH_AUTH_SOCK || undefined) : undefined,
114
+ };
115
+
116
+ // ProxyJump / jump host
117
+ if (config.jumpHost) {
118
+ const jump = _getConn(config.jumpHost);
119
+ jump.client.forwardOut("127.0.0.1", 0, config.host, port, (err, stream) => {
120
+ if (err) return reject(err);
121
+ sshConfig.sock = stream;
122
+ _finishConnect(client, sshConfig, id, config, resolve, reject);
123
+ });
124
+ } else {
125
+ _finishConnect(client, sshConfig, id, config, resolve, reject);
126
+ }
127
+ });
128
+ },
129
+
130
+ /**
131
+ * Disconnect a session by connection ID.
132
+ * @param {string} id
133
+ * @returns {{ id, status }}
134
+ */
135
+ disconnect(id) {
136
+ const c = _connections.get(id);
137
+ if (c) {
138
+ c.client.end();
139
+ _connections.delete(id);
140
+ }
141
+ return { id, status: "DISCONNECTED" };
142
+ },
143
+
144
+ /**
145
+ * Disconnect all open connections.
146
+ * @returns {{ disconnected: number }}
147
+ */
148
+ disconnectAll() {
149
+ let count = 0;
150
+ for (const [id, c] of _connections) {
151
+ c.client.end();
152
+ _connections.delete(id);
153
+ count++;
154
+ }
155
+ return { disconnected: count };
156
+ },
157
+
158
+ /**
159
+ * Check whether a connection is still alive.
160
+ * @param {string} id
161
+ * @returns {{ id, alive: boolean }}
162
+ */
163
+ isAlive(id) {
164
+ const c = _connections.get(id);
165
+ return { id, alive: !!c };
166
+ },
167
+
168
+ /**
169
+ * List all currently open connections.
170
+ * @returns {object[]}
171
+ */
172
+ listConnections() {
173
+ return Array.from(_connections.entries()).map(([id, c]) => ({
174
+ id,
175
+ host: c.config.host,
176
+ port: c.config.port || 22,
177
+ username: c.config.username,
178
+ tunnelCount: c.tunnels ? c.tunnels.length : 0,
179
+ openedAt: c.openedAt,
180
+ }));
181
+ },
182
+
183
+ /**
184
+ * Get detailed info about a specific connection.
185
+ * @param {string} id
186
+ * @returns {object}
187
+ */
188
+ connectionInfo(id) {
189
+ const c = _getConn(id);
190
+ return {
191
+ id,
192
+ host: c.config.host,
193
+ port: c.config.port || 22,
194
+ username: c.config.username,
195
+ agentForward: c.config.agentForward || false,
196
+ tunnels: c.tunnels || [],
197
+ openedAt: c.openedAt,
198
+ };
199
+ },
200
+
201
+ // ──────────────────────────────────────────────────────────────────────
202
+ // COMMAND EXECUTION
203
+ // ──────────────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Execute a command on a remote host and return stdout/stderr.
207
+ * @param {string} id - Connection ID.
208
+ * @param {string} command
209
+ * @param {object} [options]
210
+ * @param {object} [options.env] - Environment variables.
211
+ * @param {boolean}[options.pty=false] - Request a pseudo-TTY.
212
+ * @returns {Promise<{ stdout: string, stderr: string, code: number, signal?: string }>}
213
+ *
214
+ * @example
215
+ * const { stdout } = await kitSSH.exec(id, "uname -a");
216
+ */
217
+ exec(id, command, options = {}) {
218
+ const c = _getConn(id);
219
+ return new Promise((resolve, reject) => {
220
+ const execOptions = { env: options.env };
221
+ if (options.pty) execOptions.pty = true;
222
+
223
+ c.client.exec(command, execOptions, (err, stream) => {
224
+ if (err) return reject(err);
225
+ let stdout = "", stderr = "";
226
+ stream.on("data", d => { stdout += d; });
227
+ stream.stderr.on("data", d => { stderr += d; });
228
+ stream.on("close", (code, signal) => resolve({ stdout, stderr, code, signal }));
229
+ });
230
+ });
231
+ },
232
+
233
+ /**
234
+ * Execute multiple commands sequentially on the same connection.
235
+ * @param {string} id
236
+ * @param {string[]} commands
237
+ * @returns {Promise<Array<{ command, stdout, stderr, code }>>}
238
+ *
239
+ * @example
240
+ * await kitSSH.execMany(id, ["apt update", "apt upgrade -y", "reboot"]);
241
+ */
242
+ async execMany(id, commands) {
243
+ const results = [];
244
+ for (const command of commands) {
245
+ const result = await this.exec(id, command);
246
+ results.push({ command, ...result });
247
+ }
248
+ return results;
249
+ },
250
+
251
+ /**
252
+ * Execute a command and stream output line-by-line via a callback.
253
+ * @param {string} id
254
+ * @param {string} command
255
+ * @param {{ onStdout?: function, onStderr?: function }} callbacks
256
+ * @returns {Promise<{ code: number }>}
257
+ */
258
+ execStream(id, command, { onStdout, onStderr } = {}) {
259
+ const c = _getConn(id);
260
+ return new Promise((resolve, reject) => {
261
+ c.client.exec(command, (err, stream) => {
262
+ if (err) return reject(err);
263
+ stream.on("data", chunk => {
264
+ const lines = chunk.toString().split("\n").filter(Boolean);
265
+ lines.forEach(line => onStdout && onStdout(line));
266
+ });
267
+ stream.stderr.on("data", chunk => {
268
+ const lines = chunk.toString().split("\n").filter(Boolean);
269
+ lines.forEach(line => onStderr && onStderr(line));
270
+ });
271
+ stream.on("close", code => resolve({ code }));
272
+ });
273
+ });
274
+ },
275
+
276
+ /**
277
+ * Open an interactive shell session.
278
+ * @param {string} id
279
+ * @param {object} [options]
280
+ * @param {object} [options.ptyOptions] - e.g. { term: "xterm-256color", cols: 220, rows: 50 }
281
+ * @returns {Promise<stream>} - A duplex stream you can pipe to/from.
282
+ */
283
+ shell(id, options = {}) {
284
+ const c = _getConn(id);
285
+ const ptyOptions = options.ptyOptions || { term: "xterm-256color", cols: 220, rows: 50 };
286
+ return new Promise((resolve, reject) => {
287
+ c.client.shell(ptyOptions, (err, stream) => {
288
+ if (err) return reject(err);
289
+ resolve(stream);
290
+ });
291
+ });
292
+ },
293
+
294
+ /**
295
+ * Execute a command with a timeout. Kills the remote process if exceeded.
296
+ * @param {string} id
297
+ * @param {string} command
298
+ * @param {number} timeoutMs
299
+ * @returns {Promise<{ stdout, stderr, code, timedOut }>}
300
+ */
301
+ execTimeout(id, command, timeoutMs) {
302
+ return new Promise((resolve, reject) => {
303
+ const c = _getConn(id);
304
+ let timedOut = false;
305
+ let stdout = "", stderr = "";
306
+
307
+ c.client.exec(command, (err, stream) => {
308
+ if (err) return reject(err);
309
+
310
+ const timer = setTimeout(() => {
311
+ timedOut = true;
312
+ stream.close();
313
+ }, timeoutMs);
314
+
315
+ stream.on("data", d => { stdout += d; });
316
+ stream.stderr.on("data", d => { stderr += d; });
317
+ stream.on("close", code => {
318
+ clearTimeout(timer);
319
+ resolve({ stdout, stderr, code, timedOut });
320
+ });
321
+ });
322
+ });
323
+ },
324
+
325
+ /**
326
+ * Run a local script file on the remote host.
327
+ * Uploads it, executes it, then removes it.
328
+ * @param {string} id
329
+ * @param {string} localScriptPath
330
+ * @param {string[]} [args]
331
+ * @returns {Promise<{ stdout, stderr, code }>}
332
+ */
333
+ async runScript(id, localScriptPath, args = []) {
334
+ const remotePath = `/tmp/_kitSSH_script_${Date.now()}.sh`;
335
+ await this.upload(id, localScriptPath, remotePath);
336
+ await this.exec(id, `chmod +x ${remotePath}`);
337
+ const result = await this.exec(id, `${remotePath} ${args.join(" ")}`);
338
+ await this.exec(id, `rm -f ${remotePath}`);
339
+ return result;
340
+ },
341
+
342
+ /**
343
+ * Run a command as a different user via sudo.
344
+ * @param {string} id
345
+ * @param {string} command
346
+ * @param {string} [sudoPassword]
347
+ * @returns {Promise<{ stdout, stderr, code }>}
348
+ */
349
+ sudo(id, command, sudoPassword) {
350
+ const wrapped = sudoPassword
351
+ ? `echo '${sudoPassword}' | sudo -S ${command}`
352
+ : `sudo ${command}`;
353
+ return this.exec(id, wrapped);
354
+ },
355
+
356
+ // ──────────────────────────────────────────────────────────────────────
357
+ // SFTP / FILE TRANSFER
358
+ // ──────────────────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Open an SFTP session on an existing connection.
362
+ * @param {string} id
363
+ * @returns {Promise<sftp>}
364
+ */
365
+ openSFTP(id) {
366
+ const c = _getConn(id);
367
+ return new Promise((resolve, reject) => {
368
+ c.client.sftp((err, sftp) => {
369
+ if (err) return reject(err);
370
+ c.sftp = sftp;
371
+ resolve(sftp);
372
+ });
373
+ });
374
+ },
375
+
376
+ /**
377
+ * Upload a local file to the remote host.
378
+ * @param {string} id
379
+ * @param {string} localPath
380
+ * @param {string} remotePath
381
+ * @param {{ onProgress?: function }} [options]
382
+ * @returns {Promise<{ localPath, remotePath, status }>}
383
+ *
384
+ * @example
385
+ * await kitSSH.upload(id, "./deploy.sh", "/home/admin/deploy.sh");
386
+ */
387
+ async upload(id, localPath, remotePath, options = {}) {
388
+ const sftp = await this.openSFTP(id);
389
+ return new Promise((resolve, reject) => {
390
+ const readStream = fs.createReadStream(localPath);
391
+ const writeStream = sftp.createWriteStream(remotePath);
392
+ if (options.onProgress) {
393
+ let transferred = 0;
394
+ const total = fs.statSync(localPath).size;
395
+ readStream.on("data", chunk => {
396
+ transferred += chunk.length;
397
+ options.onProgress({ transferred, total, percent: (transferred / total * 100).toFixed(1) });
398
+ });
399
+ }
400
+ writeStream.on("close", () => resolve({ localPath, remotePath, status: "UPLOADED" }));
401
+ writeStream.on("error", reject);
402
+ readStream.pipe(writeStream);
403
+ });
404
+ },
405
+
406
+ /**
407
+ * Download a remote file to local disk.
408
+ * @param {string} id
409
+ * @param {string} remotePath
410
+ * @param {string} localPath
411
+ * @param {{ onProgress?: function }} [options]
412
+ * @returns {Promise<{ remotePath, localPath, status }>}
413
+ */
414
+ async download(id, remotePath, localPath, options = {}) {
415
+ const sftp = await this.openSFTP(id);
416
+ return new Promise((resolve, reject) => {
417
+ const readStream = sftp.createReadStream(remotePath);
418
+ const writeStream = fs.createWriteStream(localPath);
419
+ readStream.on("error", reject);
420
+ writeStream.on("close", () => resolve({ remotePath, localPath, status: "DOWNLOADED" }));
421
+ readStream.pipe(writeStream);
422
+ });
423
+ },
424
+
425
+ /**
426
+ * Upload an entire directory recursively.
427
+ * @param {string} id
428
+ * @param {string} localDir
429
+ * @param {string} remoteDir
430
+ * @returns {Promise<{ uploaded: string[] }>}
431
+ */
432
+ async uploadDir(id, localDir, remoteDir) {
433
+ const uploaded = [];
434
+ const walk = async (local, remote) => {
435
+ await this.exec(id, `mkdir -p ${remote}`);
436
+ for (const entry of fs.readdirSync(local, { withFileTypes: true })) {
437
+ const lp = path.join(local, entry.name);
438
+ const rp = `${remote}/${entry.name}`;
439
+ if (entry.isDirectory()) {
440
+ await walk(lp, rp);
441
+ } else {
442
+ await this.upload(id, lp, rp);
443
+ uploaded.push(rp);
444
+ }
445
+ }
446
+ };
447
+ await walk(localDir, remoteDir);
448
+ return { uploaded };
449
+ },
450
+
451
+ /**
452
+ * List files in a remote directory.
453
+ * @param {string} id
454
+ * @param {string} remotePath
455
+ * @returns {Promise<object[]>}
456
+ */
457
+ async listDir(id, remotePath) {
458
+ const sftp = await this.openSFTP(id);
459
+ return new Promise((resolve, reject) => {
460
+ sftp.readdir(remotePath, (err, list) => {
461
+ if (err) return reject(err);
462
+ resolve(list.map(f => ({
463
+ name: f.filename,
464
+ size: f.attrs.size,
465
+ mode: f.attrs.mode,
466
+ modified: new Date(f.attrs.mtime * 1000).toISOString(),
467
+ isDirectory: (f.attrs.mode & 0o40000) !== 0,
468
+ })));
469
+ });
470
+ });
471
+ },
472
+
473
+ /**
474
+ * Read a remote file's contents into a string.
475
+ * @param {string} id
476
+ * @param {string} remotePath
477
+ * @returns {Promise<string>}
478
+ */
479
+ async readFile(id, remotePath) {
480
+ const sftp = await this.openSFTP(id);
481
+ return new Promise((resolve, reject) => {
482
+ const chunks = [];
483
+ const stream = sftp.createReadStream(remotePath);
484
+ stream.on("data", d => chunks.push(d));
485
+ stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
486
+ stream.on("error", reject);
487
+ });
488
+ },
489
+
490
+ /**
491
+ * Write a string to a remote file.
492
+ * @param {string} id
493
+ * @param {string} remotePath
494
+ * @param {string} content
495
+ * @returns {Promise<{ remotePath, status }>}
496
+ */
497
+ async writeFile(id, remotePath, content) {
498
+ const sftp = await this.openSFTP(id);
499
+ return new Promise((resolve, reject) => {
500
+ const stream = sftp.createWriteStream(remotePath);
501
+ stream.on("close", () => resolve({ remotePath, status: "WRITTEN" }));
502
+ stream.on("error", reject);
503
+ stream.end(content, "utf8");
504
+ });
505
+ },
506
+
507
+ /**
508
+ * Delete a file on the remote host.
509
+ * @param {string} id
510
+ * @param {string} remotePath
511
+ * @returns {Promise<{ remotePath, status }>}
512
+ */
513
+ async deleteFile(id, remotePath) {
514
+ const sftp = await this.openSFTP(id);
515
+ return new Promise((resolve, reject) => {
516
+ sftp.unlink(remotePath, err => {
517
+ if (err) return reject(err);
518
+ resolve({ remotePath, status: "DELETED" });
519
+ });
520
+ });
521
+ },
522
+
523
+ /**
524
+ * Get the stat of a remote path.
525
+ * @param {string} id
526
+ * @param {string} remotePath
527
+ * @returns {Promise<object>}
528
+ */
529
+ async stat(id, remotePath) {
530
+ const sftp = await this.openSFTP(id);
531
+ return new Promise((resolve, reject) => {
532
+ sftp.stat(remotePath, (err, stats) => {
533
+ if (err) return reject(err);
534
+ resolve({
535
+ size: stats.size,
536
+ mode: stats.mode,
537
+ uid: stats.uid,
538
+ gid: stats.gid,
539
+ accessed: new Date(stats.atime * 1000).toISOString(),
540
+ modified: new Date(stats.mtime * 1000).toISOString(),
541
+ isDirectory: stats.isDirectory(),
542
+ isFile: stats.isFile(),
543
+ isSymlink: stats.isSymbolicLink(),
544
+ });
545
+ });
546
+ });
547
+ },
548
+
549
+ /**
550
+ * Rename/move a file on the remote host.
551
+ * @param {string} id
552
+ * @param {string} remoteSrc
553
+ * @param {string} remoteDest
554
+ * @returns {Promise<{ status }>}
555
+ */
556
+ async rename(id, remoteSrc, remoteDest) {
557
+ const sftp = await this.openSFTP(id);
558
+ return new Promise((resolve, reject) => {
559
+ sftp.rename(remoteSrc, remoteDest, err => {
560
+ if (err) return reject(err);
561
+ resolve({ status: "RENAMED", from: remoteSrc, to: remoteDest });
562
+ });
563
+ });
564
+ },
565
+
566
+ /**
567
+ * Create a directory on the remote host.
568
+ * @param {string} id
569
+ * @param {string} remotePath
570
+ * @param {boolean} [recursive=true]
571
+ * @returns {Promise<{ remotePath, status }>}
572
+ */
573
+ async mkdir(id, remotePath, recursive = true) {
574
+ if (recursive) {
575
+ await this.exec(id, `mkdir -p ${remotePath}`);
576
+ return { remotePath, status: "CREATED" };
577
+ }
578
+ const sftp = await this.openSFTP(id);
579
+ return new Promise((resolve, reject) => {
580
+ sftp.mkdir(remotePath, err => {
581
+ if (err) return reject(err);
582
+ resolve({ remotePath, status: "CREATED" });
583
+ });
584
+ });
585
+ },
586
+
587
+ // ──────────────────────────────────────────────────────────────────────
588
+ // TUNNELING & PORT FORWARDING
589
+ // ──────────────────────────────────────────────────────────────────────
590
+
591
+ /**
592
+ * Open a local port forward (ssh -L).
593
+ * Traffic to localhost:localPort is forwarded to remoteHost:remotePort via the SSH server.
594
+ *
595
+ * @param {string} id - Connection ID.
596
+ * @param {object} options
597
+ * @param {number} options.localPort
598
+ * @param {string} options.remoteHost
599
+ * @param {number} options.remotePort
600
+ * @param {string} [options.bindAddress="127.0.0.1"]
601
+ * @returns {Promise<{ tunnelId, localPort, remoteHost, remotePort, server }>}
602
+ *
603
+ * @example
604
+ * await kitSSH.localForward(id, { localPort: 5432, remoteHost: "db.internal", remotePort: 5432 });
605
+ */
606
+ localForward(id, { localPort, remoteHost, remotePort, bindAddress = "127.0.0.1" }) {
607
+ const c = _getConn(id);
608
+ const tunnelId = `tunnel-local-${localPort}`;
609
+
610
+ return new Promise((resolve, reject) => {
611
+ const server = net.createServer(sock => {
612
+ c.client.forwardOut(bindAddress, localPort, remoteHost, remotePort, (err, stream) => {
613
+ if (err) { sock.destroy(); return; }
614
+ sock.pipe(stream).pipe(sock);
615
+ });
616
+ });
617
+
618
+ server.listen(localPort, bindAddress, () => {
619
+ c.tunnels = c.tunnels || [];
620
+ c.tunnels.push({ tunnelId, type: "local", localPort, remoteHost, remotePort, server });
621
+ resolve({ tunnelId, localPort, remoteHost, remotePort, status: "ACTIVE" });
622
+ });
623
+
624
+ server.on("error", reject);
625
+ });
626
+ },
627
+
628
+ /**
629
+ * Open a remote port forward (ssh -R).
630
+ * Traffic to remotePort on the SSH server is forwarded to localHost:localPort.
631
+ *
632
+ * @param {string} id
633
+ * @param {object} options
634
+ * @param {number} options.remotePort
635
+ * @param {string} options.localHost
636
+ * @param {number} options.localPort
637
+ * @returns {Promise<{ tunnelId, remotePort, localHost, localPort }>}
638
+ */
639
+ remoteForward(id, { remotePort, localHost, localPort }) {
640
+ const c = _getConn(id);
641
+ const tunnelId = `tunnel-remote-${remotePort}`;
642
+
643
+ return new Promise((resolve, reject) => {
644
+ c.client.forwardIn("0.0.0.0", remotePort, (err) => {
645
+ if (err) return reject(err);
646
+
647
+ c.client.on("tcp connection", (info, accept) => {
648
+ if (info.destPort !== remotePort) return;
649
+ const stream = accept();
650
+ const sock = net.connect(localPort, localHost);
651
+ stream.pipe(sock).pipe(stream);
652
+ });
653
+
654
+ c.tunnels = c.tunnels || [];
655
+ c.tunnels.push({ tunnelId, type: "remote", remotePort, localHost, localPort });
656
+ resolve({ tunnelId, remotePort, localHost, localPort, status: "ACTIVE" });
657
+ });
658
+ });
659
+ },
660
+
661
+ /**
662
+ * Open a dynamic SOCKS5 proxy tunnel (ssh -D).
663
+ * @param {string} id
664
+ * @param {number} localPort - Local SOCKS5 proxy port.
665
+ * @returns {Promise<{ tunnelId, localPort, type }>}
666
+ */
667
+ dynamicForward(id, localPort) {
668
+ const c = _getConn(id);
669
+ const tunnelId = `tunnel-dynamic-${localPort}`;
670
+
671
+ return new Promise((resolve, reject) => {
672
+ const server = net.createServer(sock => {
673
+ // Minimal SOCKS5 handshake
674
+ sock.once("data", data => {
675
+ if (data[0] !== 0x05) { sock.destroy(); return; }
676
+ sock.write(Buffer.from([0x05, 0x00])); // no auth
677
+
678
+ sock.once("data", req => {
679
+ if (req[1] !== 0x01) { sock.destroy(); return; } // CONNECT only
680
+ const hostLen = req[4];
681
+ const host = req.slice(5, 5 + hostLen).toString();
682
+ const port = req.readUInt16BE(5 + hostLen);
683
+
684
+ c.client.forwardOut("127.0.0.1", localPort, host, port, (err, stream) => {
685
+ if (err) { sock.destroy(); return; }
686
+ const resp = Buffer.alloc(10);
687
+ resp[0] = 0x05; resp[1] = 0x00; resp[3] = 0x01;
688
+ sock.write(resp);
689
+ sock.pipe(stream).pipe(sock);
690
+ });
691
+ });
692
+ });
693
+ });
694
+
695
+ server.listen(localPort, "127.0.0.1", () => {
696
+ c.tunnels = c.tunnels || [];
697
+ c.tunnels.push({ tunnelId, type: "dynamic-socks5", localPort, server });
698
+ resolve({ tunnelId, localPort, type: "SOCKS5", status: "ACTIVE" });
699
+ });
700
+
701
+ server.on("error", reject);
702
+ });
703
+ },
704
+
705
+ /**
706
+ * Close a specific tunnel by its tunnel ID.
707
+ * @param {string} id - Connection ID.
708
+ * @param {string} tunnelId
709
+ * @returns {{ tunnelId, status }}
710
+ */
711
+ closeTunnel(id, tunnelId) {
712
+ const c = _getConn(id);
713
+ const idx = (c.tunnels || []).findIndex(t => t.tunnelId === tunnelId);
714
+ if (idx === -1) throw new Error(`Tunnel "${tunnelId}" not found.`);
715
+ const tunnel = c.tunnels[idx];
716
+ if (tunnel.server) tunnel.server.close();
717
+ c.tunnels.splice(idx, 1);
718
+ return { tunnelId, status: "CLOSED" };
719
+ },
720
+
721
+ /**
722
+ * List all active tunnels on a connection.
723
+ * @param {string} id
724
+ * @returns {object[]}
725
+ */
726
+ listTunnels(id) {
727
+ const c = _getConn(id);
728
+ return (c.tunnels || []).map(({ server, ...rest }) => rest);
729
+ },
730
+
731
+ // ──────────────────────────────────────────────────────────────────────
732
+ // KEY MANAGEMENT
733
+ // ──────────────────────────────────────────────────────────────────────
734
+
735
+ /**
736
+ * Generate a new SSH key pair.
737
+ * @param {object} [options]
738
+ * @param {'ed25519'|'rsa'|'ecdsa'} [options.type='ed25519']
739
+ * @param {number} [options.bits=4096] - RSA bits.
740
+ * @param {string} [options.comment='']
741
+ * @param {string} [options.passphrase='']
742
+ * @param {string} [options.outputPath] - Base path (no extension). Defaults to ~/.ssh/id_<type>.
743
+ * @returns {{ privateKeyPath, publicKeyPath, fingerprint, type }}
744
+ *
745
+ * @example
746
+ * kitSSH.generateKey({ type: "ed25519", comment: "deploy@prod" });
747
+ */
748
+ generateKey(options = {}) {
749
+ const type = options.type || "ed25519";
750
+ const bits = options.bits || 4096;
751
+ const comment = options.comment || "";
752
+ const passphrase = options.passphrase || "";
753
+ const basePath = options.outputPath || path.join(_sshDir(), `id_${type}_${Date.now()}`);
754
+ const privPath = basePath;
755
+ const pubPath = `${basePath}.pub`;
756
+
757
+ let cmd = `ssh-keygen -t ${type} -C "${comment}" -f "${privPath}" -N "${passphrase}"`;
758
+ if (type === "rsa") cmd += ` -b ${bits}`;
759
+
760
+ execSync(cmd, { stdio: "pipe" });
761
+
762
+ const pub = fs.readFileSync(pubPath, "utf8").trim();
763
+ const fingerprint = execSync(`ssh-keygen -lf "${pubPath}"`, { encoding: "utf8" }).trim();
764
+
765
+ return { privateKeyPath: privPath, publicKeyPath: pubPath, fingerprint, type, comment };
766
+ },
767
+
768
+ /**
769
+ * Add a public key to the remote host's authorized_keys for a user.
770
+ * @param {string} id - Connection ID.
771
+ * @param {string} publicKey - Path to .pub file or raw public key string.
772
+ * @param {string} [remoteUser] - Defaults to the connected user.
773
+ * @returns {Promise<{ status }>}
774
+ */
775
+ async authorizeKey(id, publicKey, remoteUser) {
776
+ let key = publicKey;
777
+ if (!key.startsWith("ssh-") && !key.startsWith("ecdsa-")) {
778
+ key = fs.readFileSync(publicKey.replace(/^~/, os.homedir()), "utf8").trim();
779
+ }
780
+ const user = remoteUser || _getConn(id).config.username;
781
+ const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '${key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
782
+ await this.exec(id, cmd);
783
+ return { status: "KEY AUTHORIZED", user, fingerprint: _fingerprint(key) };
784
+ },
785
+
786
+ /**
787
+ * Remove a public key from the remote host's authorized_keys.
788
+ * @param {string} id
789
+ * @param {string} publicKey - Path to .pub file or raw public key string.
790
+ * @returns {Promise<{ status }>}
791
+ */
792
+ async revokeKey(id, publicKey) {
793
+ let key = publicKey;
794
+ if (!key.startsWith("ssh-") && !key.startsWith("ecdsa-")) {
795
+ key = fs.readFileSync(publicKey.replace(/^~/, os.homedir()), "utf8").trim();
796
+ }
797
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
798
+ await this.exec(id, `sed -i '/${escaped}/d' ~/.ssh/authorized_keys`);
799
+ return { status: "KEY REVOKED" };
800
+ },
801
+
802
+ /**
803
+ * List all public keys in the remote host's authorized_keys.
804
+ * @param {string} id
805
+ * @returns {Promise<string[]>}
806
+ */
807
+ async listAuthorizedKeys(id) {
808
+ const { stdout } = await this.exec(id, "cat ~/.ssh/authorized_keys 2>/dev/null || echo ''");
809
+ return stdout.trim().split("\n").filter(Boolean);
810
+ },
811
+
812
+ /**
813
+ * Add a remote host's fingerprint to the local known_hosts file.
814
+ * @param {string} host
815
+ * @param {number} [port=22]
816
+ * @returns {{ host, fingerprint, status }}
817
+ */
818
+ addKnownHost(host, port = 22) {
819
+ const result = execSync(`ssh-keyscan -p ${port} ${host} 2>/dev/null`, { encoding: "utf8" }).trim();
820
+ const knownHostsPath = path.join(_sshDir(), "known_hosts");
821
+ fs.mkdirSync(_sshDir(), { recursive: true });
822
+ fs.appendFileSync(knownHostsPath, `\n${result}\n`);
823
+ const fingerprint = _fingerprint(result);
824
+ _knownHosts.set(`${host}:${port}`, { fingerprint, addedAt: new Date().toISOString() });
825
+ return { host, port, fingerprint, status: "ADDED TO KNOWN_HOSTS" };
826
+ },
827
+
828
+ /**
829
+ * Remove a host from the local known_hosts file.
830
+ * @param {string} host
831
+ * @param {number} [port=22]
832
+ * @returns {{ status }}
833
+ */
834
+ removeKnownHost(host, port = 22) {
835
+ execSync(`ssh-keygen -R "[${host}]:${port}" 2>/dev/null || ssh-keygen -R "${host}" 2>/dev/null`, { stdio: "pipe" });
836
+ _knownHosts.delete(`${host}:${port}`);
837
+ return { host, port, status: "REMOVED FROM KNOWN_HOSTS" };
838
+ },
839
+
840
+ /**
841
+ * Check if a host's fingerprint is already trusted in known_hosts.
842
+ * @param {string} host
843
+ * @param {number} [port=22]
844
+ * @returns {{ trusted: boolean, fingerprint?: string }}
845
+ */
846
+ checkKnownHost(host, port = 22) {
847
+ try {
848
+ const result = execSync(`ssh-keygen -F "[${host}]:${port}" 2>/dev/null`, { encoding: "utf8" });
849
+ return { trusted: result.trim().length > 0, fingerprint: _fingerprint(result) };
850
+ } catch {
851
+ return { trusted: false };
852
+ }
853
+ },
854
+
855
+ /**
856
+ * Get the fingerprint of a remote host without connecting.
857
+ * @param {string} host
858
+ * @param {number} [port=22]
859
+ * @returns {{ host, port, fingerprint }}
860
+ */
861
+ getHostFingerprint(host, port = 22) {
862
+ const raw = execSync(`ssh-keyscan -p ${port} ${host} 2>/dev/null`, { encoding: "utf8" }).trim();
863
+ return { host, port, fingerprint: _fingerprint(raw), raw };
864
+ },
865
+
866
+ /**
867
+ * Change the passphrase on an existing private key.
868
+ * @param {string} privateKeyPath
869
+ * @param {string} oldPassphrase
870
+ * @param {string} newPassphrase
871
+ * @returns {{ status }}
872
+ */
873
+ changePassphrase(privateKeyPath, oldPassphrase, newPassphrase) {
874
+ const expanded = privateKeyPath.replace(/^~/, os.homedir());
875
+ execSync(`ssh-keygen -p -P "${oldPassphrase}" -N "${newPassphrase}" -f "${expanded}"`, { stdio: "pipe" });
876
+ return { status: "PASSPHRASE CHANGED", path: expanded };
877
+ },
878
+
879
+ /**
880
+ * Get the fingerprint of a local key file.
881
+ * @param {string} keyPath - Path to public or private key.
882
+ * @returns {{ fingerprint, type }}
883
+ */
884
+ getKeyFingerprint(keyPath) {
885
+ const expanded = keyPath.replace(/^~/, os.homedir());
886
+ const result = execSync(`ssh-keygen -lf "${expanded}"`, { encoding: "utf8" }).trim();
887
+ const [bits, fingerprint, ...rest] = result.split(" ");
888
+ return { bits: parseInt(bits), fingerprint, comment: rest.slice(0, -1).join(" "), type: rest[rest.length - 1] };
889
+ },
890
+
891
+ // ──────────────────────────────────────────────────────────────────────
892
+ // SSH AGENT
893
+ // ──────────────────────────────────────────────────────────────────────
894
+
895
+ /**
896
+ * Add a key to the local SSH agent.
897
+ * @param {string} keyPath
898
+ * @param {string} [passphrase]
899
+ * @returns {{ status }}
900
+ */
901
+ agentAddKey(keyPath, passphrase) {
902
+ const expanded = keyPath.replace(/^~/, os.homedir());
903
+ if (passphrase) {
904
+ execSync(`ssh-add "${expanded}"`, {
905
+ env: { ...process.env, SSH_ASKPASS_REQUIRE: "never" },
906
+ input: passphrase,
907
+ stdio: ["pipe", "pipe", "pipe"],
908
+ });
909
+ } else {
910
+ execSync(`ssh-add "${expanded}"`, { stdio: "pipe" });
911
+ }
912
+ return { status: "KEY ADDED TO AGENT", path: expanded };
913
+ },
914
+
915
+ /**
916
+ * List all keys currently loaded in the SSH agent.
917
+ * @returns {string[]}
918
+ */
919
+ agentListKeys() {
920
+ try {
921
+ const result = execSync("ssh-add -l", { encoding: "utf8" }).trim();
922
+ return result.split("\n").filter(Boolean);
923
+ } catch {
924
+ return [];
925
+ }
926
+ },
927
+
928
+ /**
929
+ * Remove a key from the SSH agent.
930
+ * @param {string} keyPath
931
+ * @returns {{ status }}
932
+ */
933
+ agentRemoveKey(keyPath) {
934
+ const expanded = keyPath.replace(/^~/, os.homedir());
935
+ execSync(`ssh-add -d "${expanded}"`, { stdio: "pipe" });
936
+ return { status: "KEY REMOVED FROM AGENT", path: expanded };
937
+ },
938
+
939
+ /**
940
+ * Remove all keys from the SSH agent.
941
+ * @returns {{ status }}
942
+ */
943
+ agentClearKeys() {
944
+ execSync("ssh-add -D", { stdio: "pipe" });
945
+ return { status: "ALL KEYS REMOVED FROM AGENT" };
946
+ },
947
+
948
+ // ──────────────────────────────────────────────────────────────────────
949
+ // CONNECTION POOLING / MULTIPLEXING
950
+ // ──────────────────────────────────────────────────────────────────────
951
+
952
+ /**
953
+ * Get or create a pooled connection.
954
+ * Reuses an existing alive connection to the same host/user/port.
955
+ * @param {object} config - Same as connect().
956
+ * @returns {Promise<{ id, reused: boolean }>}
957
+ */
958
+ async pooledConnect(config) {
959
+ const key = _poolKey(config);
960
+ const existing = (_pool.get(key) || []).find(id => _connections.has(id));
961
+ if (existing) return { id: existing, reused: true };
962
+
963
+ const { id } = await this.connect(config);
964
+ const poolList = _pool.get(key) || [];
965
+ poolList.push(id);
966
+ _pool.set(key, poolList);
967
+ return { id, reused: false };
968
+ },
969
+
970
+ /**
971
+ * Release a pooled connection (but keep it open for reuse).
972
+ * @param {string} id
973
+ * @returns {{ id, status }}
974
+ */
975
+ poolRelease(id) {
976
+ return { id, status: "RETURNED TO POOL" };
977
+ },
978
+
979
+ /**
980
+ * Drain the pool — close all pooled connections for a host.
981
+ * @param {string} host
982
+ * @param {string} username
983
+ * @param {number} [port=22]
984
+ * @returns {{ drained: number }}
985
+ */
986
+ poolDrain(host, username, port = 22) {
987
+ const key = `${username}@${host}:${port}`;
988
+ const ids = _pool.get(key) || [];
989
+ ids.forEach(id => this.disconnect(id));
990
+ _pool.delete(key);
991
+ return { drained: ids.length };
992
+ },
993
+
994
+ // ──────────────────────────────────────────────────────────────────────
995
+ // JUMP HOSTS / PROXY
996
+ // ──────────────────────────────────────────────────────────────────────
997
+
998
+ /**
999
+ * Connect to a target host via one or more jump hosts (ProxyJump chain).
1000
+ * @param {object[]} hops - Array of connection configs, last entry is the target.
1001
+ * @returns {Promise<{ id: string, hops: number }>}
1002
+ *
1003
+ * @example
1004
+ * const { id } = await kitSSH.jumpConnect([
1005
+ * { host: "bastion.example.com", username: "admin", privateKey: "~/.ssh/id_ed25519" },
1006
+ * { host: "internal.db.local", username: "postgres", privateKey: "~/.ssh/id_ed25519" },
1007
+ * ]);
1008
+ */
1009
+ async jumpConnect(hops) {
1010
+ if (hops.length === 0) throw new Error("At least one hop required.");
1011
+ let prevId = null;
1012
+ for (let i = 0; i < hops.length; i++) {
1013
+ const config = { ...hops[i] };
1014
+ if (prevId) config.jumpHost = prevId;
1015
+ const { id } = await this.connect(config);
1016
+ prevId = id;
1017
+ }
1018
+ return { id: prevId, hops: hops.length };
1019
+ },
1020
+
1021
+ // ──────────────────────────────────────────────────────────────────────
1022
+ // CONFIG FILE
1023
+ // ──────────────────────────────────────────────────────────────────────
1024
+
1025
+ /**
1026
+ * Parse the local ~/.ssh/config file and return entries as objects.
1027
+ * @returns {object[]}
1028
+ */
1029
+ parseConfig() {
1030
+ const configPath = path.join(_sshDir(), "config");
1031
+ if (!fs.existsSync(configPath)) return [];
1032
+ const text = fs.readFileSync(configPath, "utf8");
1033
+ const entries = [];
1034
+ let current = null;
1035
+ for (const line of text.split("\n")) {
1036
+ const trimmed = line.trim();
1037
+ if (!trimmed || trimmed.startsWith("#")) continue;
1038
+ const [key, ...rest] = trimmed.split(/\s+/);
1039
+ const value = rest.join(" ");
1040
+ if (key.toLowerCase() === "host") {
1041
+ if (current) entries.push(current);
1042
+ current = { Host: value };
1043
+ } else if (current) {
1044
+ current[key] = value;
1045
+ }
1046
+ }
1047
+ if (current) entries.push(current);
1048
+ return entries;
1049
+ },
1050
+
1051
+ /**
1052
+ * Add a host entry to ~/.ssh/config.
1053
+ * @param {object} entry - e.g. { Host: "myserver", HostName: "1.2.3.4", User: "admin", IdentityFile: "~/.ssh/id_ed25519" }
1054
+ * @returns {{ status, configPath }}
1055
+ */
1056
+ addConfigEntry(entry) {
1057
+ const configPath = path.join(_sshDir(), "config");
1058
+ fs.mkdirSync(_sshDir(), { recursive: true });
1059
+ const lines = ["", `Host ${entry.Host}`];
1060
+ for (const [k, v] of Object.entries(entry)) {
1061
+ if (k !== "Host") lines.push(` ${k} ${v}`);
1062
+ }
1063
+ fs.appendFileSync(configPath, lines.join("\n") + "\n");
1064
+ return { status: "ENTRY ADDED", configPath };
1065
+ },
1066
+
1067
+ /**
1068
+ * Remove a host entry from ~/.ssh/config by Host pattern.
1069
+ * @param {string} hostPattern
1070
+ * @returns {{ status }}
1071
+ */
1072
+ removeConfigEntry(hostPattern) {
1073
+ const configPath = path.join(_sshDir(), "config");
1074
+ if (!fs.existsSync(configPath)) return { status: "CONFIG NOT FOUND" };
1075
+ const text = fs.readFileSync(configPath, "utf8");
1076
+ const blocks = text.split(/(?=^Host )/m);
1077
+ const filtered = blocks.filter(b => !b.startsWith(`Host ${hostPattern}`));
1078
+ fs.writeFileSync(configPath, filtered.join(""));
1079
+ return { status: "ENTRY REMOVED", hostPattern };
1080
+ },
1081
+
1082
+ // ──────────────────────────────────────────────────────────────────────
1083
+ // DIAGNOSTICS & UTILITIES
1084
+ // ──────────────────────────────────────────────────────────────────────
1085
+
1086
+ /**
1087
+ * Check if a remote port is reachable from the SSH server.
1088
+ * @param {string} id
1089
+ * @param {string} host
1090
+ * @param {number} port
1091
+ * @param {number} [timeoutSeconds=5]
1092
+ * @returns {Promise<{ reachable: boolean, host, port }>}
1093
+ */
1094
+ async checkPort(id, host, port, timeoutSeconds = 5) {
1095
+ const { stdout } = await this.exec(
1096
+ id,
1097
+ `(echo >/dev/tcp/${host}/${port}) &>/dev/null && echo "open" || echo "closed"`,
1098
+ );
1099
+ return { reachable: stdout.trim() === "open", host, port };
1100
+ },
1101
+
1102
+ /**
1103
+ * Get system information from the remote host.
1104
+ * @param {string} id
1105
+ * @returns {Promise<object>}
1106
+ */
1107
+ async systemInfo(id) {
1108
+ const [os_, hostname, uptime, mem, disk, cpu] = await Promise.all([
1109
+ this.exec(id, "uname -a"),
1110
+ this.exec(id, "hostname"),
1111
+ this.exec(id, "uptime -p 2>/dev/null || uptime"),
1112
+ this.exec(id, "free -h 2>/dev/null || vm_stat"),
1113
+ this.exec(id, "df -h /"),
1114
+ this.exec(id, "nproc 2>/dev/null || sysctl -n hw.ncpu"),
1115
+ ]);
1116
+ return {
1117
+ os: os_.stdout.trim(),
1118
+ hostname: hostname.stdout.trim(),
1119
+ uptime: uptime.stdout.trim(),
1120
+ memory: mem.stdout.trim(),
1121
+ disk: disk.stdout.trim(),
1122
+ cpuCores: cpu.stdout.trim(),
1123
+ };
1124
+ },
1125
+
1126
+ /**
1127
+ * Measure round-trip latency to the SSH server.
1128
+ * @param {string} id
1129
+ * @param {number} [samples=5]
1130
+ * @returns {Promise<{ avgMs: number, minMs: number, maxMs: number, samples: number[] }>}
1131
+ */
1132
+ async ping(id, samples = 5) {
1133
+ const times = [];
1134
+ for (let i = 0; i < samples; i++) {
1135
+ const start = Date.now();
1136
+ await this.exec(id, "echo 1");
1137
+ times.push(Date.now() - start);
1138
+ }
1139
+ return {
1140
+ avgMs: Math.round(times.reduce((a, b) => a + b, 0) / times.length),
1141
+ minMs: Math.min(...times),
1142
+ maxMs: Math.max(...times),
1143
+ samples: times,
1144
+ };
1145
+ },
1146
+
1147
+ /**
1148
+ * Test connectivity to a host without establishing a full session.
1149
+ * @param {string} host
1150
+ * @param {number} [port=22]
1151
+ * @param {number} [timeoutMs=5000]
1152
+ * @returns {Promise<{ reachable: boolean, latencyMs?: number }>}
1153
+ */
1154
+ testConnectivity(host, port = 22, timeoutMs = 5000) {
1155
+ return new Promise(resolve => {
1156
+ const start = Date.now();
1157
+ const sock = net.connect(port, host);
1158
+ sock.setTimeout(timeoutMs);
1159
+ sock.on("connect", () => {
1160
+ const latencyMs = Date.now() - start;
1161
+ sock.destroy();
1162
+ resolve({ reachable: true, latencyMs });
1163
+ });
1164
+ sock.on("error", () => resolve({ reachable: false }));
1165
+ sock.on("timeout", () => { sock.destroy(); resolve({ reachable: false }); });
1166
+ });
1167
+ },
1168
+
1169
+ /**
1170
+ * Get the remote host's SSH server version banner.
1171
+ * @param {string} host
1172
+ * @param {number} [port=22]
1173
+ * @returns {Promise<{ banner: string }>}
1174
+ */
1175
+ getServerBanner(host, port = 22) {
1176
+ return new Promise((resolve, reject) => {
1177
+ const sock = net.connect(port, host);
1178
+ let banner = "";
1179
+ sock.on("data", data => {
1180
+ banner += data.toString();
1181
+ if (banner.includes("\n")) {
1182
+ sock.destroy();
1183
+ resolve({ host, port, banner: banner.split("\n")[0].trim() });
1184
+ }
1185
+ });
1186
+ sock.on("error", reject);
1187
+ setTimeout(() => { sock.destroy(); reject(new Error("Banner timeout")); }, 5000);
1188
+ });
1189
+ },
1190
+
1191
+ /**
1192
+ * Copy a file between two remote hosts (remote-to-remote via local bounce).
1193
+ * @param {string} srcId - Source connection ID.
1194
+ * @param {string} srcPath
1195
+ * @param {string} dstId - Destination connection ID.
1196
+ * @param {string} dstPath
1197
+ * @returns {Promise<{ status }>}
1198
+ */
1199
+ async remoteCopy(srcId, srcPath, dstId, dstPath) {
1200
+ const tmpPath = path.join(os.tmpdir(), `kitSSH_bounce_${Date.now()}`);
1201
+ await this.download(srcId, srcPath, tmpPath);
1202
+ await this.upload(dstId, tmpPath, dstPath);
1203
+ fs.unlinkSync(tmpPath);
1204
+ return { status: "COPIED", from: `${srcId}:${srcPath}`, to: `${dstId}:${dstPath}` };
1205
+ },
1206
+
1207
+ /**
1208
+ * Watch a remote file for changes and call a callback when it changes.
1209
+ * Uses polling.
1210
+ * @param {string} id
1211
+ * @param {string} remotePath
1212
+ * @param {function} onChange - Called with new content string.
1213
+ * @param {number} [intervalMs=2000]
1214
+ * @returns {{ stop: function }} - Call stop() to end watching.
1215
+ */
1216
+ watchFile(id, remotePath, onChange, intervalMs = 2000) {
1217
+ let last = null;
1218
+ const timer = setInterval(async () => {
1219
+ try {
1220
+ const content = await this.readFile(id, remotePath);
1221
+ if (content !== last) { last = content; onChange(content); }
1222
+ } catch { /* file may not exist yet */ }
1223
+ }, intervalMs);
1224
+ return { stop: () => clearInterval(timer) };
1225
+ },
1226
+
1227
+ /**
1228
+ * Tail a remote log file, streaming new lines to a callback.
1229
+ * @param {string} id
1230
+ * @param {string} remotePath
1231
+ * @param {function} onLine
1232
+ * @param {number} [lines=10] - Initial lines to show.
1233
+ * @returns {Promise<{ stop: function }>}
1234
+ */
1235
+ async tailFile(id, remotePath, onLine, lines = 10) {
1236
+ const stream = await this.shell(id);
1237
+ stream.write(`tail -n ${lines} -f ${remotePath}\n`);
1238
+ let buf = "";
1239
+ stream.on("data", data => {
1240
+ buf += data.toString();
1241
+ const parts = buf.split("\n");
1242
+ buf = parts.pop();
1243
+ parts.forEach(line => onLine(line));
1244
+ });
1245
+ return { stop: () => stream.end("exit\n") };
1246
+ },
1247
+
1248
+ }
1249
+ };
1250
+
1251
+ // ── Internal helpers (outside kitdef for cleanliness) ────────────────────────
1252
+
1253
+ function _finishConnect(client, sshConfig, id, originalConfig, resolve, reject) {
1254
+ client.on("ready", () => {
1255
+ _connections.set(id, {
1256
+ client,
1257
+ config: originalConfig,
1258
+ tunnels: [],
1259
+ sftp: null,
1260
+ openedAt: new Date().toISOString(),
1261
+ });
1262
+ resolve({
1263
+ id,
1264
+ host: originalConfig.host,
1265
+ port: originalConfig.port || 22,
1266
+ username: originalConfig.username,
1267
+ status: "CONNECTED",
1268
+ });
1269
+ });
1270
+ client.on("error", reject);
1271
+ client.connect(sshConfig);
1272
+ }