@bingzi-233/ssh-mcp 1.3.0 → 1.5.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/dist/ops.js ADDED
@@ -0,0 +1,470 @@
1
+ import { createWriteStream, createReadStream, statSync } from "node:fs";
2
+ import { createConnection, runCommand } from "./ssh.js";
3
+ // reopen: helper because openSftp is not exported from transfer.ts, but we need it here
4
+ function sftpOpen(conn) {
5
+ return new Promise((resolve, reject) => {
6
+ conn.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
7
+ });
8
+ }
9
+ const HEALTH_CMD = [
10
+ 'echo "HOSTNAME:$(hostname)"',
11
+ 'echo "OS_START"',
12
+ 'cat /etc/os-release 2>/dev/null | head -2 || echo "N/A"',
13
+ 'echo "OS_END"',
14
+ 'echo "UPTIME:$(uptime -p 2>/dev/null || cat /proc/uptime)"',
15
+ 'echo "LOAD:$(cat /proc/loadavg)"',
16
+ 'echo "MEM:$(free -h 2>/dev/null || head -5 /proc/meminfo)"',
17
+ 'echo "DISK_START"',
18
+ 'df -h / /tmp /var 2>/dev/null || df -h /',
19
+ 'echo "DISK_END"',
20
+ 'echo "CPU:$(nproc) cores"',
21
+ ].join("; ");
22
+ export async function getHealth(cfg, timeoutMs = 30_000) {
23
+ const r = await runCommand(cfg, HEALTH_CMD, timeoutMs);
24
+ const out = (r.stdout || "") + (r.stderr || "");
25
+ const extract = (label) => {
26
+ const m = out.match(new RegExp(`${label}:(.+?)(?:\\n|$)`));
27
+ return m ? m[1].trim() : "—";
28
+ };
29
+ const section = (start, end) => {
30
+ const i = out.indexOf(start);
31
+ const j = out.indexOf(end, i);
32
+ if (i === -1)
33
+ return "—";
34
+ return out.slice(i + start.length, j === -1 ? undefined : j).trim();
35
+ };
36
+ return {
37
+ server: cfg.name,
38
+ hostname: extract("HOSTNAME"),
39
+ os: section("OS_START", "OS_END"),
40
+ uptime: extract("UPTIME"),
41
+ load: extract("LOAD"),
42
+ memory: extract("MEM"),
43
+ disk: section("DISK_START", "DISK_END"),
44
+ cpuCores: extract("CPU"),
45
+ };
46
+ }
47
+ export async function getCertInfo(cfg, host, port = 443, timeoutMs = 15_000) {
48
+ const cmd = `echo | openssl s_client -connect '${host}:${port}' -servername '${host}' 2>/dev/null | openssl x509 -noout -subject -issuer -dates -fingerprint -ext subjectAltName`;
49
+ const r = await runCommand(cfg, cmd, timeoutMs);
50
+ const out = (r.stdout || "") + (r.stderr || "");
51
+ const extract = (label) => {
52
+ const m = out.match(new RegExp(`^${label}\\s*=\\s*(.+)$`, "m"));
53
+ return m ? m[1].trim() : "—";
54
+ };
55
+ const sans = [];
56
+ const sanRe = /DNS:([^\s,]+)/g;
57
+ let m;
58
+ while ((m = sanRe.exec(out)))
59
+ sans.push(m[1]);
60
+ const notAfter = extract("notAfter");
61
+ let remainingDays = -1;
62
+ try {
63
+ remainingDays = Math.ceil((Date.parse(notAfter) - Date.now()) / 86_400_000);
64
+ }
65
+ catch { /* */ }
66
+ return {
67
+ subject: extract("subject"),
68
+ issuer: extract("issuer"),
69
+ notBefore: extract("notBefore"),
70
+ notAfter,
71
+ sans,
72
+ fingerprint: extract("fingerprint") || extract("SHA256 Fingerprint") || extract("SHA1 Fingerprint"),
73
+ remainingDays,
74
+ };
75
+ }
76
+ export async function copyBetween(srcCfg, destCfg, sourcePath, destPath) {
77
+ const srcConn = await createConnection(srcCfg, 20_000);
78
+ const srcSftp = await sftpOpen(srcConn);
79
+ // stat the source file
80
+ const srcStat = await new Promise((resolve, reject) => {
81
+ srcSftp.stat(sourcePath, (err, s) => (err ? reject(err) : resolve(s)));
82
+ });
83
+ const destConn = await createConnection(destCfg, 20_000);
84
+ const destSftp = await sftpOpen(destConn);
85
+ const startedAt = Date.now();
86
+ await new Promise((resolve, reject) => {
87
+ const readStream = srcSftp.createReadStream(sourcePath);
88
+ const writeStream = destSftp.createWriteStream(destPath);
89
+ readStream.on("error", (err) => {
90
+ destConn.end();
91
+ srcConn.end();
92
+ reject(err);
93
+ });
94
+ writeStream.on("error", (err) => {
95
+ destConn.end();
96
+ srcConn.end();
97
+ reject(err);
98
+ });
99
+ writeStream.on("close", () => {
100
+ destConn.end();
101
+ srcConn.end();
102
+ resolve();
103
+ });
104
+ readStream.pipe(writeStream);
105
+ });
106
+ return {
107
+ sourceServer: srcCfg.name,
108
+ destServer: destCfg.name,
109
+ sourcePath,
110
+ destPath,
111
+ size: srcStat.size,
112
+ elapsedMs: Date.now() - startedAt,
113
+ };
114
+ }
115
+ export async function diffServers(cfgA, cfgB, path) {
116
+ const read = async (cfg) => {
117
+ const conn = await createConnection(cfg, 20_000);
118
+ const sftp = await sftpOpen(conn);
119
+ return new Promise((resolve, reject) => {
120
+ let buf = "";
121
+ const stream = sftp.createReadStream(path, { autoClose: true });
122
+ stream.on("data", (d) => (buf += d.toString("utf8")));
123
+ stream.on("error", reject);
124
+ stream.on("end", () => {
125
+ conn.end();
126
+ resolve(buf);
127
+ });
128
+ });
129
+ };
130
+ const [a, b] = await Promise.all([read(cfgA), read(cfgB)]);
131
+ const linesA = a.split("\n");
132
+ const linesB = b.split("\n");
133
+ // simple unified diff
134
+ const diffLines = [];
135
+ const maxLen = Math.max(linesA.length, linesB.length);
136
+ let added = 0;
137
+ let removed = 0;
138
+ // Very simple line-by-line comparison with context
139
+ let i = 0, j = 0;
140
+ while (i < linesA.length || j < linesB.length) {
141
+ if (i < linesA.length && j < linesB.length && linesA[i] === linesB[j]) {
142
+ diffLines.push(` ${linesA[i]}`);
143
+ i++;
144
+ j++;
145
+ }
146
+ else {
147
+ // look ahead for sync point
148
+ let syncI = -1, syncJ = -1;
149
+ const lookahead = 10;
150
+ for (let di = 0; di <= lookahead && i + di < linesA.length; di++) {
151
+ for (let dj = 0; dj <= lookahead && j + dj < linesB.length; dj++) {
152
+ if (di === 0 && dj === 0)
153
+ continue;
154
+ if (linesA[i + di] === linesB[j + dj]) {
155
+ if (syncI === -1 || di + dj < syncI + syncJ) {
156
+ syncI = di;
157
+ syncJ = dj;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ if (syncI >= 0) {
163
+ // show removed lines from A
164
+ for (let di = 0; di < syncI; di++) {
165
+ diffLines.push(`- ${linesA[i + di]}`);
166
+ removed++;
167
+ }
168
+ // show added lines from B
169
+ for (let dj = 0; dj < syncJ; dj++) {
170
+ diffLines.push(`+ ${linesB[j + dj]}`);
171
+ added++;
172
+ }
173
+ i += syncI;
174
+ j += syncJ;
175
+ }
176
+ else {
177
+ // no sync found, dump remaining
178
+ while (i < linesA.length) {
179
+ diffLines.push(`- ${linesA[i++]}`);
180
+ removed++;
181
+ }
182
+ while (j < linesB.length) {
183
+ diffLines.push(`+ ${linesB[j++]}`);
184
+ added++;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return {
190
+ path,
191
+ serverA: cfgA.name,
192
+ serverB: cfgB.name,
193
+ identical: added === 0 && removed === 0,
194
+ added,
195
+ removed,
196
+ diff: diffLines.join("\n"),
197
+ };
198
+ }
199
+ export async function execScript(cfg, localScriptPath, remotePath, timeoutMs = 120_000) {
200
+ const conn = await createConnection(cfg, 20_000);
201
+ const sftp = await sftpOpen(conn);
202
+ // upload the script
203
+ await new Promise((resolve, reject) => {
204
+ const read = createReadStream(localScriptPath);
205
+ const write = sftp.createWriteStream(remotePath, { mode: 0o755 });
206
+ read.on("error", reject);
207
+ write.on("error", reject);
208
+ write.on("close", resolve);
209
+ read.pipe(write);
210
+ });
211
+ // run it
212
+ const result = await runCommand(cfg, `chmod +x '${remotePath}' && '${remotePath}'; EC=$?; rm -f '${remotePath}'; exit $EC`, timeoutMs, undefined);
213
+ conn.end();
214
+ return {
215
+ server: cfg.name,
216
+ exitCode: result.code,
217
+ stdout: result.stdout,
218
+ stderr: result.stderr,
219
+ };
220
+ }
221
+ export async function snapshot(cfg, remoteDir, localFile, excludes = []) {
222
+ const startedAt = Date.now();
223
+ const excludeArgs = excludes.map((e) => `--exclude='${e}'`).join(" ");
224
+ const cmd = `cd '${remoteDir}' && tar czf - ${excludeArgs} .`;
225
+ const conn = await createConnection(cfg, 20_000);
226
+ await new Promise((resolve, reject) => {
227
+ conn.exec(cmd, (err, stream) => {
228
+ if (err) {
229
+ conn.end();
230
+ return reject(err);
231
+ }
232
+ const write = createWriteStream(localFile);
233
+ stream.on("error", (e) => { conn.end(); reject(e); });
234
+ write.on("error", (e) => { conn.end(); reject(e); });
235
+ stream.on("close", (code) => {
236
+ conn.end();
237
+ if (code !== null && code !== 0) {
238
+ return reject(new Error(`tar 退出码: ${code}`));
239
+ }
240
+ resolve();
241
+ });
242
+ stream.pipe(write);
243
+ });
244
+ });
245
+ const fileSize = statSync(localFile).size;
246
+ return {
247
+ server: cfg.name,
248
+ remotePath: remoteDir,
249
+ localFile,
250
+ fileSize,
251
+ elapsedMs: Date.now() - startedAt,
252
+ };
253
+ }
254
+ const tails = new Map();
255
+ let tailCounter = 0;
256
+ export async function startTailFollow(cfg, path, intervalMs = 2000, onData) {
257
+ const conn = await createConnection(cfg, 20_000);
258
+ const sftp = await sftpOpen(conn);
259
+ const currentSize = await new Promise((resolve, reject) => {
260
+ sftp.stat(path, (err, s) => (err ? reject(err) : resolve(s)));
261
+ });
262
+ const id = `tail${++tailCounter}`;
263
+ let offset = currentSize.size;
264
+ const tf = {
265
+ id,
266
+ server: cfg.name,
267
+ path,
268
+ state: "following",
269
+ seenBytes: 0,
270
+ createdAt: Date.now(),
271
+ };
272
+ const timer = setInterval(() => {
273
+ if (tf.state === "stopped") {
274
+ clearInterval(timer);
275
+ conn.end();
276
+ tails.delete(id);
277
+ return;
278
+ }
279
+ sftp.stat(path, (err, s) => {
280
+ if (err)
281
+ return;
282
+ if (s.size > offset) {
283
+ const read = sftp.createReadStream(path, { start: offset, end: s.size - 1 });
284
+ let chunk = "";
285
+ read.on("data", (d) => (chunk += d.toString("utf8")));
286
+ read.on("end", () => {
287
+ tf.seenBytes += chunk.length;
288
+ offset = s.size;
289
+ onData(id, chunk);
290
+ });
291
+ }
292
+ else if (s.size < offset) {
293
+ // file truncated
294
+ offset = 0;
295
+ }
296
+ });
297
+ }, intervalMs);
298
+ tails.set(id, { ...tf, timer });
299
+ conn.on("close", () => { tf.state = "stopped"; clearInterval(timer); tails.delete(id); });
300
+ conn.on("error", () => { tf.state = "stopped"; clearInterval(timer); tails.delete(id); });
301
+ return tf;
302
+ }
303
+ export function stopTailFollow(id) {
304
+ const t = tails.get(id);
305
+ if (!t)
306
+ return false;
307
+ t.state = "stopped";
308
+ clearInterval(t.timer);
309
+ tails.delete(id);
310
+ return true;
311
+ }
312
+ export function getTailFollow(id) {
313
+ const t = tails.get(id);
314
+ if (!t)
315
+ return undefined;
316
+ return { id: t.id, server: t.server, path: t.path, state: t.state, seenBytes: t.seenBytes, createdAt: t.createdAt };
317
+ }
318
+ export function listTailFollows() {
319
+ return [...tails.values()].map((t) => ({
320
+ id: t.id, server: t.server, path: t.path, state: t.state, seenBytes: t.seenBytes, createdAt: t.createdAt,
321
+ }));
322
+ }
323
+ export async function httpRequest(cfg, url, method = "GET", headers = {}, body, timeoutMs = 30_000) {
324
+ const headerArgs = Object.entries(headers)
325
+ .map(([k, v]) => `-H '${k}: ${v.replace(/'/g, "'\\''")}'`)
326
+ .join(" ");
327
+ const dataArg = body ? `--data '${body.replace(/'/g, "'\\''")}'` : "";
328
+ const urlEscaped = url.replace(/'/g, "'\\''");
329
+ const cmd = `curl -sS -w '\\n<<HTTP_CODE>>%{http_code}<<TIME>>%{time_total}' -X ${method} ${headerArgs} ${dataArg} '${urlEscaped}'`;
330
+ const r = await runCommand(cfg, cmd, timeoutMs);
331
+ const out = (r.stdout || "") + (r.stderr || "");
332
+ let httpCode = "—";
333
+ let duration = "—";
334
+ const codeM = out.match(/<<HTTP_CODE>>(\d+)/);
335
+ const timeM = out.match(/<<TIME>>([\d.]+)/);
336
+ if (codeM)
337
+ httpCode = codeM[1];
338
+ if (timeM)
339
+ duration = `${timeM[1]}s`;
340
+ const bodyContent = out.replace(/<<HTTP_CODE>>\d+.*$/, "").trim();
341
+ return {
342
+ server: cfg.name,
343
+ exitCode: r.code,
344
+ httpCode,
345
+ body: bodyContent,
346
+ headers: "",
347
+ duration,
348
+ };
349
+ }
350
+ export async function getRemoteEnv(cfg, processName, timeoutMs = 20_000) {
351
+ const cmds = [
352
+ "echo '<<ENV>>'" + "&& env | head -60",
353
+ "echo '<<USERS>>'" + "&& who 2>/dev/null || echo 'N/A'",
354
+ "echo '<<OPENFILES>>'" + "&& lsof -u $(whoami) 2>/dev/null | tail -20 || echo 'N/A'",
355
+ "echo '<<NETWORK>>'" + "&& ss -tlnp 2>/dev/null | head -20 || netstat -tlnp 2>/dev/null | head -20 || echo 'N/A'",
356
+ ];
357
+ if (processName) {
358
+ cmds.push(`echo '<<PROC>>' && ps aux | grep '${processName}' | grep -v grep | head -5`);
359
+ }
360
+ const cmd = cmds.join("; ");
361
+ const r = await runCommand(cfg, cmd, timeoutMs);
362
+ const out = (r.stdout || "") + (r.stderr || "");
363
+ const section = (label) => {
364
+ const i = out.indexOf(`<<${label}>>`);
365
+ if (i === -1)
366
+ return "";
367
+ const start = i + label.length + 7; // <<LABEL>>\n
368
+ const rest = out.slice(start);
369
+ const next = rest.search(/<<[A-Z]+>>/);
370
+ return next === -1 ? rest.trim() : rest.slice(0, next).trim();
371
+ };
372
+ const envStr = section("ENV");
373
+ const envVars = {};
374
+ for (const line of envStr.split("\n")) {
375
+ const eq = line.indexOf("=");
376
+ if (eq > 0)
377
+ envVars[line.slice(0, eq)] = line.slice(eq + 1);
378
+ }
379
+ let procInfo = null;
380
+ const procSection = section("PROC");
381
+ if (procSection) {
382
+ const fields = procSection.split(/\s+/);
383
+ if (fields.length >= 11) {
384
+ procInfo = {
385
+ pid: parseInt(fields[1], 10),
386
+ ppid: parseInt(fields[2], 10),
387
+ cmdline: fields.slice(10).join(" "),
388
+ };
389
+ }
390
+ }
391
+ return {
392
+ server: cfg.name,
393
+ envVars,
394
+ procInfo,
395
+ users: section("USERS"),
396
+ openFiles: section("OPENFILES"),
397
+ network: section("NETWORK"),
398
+ };
399
+ }
400
+ const watches = new Map();
401
+ let watchCounter = 0;
402
+ export function startWatch(cfg, command, intervalMs, onIteration, timeoutMs = 10_000) {
403
+ const id = `w${++watchCounter}`;
404
+ const wh = { id, command, server: cfg.name, intervalMs, state: "running" };
405
+ let prevOut = "";
406
+ const tick = async () => {
407
+ if (wh.state === "stopped") {
408
+ clearInterval(timer);
409
+ watches.delete(id);
410
+ return;
411
+ }
412
+ try {
413
+ const r = await runCommand(cfg, command, timeoutMs);
414
+ const out = r.stdout + r.stderr;
415
+ const changed = out !== prevOut;
416
+ const diff = changed ? computeDiff(prevOut, out) : "";
417
+ prevOut = out;
418
+ onIteration(id, {
419
+ timestamp: Date.now(),
420
+ stdout: r.stdout,
421
+ stderr: r.stderr,
422
+ exitCode: r.code,
423
+ changed,
424
+ diff,
425
+ });
426
+ }
427
+ catch {
428
+ // skip failed iterations
429
+ }
430
+ };
431
+ const timer = setInterval(tick, intervalMs);
432
+ watches.set(id, { ...wh, timer });
433
+ // run first iteration immediately
434
+ tick();
435
+ return wh;
436
+ }
437
+ export function stopWatch(id) {
438
+ const w = watches.get(id);
439
+ if (!w)
440
+ return false;
441
+ w.state = "stopped";
442
+ clearInterval(w.timer);
443
+ watches.delete(id);
444
+ return true;
445
+ }
446
+ export function getWatch(id) {
447
+ return watches.get(id);
448
+ }
449
+ export function listWatches() {
450
+ return [...watches.values()].map((w) => ({
451
+ id: w.id, command: w.command, server: w.server, intervalMs: w.intervalMs, state: w.state,
452
+ }));
453
+ }
454
+ function computeDiff(prev, curr) {
455
+ const pa = prev.split("\n");
456
+ const ca = curr.split("\n");
457
+ const lines = [];
458
+ const max = Math.max(pa.length, ca.length);
459
+ for (let i = 0; i < max; i++) {
460
+ const p = pa[i] ?? "";
461
+ const c = ca[i] ?? "";
462
+ if (p !== c) {
463
+ if (p)
464
+ lines.push(`- ${p}`);
465
+ if (c)
466
+ lines.push(`+ ${c}`);
467
+ }
468
+ }
469
+ return lines.slice(0, 40).join("\n");
470
+ }
@@ -0,0 +1,160 @@
1
+ import { posix } from "node:path";
2
+ import { createConnection } from "./ssh.js";
3
+ function openSftp(conn) {
4
+ return new Promise((resolve, reject) => {
5
+ conn.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
6
+ });
7
+ }
8
+ function classify(mode) {
9
+ const ifmt = mode & 0o170000;
10
+ if (ifmt === 0o040000)
11
+ return "directory";
12
+ if (ifmt === 0o100000)
13
+ return "file";
14
+ if (ifmt === 0o120000)
15
+ return "link";
16
+ return "other";
17
+ }
18
+ function formatMode(mode) {
19
+ const r = (mode & 0o4) ? "r" : "-";
20
+ const w = (mode & 0o2) ? "w" : "-";
21
+ const x = (mode & 0o1) ? "x" : "-";
22
+ return r + w + x;
23
+ }
24
+ function formatPerms(mode) {
25
+ const f = classify(mode);
26
+ const c = f === "directory" ? "d" : f === "link" ? "l" : f === "other" ? "?" : "-";
27
+ return c + formatMode(mode >> 6) + formatMode(mode >> 3) + formatMode(mode);
28
+ }
29
+ export function formatLsLong(e) {
30
+ return `${formatPerms(e.mode)} ${String(e.uid).padStart(5)} ${String(e.gid).padStart(5)} ${String(e.size).padStart(10)} ${new Date(e.mtime * 1000).toISOString().replace("T", " ").replace(/\..*/, "")} ${e.name}`;
31
+ }
32
+ export function formatLsShort(e) {
33
+ return e.name;
34
+ }
35
+ export async function listDirectory(cfg, path) {
36
+ const conn = await createConnection(cfg, 20_000);
37
+ try {
38
+ const sftp = await openSftp(conn);
39
+ return new Promise((resolve, reject) => {
40
+ sftp.readdir(path, (err, entries) => {
41
+ conn.end();
42
+ if (err)
43
+ return reject(err);
44
+ resolve(entries.map((e) => ({
45
+ name: e.filename,
46
+ type: classify(e.attrs.mode),
47
+ size: e.attrs.size,
48
+ mode: e.attrs.mode,
49
+ uid: e.attrs.uid,
50
+ gid: e.attrs.gid,
51
+ mtime: e.attrs.mtime,
52
+ atime: e.attrs.atime,
53
+ longname: e.longname,
54
+ })));
55
+ });
56
+ });
57
+ }
58
+ catch (e) {
59
+ conn.end();
60
+ throw e;
61
+ }
62
+ }
63
+ export async function statPath(cfg, path) {
64
+ const conn = await createConnection(cfg, 20_000);
65
+ try {
66
+ const sftp = await openSftp(conn);
67
+ return new Promise((resolve, reject) => {
68
+ sftp.stat(path, (err, s) => {
69
+ conn.end();
70
+ if (err)
71
+ return reject(err);
72
+ resolve({
73
+ type: classify(s.mode),
74
+ size: s.size,
75
+ mode: s.mode,
76
+ uid: s.uid,
77
+ gid: s.gid,
78
+ mtime: s.mtime,
79
+ atime: s.atime,
80
+ });
81
+ });
82
+ });
83
+ }
84
+ catch (e) {
85
+ conn.end();
86
+ throw e;
87
+ }
88
+ }
89
+ export async function removePath(cfg, path, recursive) {
90
+ const conn = await createConnection(cfg, 20_000);
91
+ try {
92
+ const sftp = await openSftp(conn);
93
+ // 先 stat 确定类型
94
+ const s = await new Promise((resolve, reject) => {
95
+ sftp.stat(path, (err, st) => {
96
+ if (err)
97
+ return reject(err);
98
+ resolve({ type: classify(st.mode) });
99
+ });
100
+ });
101
+ if (s.type === "directory") {
102
+ if (!recursive)
103
+ throw new Error(`"${path}" 是目录,需要 --recursive 选项`);
104
+ // 递归删除目录内容
105
+ const entries = await new Promise((res, rej) => {
106
+ sftp.readdir(path, (err, e) => (err ? rej(err) : res(e)));
107
+ });
108
+ for (const e of entries) {
109
+ if (e.filename === "." || e.filename === "..")
110
+ continue;
111
+ await removePath(cfg, posix.join(path, e.filename), true);
112
+ }
113
+ await new Promise((res, rej) => {
114
+ sftp.rmdir(path, (err) => (err ? rej(err) : res()));
115
+ });
116
+ }
117
+ else {
118
+ await new Promise((res, rej) => {
119
+ sftp.unlink(path, (err) => (err ? rej(err) : res()));
120
+ });
121
+ }
122
+ conn.end();
123
+ }
124
+ catch (e) {
125
+ conn.end();
126
+ throw e;
127
+ }
128
+ }
129
+ export async function makeDir(cfg, path, parents) {
130
+ const conn = await createConnection(cfg, 20_000);
131
+ try {
132
+ const sftp = await openSftp(conn);
133
+ if (parents) {
134
+ // 逐层创建
135
+ const parts = path.split("/").filter(Boolean);
136
+ let current = path.startsWith("/") ? "/" : "";
137
+ for (const p of parts) {
138
+ current = current ? posix.join(current, p) : p;
139
+ await new Promise((resolve, reject) => {
140
+ sftp.mkdir(current, { mode: 0o755 }, (err) => {
141
+ // 已存在不算错
142
+ if (err && err.code !== 4)
143
+ return reject(err);
144
+ resolve();
145
+ });
146
+ });
147
+ }
148
+ }
149
+ else {
150
+ await new Promise((res, rej) => {
151
+ sftp.mkdir(path, { mode: 0o755 }, (err) => (err ? rej(err) : res()));
152
+ });
153
+ }
154
+ conn.end();
155
+ }
156
+ catch (e) {
157
+ conn.end();
158
+ throw e;
159
+ }
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bingzi-233/ssh-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式。",
5
5
  "type": "module",
6
6
  "bin": {