notoken-core 1.5.1 → 2.0.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 (99) hide show
  1. package/config/chat-responses.json +767 -0
  2. package/config/concept-clusters.json +31 -0
  3. package/config/entities.json +93 -0
  4. package/config/image-prompts.json +20 -0
  5. package/config/intent-vectors.json +1 -0
  6. package/config/intents.json +5023 -65
  7. package/config/ollama-models.json +193 -0
  8. package/config/rules.json +32 -1
  9. package/dist/automation/discordPatchright.d.ts +35 -0
  10. package/dist/automation/discordPatchright.js +424 -0
  11. package/dist/automation/discordSetup.d.ts +31 -0
  12. package/dist/automation/discordSetup.js +338 -0
  13. package/dist/conversation/coreference.js +44 -4
  14. package/dist/conversation/pendingActions.d.ts +55 -0
  15. package/dist/conversation/pendingActions.js +127 -0
  16. package/dist/conversation/store.d.ts +72 -0
  17. package/dist/conversation/store.js +140 -1
  18. package/dist/conversation/topicTracker.d.ts +36 -0
  19. package/dist/conversation/topicTracker.js +141 -0
  20. package/dist/execution/ssh.d.ts +42 -1
  21. package/dist/execution/ssh.js +532 -3
  22. package/dist/handlers/executor.js +3981 -16
  23. package/dist/index.d.ts +25 -3
  24. package/dist/index.js +36 -2
  25. package/dist/nlp/batchParser.d.ts +30 -0
  26. package/dist/nlp/batchParser.js +77 -0
  27. package/dist/nlp/conceptExpansion.d.ts +54 -0
  28. package/dist/nlp/conceptExpansion.js +136 -0
  29. package/dist/nlp/conceptRouter.d.ts +49 -0
  30. package/dist/nlp/conceptRouter.js +302 -0
  31. package/dist/nlp/confidenceCalibrator.d.ts +62 -0
  32. package/dist/nlp/confidenceCalibrator.js +116 -0
  33. package/dist/nlp/correctionLearner.d.ts +45 -0
  34. package/dist/nlp/correctionLearner.js +207 -0
  35. package/dist/nlp/entitySpellCorrect.d.ts +35 -0
  36. package/dist/nlp/entitySpellCorrect.js +141 -0
  37. package/dist/nlp/knowledgeGraph.d.ts +70 -0
  38. package/dist/nlp/knowledgeGraph.js +380 -0
  39. package/dist/nlp/llmFallback.js +28 -1
  40. package/dist/nlp/multiClassifier.js +91 -6
  41. package/dist/nlp/multiIntent.d.ts +43 -0
  42. package/dist/nlp/multiIntent.js +154 -0
  43. package/dist/nlp/parseIntent.d.ts +6 -1
  44. package/dist/nlp/parseIntent.js +180 -5
  45. package/dist/nlp/ruleParser.js +315 -0
  46. package/dist/nlp/semanticSimilarity.d.ts +30 -0
  47. package/dist/nlp/semanticSimilarity.js +174 -0
  48. package/dist/nlp/vocabularyBuilder.d.ts +43 -0
  49. package/dist/nlp/vocabularyBuilder.js +224 -0
  50. package/dist/nlp/wikidata.d.ts +49 -0
  51. package/dist/nlp/wikidata.js +228 -0
  52. package/dist/policy/confirm.d.ts +10 -0
  53. package/dist/policy/confirm.js +39 -0
  54. package/dist/policy/safety.js +6 -4
  55. package/dist/utils/aliases.d.ts +5 -0
  56. package/dist/utils/aliases.js +39 -0
  57. package/dist/utils/analysis.js +71 -15
  58. package/dist/utils/browser.d.ts +64 -0
  59. package/dist/utils/browser.js +364 -0
  60. package/dist/utils/commandHistory.d.ts +20 -0
  61. package/dist/utils/commandHistory.js +108 -0
  62. package/dist/utils/completer.d.ts +17 -0
  63. package/dist/utils/completer.js +79 -0
  64. package/dist/utils/config.js +32 -2
  65. package/dist/utils/dbQuery.d.ts +25 -0
  66. package/dist/utils/dbQuery.js +248 -0
  67. package/dist/utils/discordDiag.d.ts +35 -0
  68. package/dist/utils/discordDiag.js +826 -0
  69. package/dist/utils/diskCleanup.d.ts +36 -0
  70. package/dist/utils/diskCleanup.js +775 -0
  71. package/dist/utils/entityResolver.d.ts +107 -0
  72. package/dist/utils/entityResolver.js +468 -0
  73. package/dist/utils/imageGen.d.ts +92 -0
  74. package/dist/utils/imageGen.js +2031 -0
  75. package/dist/utils/installTracker.d.ts +57 -0
  76. package/dist/utils/installTracker.js +160 -0
  77. package/dist/utils/multiExec.d.ts +21 -0
  78. package/dist/utils/multiExec.js +141 -0
  79. package/dist/utils/openclawDiag.d.ts +29 -0
  80. package/dist/utils/openclawDiag.js +1035 -0
  81. package/dist/utils/output.js +4 -0
  82. package/dist/utils/platform.js +2 -1
  83. package/dist/utils/progressReporter.d.ts +50 -0
  84. package/dist/utils/progressReporter.js +58 -0
  85. package/dist/utils/projectDetect.d.ts +44 -0
  86. package/dist/utils/projectDetect.js +319 -0
  87. package/dist/utils/projectScanner.d.ts +44 -0
  88. package/dist/utils/projectScanner.js +312 -0
  89. package/dist/utils/shellCompat.d.ts +78 -0
  90. package/dist/utils/shellCompat.js +186 -0
  91. package/dist/utils/smartArchive.d.ts +16 -0
  92. package/dist/utils/smartArchive.js +172 -0
  93. package/dist/utils/smartRetry.d.ts +26 -0
  94. package/dist/utils/smartRetry.js +114 -0
  95. package/dist/utils/updater.d.ts +1 -0
  96. package/dist/utils/updater.js +1 -1
  97. package/dist/utils/version.d.ts +20 -0
  98. package/dist/utils/version.js +212 -0
  99. package/package.json +6 -3
@@ -1,17 +1,546 @@
1
+ /**
2
+ * SSH / local / Docker execution layer.
3
+ *
4
+ * Uses the `ssh2` npm package for all remote connections:
5
+ * - Password auth (no sshpass/expect/plink needed)
6
+ * - Key-based auth (reads key file directly)
7
+ * - SSH agent forwarding
8
+ * - Reads ~/.ssh/config for host aliases, keys, ports
9
+ *
10
+ * Falls back to system `ssh` binary only if ssh2 fails unexpectedly.
11
+ */
12
+ import { Client } from "ssh2";
1
13
  import { exec } from "node:child_process";
2
14
  import { promisify } from "node:util";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { homedir } from "node:os";
3
18
  import { loadHosts } from "../utils/config.js";
4
19
  const execAsync = promisify(exec);
20
+ /**
21
+ * Read credentials from a file.
22
+ * Supports formats:
23
+ * - Line 1: username, Line 2: password
24
+ * - username:password (single line)
25
+ * - KEY=VALUE format (USERNAME=x, PASSWORD=y)
26
+ */
27
+ function readCredentialsFile(filePath) {
28
+ if (!existsSync(filePath))
29
+ return {};
30
+ try {
31
+ const content = readFileSync(filePath, "utf-8").trim();
32
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
33
+ // KEY=VALUE format
34
+ const kvUser = lines.find((l) => /^(USER|USERNAME)=/i.test(l));
35
+ const kvPass = lines.find((l) => /^(PASS|PASSWORD)=/i.test(l));
36
+ if (kvUser || kvPass) {
37
+ return {
38
+ username: kvUser?.split("=").slice(1).join("="),
39
+ password: kvPass?.split("=").slice(1).join("="),
40
+ };
41
+ }
42
+ // username:password (single line)
43
+ if (lines.length === 1 && lines[0].includes(":")) {
44
+ const [username, ...rest] = lines[0].split(":");
45
+ return { username, password: rest.join(":") };
46
+ }
47
+ // Line 1 = username, line 2 = password
48
+ return {
49
+ username: lines[0] || undefined,
50
+ password: lines[1] || undefined,
51
+ };
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ function parseSshConfig(alias) {
58
+ const configPath = resolve(homedir(), ".ssh", "config");
59
+ if (!existsSync(configPath))
60
+ return null;
61
+ try {
62
+ const content = readFileSync(configPath, "utf-8");
63
+ const lines = content.split("\n");
64
+ let current = null;
65
+ let matched = false;
66
+ for (const line of lines) {
67
+ const trimmed = line.trim();
68
+ if (!trimmed || trimmed.startsWith("#"))
69
+ continue;
70
+ const hostMatch = trimmed.match(/^Host\s+(.+)$/i);
71
+ if (hostMatch) {
72
+ if (matched && current)
73
+ return current;
74
+ const patterns = hostMatch[1].split(/\s+/);
75
+ matched = patterns.some((p) => {
76
+ if (p === "*")
77
+ return false;
78
+ if (p.includes("*")) {
79
+ const regex = new RegExp("^" + p.replace(/\*/g, ".*") + "$");
80
+ return regex.test(alias);
81
+ }
82
+ return p === alias;
83
+ });
84
+ current = matched ? {} : null;
85
+ continue;
86
+ }
87
+ if (matched && current) {
88
+ const kv = trimmed.match(/^(\w+)\s+(.+)$/);
89
+ if (kv) {
90
+ const key = kv[1].toLowerCase();
91
+ if (key === "hostname")
92
+ current.hostname = kv[2];
93
+ else if (key === "user")
94
+ current.user = kv[2];
95
+ else if (key === "port")
96
+ current.port = kv[2];
97
+ else if (key === "identityfile")
98
+ current.identityFile = kv[2].replace("~", homedir());
99
+ }
100
+ }
101
+ }
102
+ return matched ? current : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ // ─── ssh2 connection ─────────────────────────────────────────────────────────
109
+ function resolveHostConfig(entry) {
110
+ // Read credentials file if specified
111
+ const fileCreds = entry.credentialsFile ? readCredentialsFile(entry.credentialsFile) : {};
112
+ const rawHost = hostPart(entry.host);
113
+ const rawUser = fileCreds.username ?? (entry.host.includes("@") ? entry.host.split("@")[0] : "root");
114
+ const sshConfig = parseSshConfig(rawHost);
115
+ const hostname = sshConfig?.hostname ?? rawHost;
116
+ const username = sshConfig?.user ?? rawUser;
117
+ const port = entry.port ?? (sshConfig?.port ? parseInt(sshConfig.port) : 22);
118
+ const password = entry.password || fileCreds.password;
119
+ // Key: explicit > ssh config > default keys
120
+ const keyPath = entry.key || sshConfig?.identityFile;
121
+ let privateKey;
122
+ if (keyPath && existsSync(keyPath)) {
123
+ privateKey = readFileSync(keyPath);
124
+ }
125
+ else if (!password) {
126
+ // Try default key locations
127
+ const defaultKeys = [
128
+ resolve(homedir(), ".ssh", "id_ed25519"),
129
+ resolve(homedir(), ".ssh", "id_rsa"),
130
+ resolve(homedir(), ".ssh", "id_ecdsa"),
131
+ ];
132
+ for (const k of defaultKeys) {
133
+ if (existsSync(k)) {
134
+ privateKey = readFileSync(k);
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ return {
140
+ hostname,
141
+ username,
142
+ port,
143
+ privateKey,
144
+ password: password || undefined,
145
+ // Use SSH agent if no key/password
146
+ agent: (!privateKey && !password) ? process.env.SSH_AUTH_SOCK : undefined,
147
+ };
148
+ }
149
+ /**
150
+ * Execute a command on a remote host via ssh2.
151
+ * Returns combined stdout+stderr.
152
+ */
153
+ function execSsh2(entry, command, timeout = 30_000) {
154
+ return new Promise((resolve, reject) => {
155
+ const config = resolveHostConfig(entry);
156
+ const conn = new Client();
157
+ let stdout = "";
158
+ let stderr = "";
159
+ let timedOut = false;
160
+ const timer = setTimeout(() => {
161
+ timedOut = true;
162
+ conn.end();
163
+ reject(new Error(`SSH command timed out after ${timeout / 1000}s`));
164
+ }, timeout);
165
+ conn.on("ready", () => {
166
+ conn.exec(command, (err, stream) => {
167
+ if (err) {
168
+ clearTimeout(timer);
169
+ conn.end();
170
+ reject(err);
171
+ return;
172
+ }
173
+ stream.on("data", (data) => { stdout += data.toString(); });
174
+ stream.stderr.on("data", (data) => { stderr += data.toString(); });
175
+ stream.on("close", (code) => {
176
+ clearTimeout(timer);
177
+ conn.end();
178
+ if (code !== 0 && !stdout && stderr) {
179
+ reject(new Error(stderr.trim()));
180
+ }
181
+ else {
182
+ resolve(stderr ? `${stdout}${stderr}` : stdout);
183
+ }
184
+ });
185
+ });
186
+ });
187
+ conn.on("error", (err) => {
188
+ clearTimeout(timer);
189
+ if (timedOut)
190
+ return;
191
+ reject(enhanceError(err, entry));
192
+ });
193
+ conn.connect({
194
+ host: config.hostname,
195
+ port: config.port,
196
+ username: config.username,
197
+ privateKey: config.privateKey,
198
+ password: config.password,
199
+ agent: config.agent,
200
+ readyTimeout: 10_000,
201
+ // Try all auth methods
202
+ authHandler: buildAuthHandler(config),
203
+ });
204
+ });
205
+ }
206
+ /** Build auth handler that tries methods in order. */
207
+ function buildAuthHandler(config) {
208
+ const methods = [];
209
+ if (config.privateKey) {
210
+ methods.push({ type: "publickey", username: config.username, key: config.privateKey });
211
+ }
212
+ if (config.agent) {
213
+ methods.push({ type: "agent", username: config.username });
214
+ }
215
+ if (config.password) {
216
+ methods.push({ type: "password", username: config.username, password: config.password });
217
+ }
218
+ // If no methods configured, try agent then keyboard-interactive
219
+ if (methods.length === 0) {
220
+ methods.push({ type: "agent", username: config.username });
221
+ }
222
+ let idx = 0;
223
+ return (methodsLeft, _partialSuccess, callback) => {
224
+ if (idx >= methods.length) {
225
+ return callback(false); // no more methods
226
+ }
227
+ const method = methods[idx++];
228
+ callback(method);
229
+ };
230
+ }
231
+ /** Enhance SSH errors with helpful context. */
232
+ function enhanceError(err, entry) {
233
+ const msg = err.message;
234
+ if (msg.includes("Authentication failed") || msg.includes("All configured authentication methods failed")) {
235
+ const hints = [`SSH auth failed for ${entry.host}.`];
236
+ if (entry.key)
237
+ hints.push(` - Key: ${entry.key} — check file exists and permissions`);
238
+ if (entry.password)
239
+ hints.push(` - Password — check password in hosts.json`);
240
+ if (!entry.key && !entry.password)
241
+ hints.push(` - No key or password configured — check SSH agent or add to hosts.json`);
242
+ hints.push(`\nTo configure: edit config/hosts.json and set "key" or "password" for this host.`);
243
+ return new Error(hints.join("\n"));
244
+ }
245
+ if (msg.includes("ECONNREFUSED")) {
246
+ return new Error(`Connection refused by ${hostPart(entry.host)}:${entry.port ?? 22}.\n` +
247
+ ` - Is the SSH server running?\n` +
248
+ ` - Is port ${entry.port ?? 22} open?\n` +
249
+ ` - Check firewall rules.`);
250
+ }
251
+ if (msg.includes("ETIMEDOUT") || msg.includes("ENOTFOUND") || msg.includes("getaddrinfo")) {
252
+ return new Error(`Cannot reach ${hostPart(entry.host)}.\n` +
253
+ ` - Check the hostname/IP is correct\n` +
254
+ ` - Check network connectivity\n` +
255
+ ` - Try: ping ${hostPart(entry.host)}`);
256
+ }
257
+ return err;
258
+ }
259
+ // ─── Public API ──────────────────────────────────────────────────────────────
5
260
  export async function runRemoteCommand(environment, command) {
6
261
  const hosts = loadHosts();
7
262
  const entry = hosts[environment];
8
263
  if (!entry) {
9
264
  throw new Error(`No host configured for environment: ${environment}`);
10
265
  }
11
- const { stdout, stderr } = await execAsync(`ssh ${entry.host} ${JSON.stringify(command)}`, { timeout: 30_000 });
266
+ return execSsh2(entry, command);
267
+ }
268
+ export async function runLocalCommand(command, timeout = 30_000) {
269
+ const shell = process.platform === "win32" ? "bash" : undefined;
270
+ const { stdout, stderr } = await execAsync(command, { timeout, shell });
12
271
  return stderr ? `${stdout}\n${stderr}` : stdout;
13
272
  }
14
- export async function runLocalCommand(command) {
15
- const { stdout, stderr } = await execAsync(command, { timeout: 30_000 });
273
+ /**
274
+ * Run a command inside a Docker container.
275
+ */
276
+ export async function runDockerExec(container, command) {
277
+ const { stdout, stderr } = await execAsync(`docker exec ${container} sh -c ${JSON.stringify(command)}`, { timeout: 30_000 });
16
278
  return stderr ? `${stdout}\n${stderr}` : stdout;
17
279
  }
280
+ /**
281
+ * Test SSH connectivity to a host. Returns formatted status.
282
+ */
283
+ export async function testSshConnection(environment) {
284
+ const hosts = loadHosts();
285
+ const entry = hosts[environment];
286
+ if (!entry) {
287
+ return `No host configured for: ${environment}`;
288
+ }
289
+ const cc = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", red: "\x1b[31m", cyan: "\x1b[36m", yellow: "\x1b[33m" };
290
+ const config = resolveHostConfig(entry);
291
+ const lines = [];
292
+ lines.push(`\n${cc.bold}${cc.cyan}── SSH Connection Test: ${environment} ──${cc.reset}\n`);
293
+ lines.push(` Host: ${cc.bold}${config.hostname}${cc.reset}`);
294
+ lines.push(` User: ${config.username}`);
295
+ lines.push(` Port: ${config.port}`);
296
+ // Show auth method
297
+ if (config.privateKey) {
298
+ const keySource = entry.key || "(auto-detected)";
299
+ lines.push(` Auth: ${cc.green}key${cc.reset} ${cc.dim}${keySource}${cc.reset}`);
300
+ }
301
+ else if (config.password) {
302
+ const source = entry.credentialsFile ? `from ${entry.credentialsFile}` : "from hosts.json";
303
+ lines.push(` Auth: ${cc.green}password${cc.reset} ${cc.dim}(${source}, via ssh2)${cc.reset}`);
304
+ }
305
+ else if (config.agent) {
306
+ lines.push(` Auth: ${cc.green}SSH agent${cc.reset}`);
307
+ }
308
+ else {
309
+ lines.push(` Auth: ${cc.yellow}none configured${cc.reset}`);
310
+ }
311
+ // Check SSH config
312
+ const sshConf = parseSshConfig(hostPart(entry.host));
313
+ if (sshConf) {
314
+ lines.push(` SSH config: ${cc.green}✓${cc.reset} found in ~/.ssh/config`);
315
+ if (sshConf.hostname)
316
+ lines.push(` HostName: ${sshConf.hostname}`);
317
+ if (sshConf.identityFile)
318
+ lines.push(` IdentityFile: ${sshConf.identityFile}`);
319
+ }
320
+ try {
321
+ const result = await execSsh2(entry, "echo OK && hostname && uname -a", 15_000);
322
+ if (result.includes("OK")) {
323
+ const resultLines = result.split("\n");
324
+ const hostname = resultLines[1]?.trim() ?? "unknown";
325
+ const uname = resultLines[2]?.trim() ?? "";
326
+ lines.push(`\n ${cc.green}${cc.bold}✓ Connected${cc.reset}`);
327
+ lines.push(` Hostname: ${cc.bold}${hostname}${cc.reset}`);
328
+ if (uname)
329
+ lines.push(` System: ${cc.dim}${uname}${cc.reset}`);
330
+ }
331
+ else {
332
+ lines.push(`\n ${cc.yellow}⚠ Connected but unexpected response${cc.reset}`);
333
+ }
334
+ }
335
+ catch (err) {
336
+ const msg = err instanceof Error ? err.message : String(err);
337
+ lines.push(`\n ${cc.red}${cc.bold}✗ Connection failed${cc.reset}`);
338
+ lines.push(` ${cc.red}${msg}${cc.reset}`);
339
+ }
340
+ return lines.join("\n");
341
+ }
342
+ // ─── SFTP file transfer ──────────────────────────────────────────────────────
343
+ import { createReadStream, createWriteStream, readdirSync, statSync, mkdirSync } from "node:fs";
344
+ import { basename, join, dirname, posix } from "node:path";
345
+ /**
346
+ * Transfer files to/from a remote host via SFTP.
347
+ * Supports single files and recursive directories.
348
+ */
349
+ export async function sftpTransfer(environment, localPath, remotePath, direction, onProgress) {
350
+ const hosts = loadHosts();
351
+ const entry = hosts[environment];
352
+ if (!entry)
353
+ throw new Error(`No host configured for environment: ${environment}`);
354
+ const config = resolveHostConfig(entry);
355
+ const conn = new Client();
356
+ return new Promise((resolve, reject) => {
357
+ conn.on("ready", () => {
358
+ conn.sftp(async (err, sftp) => {
359
+ if (err) {
360
+ conn.end();
361
+ reject(err);
362
+ return;
363
+ }
364
+ try {
365
+ if (direction === "upload") {
366
+ const result = await uploadPath(sftp, localPath, remotePath, onProgress);
367
+ conn.end();
368
+ resolve(result);
369
+ }
370
+ else {
371
+ const result = await downloadPath(sftp, remotePath, localPath, onProgress);
372
+ conn.end();
373
+ resolve(result);
374
+ }
375
+ }
376
+ catch (e) {
377
+ conn.end();
378
+ reject(e);
379
+ }
380
+ });
381
+ });
382
+ conn.on("error", (err) => reject(enhanceError(err, entry)));
383
+ conn.connect({
384
+ host: config.hostname,
385
+ port: config.port,
386
+ username: config.username,
387
+ privateKey: config.privateKey,
388
+ password: config.password,
389
+ agent: config.agent,
390
+ readyTimeout: 10_000,
391
+ authHandler: buildAuthHandler(config),
392
+ });
393
+ });
394
+ }
395
+ /** Collect all files in a local directory recursively. */
396
+ function collectLocalFiles(dir, base = "") {
397
+ const files = [];
398
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
399
+ const fullPath = join(dir, entry.name);
400
+ const relPath = base ? `${base}/${entry.name}` : entry.name;
401
+ if (entry.isDirectory()) {
402
+ files.push(...collectLocalFiles(fullPath, relPath));
403
+ }
404
+ else if (entry.isFile()) {
405
+ files.push({ localPath: fullPath, relativePath: relPath, size: statSync(fullPath).size });
406
+ }
407
+ }
408
+ return files;
409
+ }
410
+ /** Upload a file or directory to remote via SFTP. */
411
+ async function uploadPath(sftp, localPath, remotePath, onProgress) {
412
+ const stat = statSync(localPath);
413
+ if (stat.isFile()) {
414
+ await uploadFile(sftp, localPath, remotePath);
415
+ return `Uploaded ${basename(localPath)} (${formatBytes(stat.size)})`;
416
+ }
417
+ // Directory: collect all files, create remote dirs, upload each
418
+ const files = collectLocalFiles(localPath);
419
+ const totalBytes = files.reduce((s, f) => s + f.size, 0);
420
+ let bytesTransferred = 0;
421
+ let filesCompleted = 0;
422
+ // Ensure remote base directory exists
423
+ await mkdirRemote(sftp, remotePath);
424
+ for (const file of files) {
425
+ const remoteFilePath = posix.join(remotePath, file.relativePath);
426
+ const remoteDir = posix.dirname(remoteFilePath);
427
+ await mkdirRemote(sftp, remoteDir);
428
+ await uploadFile(sftp, file.localPath, remoteFilePath);
429
+ bytesTransferred += file.size;
430
+ filesCompleted++;
431
+ onProgress?.({
432
+ file: file.relativePath,
433
+ bytesTransferred,
434
+ totalBytes,
435
+ filesCompleted,
436
+ totalFiles: files.length,
437
+ });
438
+ }
439
+ return `Uploaded ${files.length} file(s) (${formatBytes(totalBytes)}) to ${remotePath}`;
440
+ }
441
+ /** Upload a single file. */
442
+ function uploadFile(sftp, localPath, remotePath) {
443
+ return new Promise((resolve, reject) => {
444
+ const readStream = createReadStream(localPath);
445
+ const writeStream = sftp.createWriteStream(remotePath);
446
+ writeStream.on("close", () => resolve());
447
+ writeStream.on("error", reject);
448
+ readStream.on("error", reject);
449
+ readStream.pipe(writeStream);
450
+ });
451
+ }
452
+ /** Download a file or directory from remote via SFTP. */
453
+ async function downloadPath(sftp, remotePath, localPath, onProgress) {
454
+ // Check if remote path is a file or directory
455
+ const remoteStat = await new Promise((resolve, reject) => {
456
+ sftp.stat(remotePath, (err, stats) => {
457
+ if (err)
458
+ reject(new Error(`Remote path not found: ${remotePath}`));
459
+ else
460
+ resolve(stats);
461
+ });
462
+ });
463
+ if (remoteStat.isFile()) {
464
+ mkdirSync(dirname(localPath), { recursive: true });
465
+ await downloadFile(sftp, remotePath, localPath);
466
+ return `Downloaded ${basename(remotePath)} (${formatBytes(remoteStat.size)})`;
467
+ }
468
+ // Directory: list recursively, download each
469
+ const files = await collectRemoteFiles(sftp, remotePath);
470
+ const totalBytes = files.reduce((s, f) => s + f.size, 0);
471
+ let bytesTransferred = 0;
472
+ let filesCompleted = 0;
473
+ for (const file of files) {
474
+ const localFilePath = join(localPath, file.relativePath);
475
+ mkdirSync(dirname(localFilePath), { recursive: true });
476
+ await downloadFile(sftp, file.remotePath, localFilePath);
477
+ bytesTransferred += file.size;
478
+ filesCompleted++;
479
+ onProgress?.({
480
+ file: file.relativePath,
481
+ bytesTransferred,
482
+ totalBytes,
483
+ filesCompleted,
484
+ totalFiles: files.length,
485
+ });
486
+ }
487
+ return `Downloaded ${files.length} file(s) (${formatBytes(totalBytes)}) to ${localPath}`;
488
+ }
489
+ /** Download a single file. */
490
+ function downloadFile(sftp, remotePath, localPath) {
491
+ return new Promise((resolve, reject) => {
492
+ const readStream = sftp.createReadStream(remotePath);
493
+ const writeStream = createWriteStream(localPath);
494
+ writeStream.on("close", () => resolve());
495
+ writeStream.on("error", reject);
496
+ readStream.on("error", reject);
497
+ readStream.pipe(writeStream);
498
+ });
499
+ }
500
+ /** Recursively list files on remote. */
501
+ async function collectRemoteFiles(sftp, dir, base = "") {
502
+ const files = [];
503
+ const entries = await new Promise((resolve, reject) => {
504
+ sftp.readdir(dir, (err, list) => {
505
+ if (err)
506
+ reject(err);
507
+ else
508
+ resolve(list || []);
509
+ });
510
+ });
511
+ for (const entry of entries) {
512
+ const fullPath = posix.join(dir, entry.filename);
513
+ const relPath = base ? `${base}/${entry.filename}` : entry.filename;
514
+ if (entry.attrs.isDirectory()) {
515
+ files.push(...await collectRemoteFiles(sftp, fullPath, relPath));
516
+ }
517
+ else if (entry.attrs.isFile()) {
518
+ files.push({ remotePath: fullPath, relativePath: relPath, size: entry.attrs.size });
519
+ }
520
+ }
521
+ return files;
522
+ }
523
+ /** Create remote directory recursively. */
524
+ async function mkdirRemote(sftp, path) {
525
+ const parts = path.split("/").filter(Boolean);
526
+ let current = "";
527
+ for (const part of parts) {
528
+ current += "/" + part;
529
+ await new Promise((resolve) => {
530
+ sftp.mkdir(current, (_err) => resolve()); // ignore errors (dir may exist)
531
+ });
532
+ }
533
+ }
534
+ function formatBytes(bytes) {
535
+ if (bytes >= 1073741824)
536
+ return `${(bytes / 1073741824).toFixed(2)} GB`;
537
+ if (bytes >= 1048576)
538
+ return `${(bytes / 1048576).toFixed(1)} MB`;
539
+ if (bytes >= 1024)
540
+ return `${(bytes / 1024).toFixed(0)} KB`;
541
+ return `${bytes} B`;
542
+ }
543
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
544
+ function hostPart(hostStr) {
545
+ return hostStr.includes("@") ? hostStr.split("@")[1] : hostStr;
546
+ }