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.
- package/config/chat-responses.json +767 -0
- package/config/concept-clusters.json +31 -0
- package/config/entities.json +93 -0
- package/config/image-prompts.json +20 -0
- package/config/intent-vectors.json +1 -0
- package/config/intents.json +5023 -65
- package/config/ollama-models.json +193 -0
- package/config/rules.json +32 -1
- package/dist/automation/discordPatchright.d.ts +35 -0
- package/dist/automation/discordPatchright.js +424 -0
- package/dist/automation/discordSetup.d.ts +31 -0
- package/dist/automation/discordSetup.js +338 -0
- package/dist/conversation/coreference.js +44 -4
- package/dist/conversation/pendingActions.d.ts +55 -0
- package/dist/conversation/pendingActions.js +127 -0
- package/dist/conversation/store.d.ts +72 -0
- package/dist/conversation/store.js +140 -1
- package/dist/conversation/topicTracker.d.ts +36 -0
- package/dist/conversation/topicTracker.js +141 -0
- package/dist/execution/ssh.d.ts +42 -1
- package/dist/execution/ssh.js +532 -3
- package/dist/handlers/executor.js +3981 -16
- package/dist/index.d.ts +25 -3
- package/dist/index.js +36 -2
- package/dist/nlp/batchParser.d.ts +30 -0
- package/dist/nlp/batchParser.js +77 -0
- package/dist/nlp/conceptExpansion.d.ts +54 -0
- package/dist/nlp/conceptExpansion.js +136 -0
- package/dist/nlp/conceptRouter.d.ts +49 -0
- package/dist/nlp/conceptRouter.js +302 -0
- package/dist/nlp/confidenceCalibrator.d.ts +62 -0
- package/dist/nlp/confidenceCalibrator.js +116 -0
- package/dist/nlp/correctionLearner.d.ts +45 -0
- package/dist/nlp/correctionLearner.js +207 -0
- package/dist/nlp/entitySpellCorrect.d.ts +35 -0
- package/dist/nlp/entitySpellCorrect.js +141 -0
- package/dist/nlp/knowledgeGraph.d.ts +70 -0
- package/dist/nlp/knowledgeGraph.js +380 -0
- package/dist/nlp/llmFallback.js +28 -1
- package/dist/nlp/multiClassifier.js +91 -6
- package/dist/nlp/multiIntent.d.ts +43 -0
- package/dist/nlp/multiIntent.js +154 -0
- package/dist/nlp/parseIntent.d.ts +6 -1
- package/dist/nlp/parseIntent.js +180 -5
- package/dist/nlp/ruleParser.js +315 -0
- package/dist/nlp/semanticSimilarity.d.ts +30 -0
- package/dist/nlp/semanticSimilarity.js +174 -0
- package/dist/nlp/vocabularyBuilder.d.ts +43 -0
- package/dist/nlp/vocabularyBuilder.js +224 -0
- package/dist/nlp/wikidata.d.ts +49 -0
- package/dist/nlp/wikidata.js +228 -0
- package/dist/policy/confirm.d.ts +10 -0
- package/dist/policy/confirm.js +39 -0
- package/dist/policy/safety.js +6 -4
- package/dist/utils/aliases.d.ts +5 -0
- package/dist/utils/aliases.js +39 -0
- package/dist/utils/analysis.js +71 -15
- package/dist/utils/browser.d.ts +64 -0
- package/dist/utils/browser.js +364 -0
- package/dist/utils/commandHistory.d.ts +20 -0
- package/dist/utils/commandHistory.js +108 -0
- package/dist/utils/completer.d.ts +17 -0
- package/dist/utils/completer.js +79 -0
- package/dist/utils/config.js +32 -2
- package/dist/utils/dbQuery.d.ts +25 -0
- package/dist/utils/dbQuery.js +248 -0
- package/dist/utils/discordDiag.d.ts +35 -0
- package/dist/utils/discordDiag.js +826 -0
- package/dist/utils/diskCleanup.d.ts +36 -0
- package/dist/utils/diskCleanup.js +775 -0
- package/dist/utils/entityResolver.d.ts +107 -0
- package/dist/utils/entityResolver.js +468 -0
- package/dist/utils/imageGen.d.ts +92 -0
- package/dist/utils/imageGen.js +2031 -0
- package/dist/utils/installTracker.d.ts +57 -0
- package/dist/utils/installTracker.js +160 -0
- package/dist/utils/multiExec.d.ts +21 -0
- package/dist/utils/multiExec.js +141 -0
- package/dist/utils/openclawDiag.d.ts +29 -0
- package/dist/utils/openclawDiag.js +1035 -0
- package/dist/utils/output.js +4 -0
- package/dist/utils/platform.js +2 -1
- package/dist/utils/progressReporter.d.ts +50 -0
- package/dist/utils/progressReporter.js +58 -0
- package/dist/utils/projectDetect.d.ts +44 -0
- package/dist/utils/projectDetect.js +319 -0
- package/dist/utils/projectScanner.d.ts +44 -0
- package/dist/utils/projectScanner.js +312 -0
- package/dist/utils/shellCompat.d.ts +78 -0
- package/dist/utils/shellCompat.js +186 -0
- package/dist/utils/smartArchive.d.ts +16 -0
- package/dist/utils/smartArchive.js +172 -0
- package/dist/utils/smartRetry.d.ts +26 -0
- package/dist/utils/smartRetry.js +114 -0
- package/dist/utils/updater.d.ts +1 -0
- package/dist/utils/updater.js +1 -1
- package/dist/utils/version.d.ts +20 -0
- package/dist/utils/version.js +212 -0
- package/package.json +6 -3
package/dist/execution/ssh.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
+
}
|