@pyrokine/mcp-ssh 1.0.0 → 1.1.2

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/src/file-ops.ts CHANGED
@@ -2,374 +2,394 @@
2
2
  * SSH File Operations - 文件操作
3
3
  */
4
4
 
5
- import * as fs from 'fs';
6
- import * as path from 'path';
7
- import { execSync } from 'child_process';
8
- import { SFTPWrapper, Stats } from 'ssh2';
9
- import { sessionManager } from './session-manager.js';
10
- import { FileInfo, TransferProgress } from './types.js';
5
+ import {execSync} from 'child_process'
6
+ import * as fs from 'fs'
7
+ import * as path from 'path'
8
+ import {Stats} from 'ssh2'
9
+ import {sessionManager} from './session-manager.js'
10
+ import {FileInfo, TransferProgress} from './types.js'
11
11
 
12
12
  /**
13
13
  * 上传文件
14
14
  */
15
15
  export async function uploadFile(
16
- alias: string,
17
- localPath: string,
18
- remotePath: string,
19
- onProgress?: (progress: TransferProgress) => void
16
+ alias: string,
17
+ localPath: string,
18
+ remotePath: string,
19
+ onProgress?: (progress: TransferProgress) => void,
20
20
  ): Promise<{ success: boolean; size: number }> {
21
- if (!fs.existsSync(localPath)) {
22
- throw new Error(`Local file not found: ${localPath}`);
23
- }
24
-
25
- const sftp = await sessionManager.getSftp(alias);
26
- const stats = fs.statSync(localPath);
27
- const totalSize = stats.size;
28
-
29
- return new Promise((resolve, reject) => {
30
- const readStream = fs.createReadStream(localPath);
31
- const writeStream = sftp.createWriteStream(remotePath);
32
- let settled = false;
33
-
34
- const cleanup = (err?: Error) => {
35
- if (settled) return;
36
- settled = true;
37
- sftp.end();
38
- if (err) reject(err);
39
- };
40
-
41
- let transferred = 0;
42
-
43
- readStream.on('data', (chunk: Buffer) => {
44
- transferred += chunk.length;
45
- if (onProgress) {
46
- onProgress({
47
- transferred,
48
- total: totalSize,
49
- percent: totalSize > 0 ? Math.round((transferred / totalSize) * 100) : 100,
50
- });
51
- }
52
- });
53
-
54
- readStream.on('error', (err: Error) => cleanup(err));
55
- writeStream.on('error', (err: Error) => cleanup(err));
56
-
57
- writeStream.on('close', () => {
58
- if (!settled) {
59
- settled = true;
60
- sftp.end();
61
- resolve({ success: true, size: totalSize });
62
- }
63
- });
64
-
65
- readStream.pipe(writeStream);
66
- });
21
+ if (!fs.existsSync(localPath)) {
22
+ throw new Error(`Local file not found: ${localPath}`)
23
+ }
24
+
25
+ const sftp = await sessionManager.getSftp(alias)
26
+ const stats = fs.statSync(localPath)
27
+ const totalSize = stats.size
28
+
29
+ return new Promise((resolve, reject) => {
30
+ const readStream = fs.createReadStream(localPath)
31
+ const writeStream = sftp.createWriteStream(remotePath)
32
+ let settled = false
33
+
34
+ const cleanup = (err?: Error) => {
35
+ if (settled) {
36
+ return
37
+ }
38
+ settled = true
39
+ sftp.end()
40
+ if (err) {
41
+ reject(err)
42
+ }
43
+ }
44
+
45
+ let transferred = 0
46
+
47
+ readStream.on('data', (chunk: Buffer) => {
48
+ transferred += chunk.length
49
+ if (onProgress) {
50
+ onProgress({
51
+ transferred,
52
+ total: totalSize,
53
+ percent: totalSize > 0 ? Math.round((transferred / totalSize) * 100) : 100,
54
+ })
55
+ }
56
+ })
57
+
58
+ readStream.on('error', (err: Error) => cleanup(err))
59
+ writeStream.on('error', (err: Error) => cleanup(err))
60
+
61
+ writeStream.on('close', () => {
62
+ if (!settled) {
63
+ settled = true
64
+ sftp.end()
65
+ resolve({success: true, size: totalSize})
66
+ }
67
+ })
68
+
69
+ readStream.pipe(writeStream)
70
+ })
67
71
  }
68
72
 
69
73
  /**
70
74
  * 下载文件
71
75
  */
72
76
  export async function downloadFile(
73
- alias: string,
74
- remotePath: string,
75
- localPath: string,
76
- onProgress?: (progress: TransferProgress) => void
77
+ alias: string,
78
+ remotePath: string,
79
+ localPath: string,
80
+ onProgress?: (progress: TransferProgress) => void,
77
81
  ): Promise<{ success: boolean; size: number }> {
78
- const sftp = await sessionManager.getSftp(alias);
79
-
80
- // 获取远程文件大小
81
- const stats = await new Promise<Stats>((resolve, reject) => {
82
- sftp.stat(remotePath, (err, stats) => {
83
- if (err) reject(err);
84
- else resolve(stats);
85
- });
86
- });
87
- const totalSize = stats.size;
88
-
89
- // 确保本地目录存在
90
- const localDir = path.dirname(localPath);
91
- if (!fs.existsSync(localDir)) {
92
- fs.mkdirSync(localDir, { recursive: true });
93
- }
94
-
95
- return new Promise((resolve, reject) => {
96
- const readStream = sftp.createReadStream(remotePath);
97
- const writeStream = fs.createWriteStream(localPath);
98
- let settled = false;
99
-
100
- const cleanup = (err?: Error) => {
101
- if (settled) return;
102
- settled = true;
103
- sftp.end();
104
- if (err) reject(err);
105
- };
106
-
107
- let transferred = 0;
108
-
109
- readStream.on('data', (chunk: Buffer) => {
110
- transferred += chunk.length;
111
- if (onProgress) {
112
- onProgress({
113
- transferred,
114
- total: totalSize,
115
- percent: totalSize > 0 ? Math.round((transferred / totalSize) * 100) : 100,
116
- });
117
- }
118
- });
119
-
120
- readStream.on('error', (err: Error) => cleanup(err));
121
- writeStream.on('error', (err: Error) => cleanup(err));
122
-
123
- writeStream.on('close', () => {
124
- if (!settled) {
125
- settled = true;
126
- sftp.end();
127
- resolve({ success: true, size: totalSize });
128
- }
129
- });
130
-
131
- readStream.pipe(writeStream);
132
- });
82
+ const sftp = await sessionManager.getSftp(alias)
83
+
84
+ // 获取远程文件大小
85
+ const stats = await new Promise<Stats>((resolve, reject) => {
86
+ sftp.stat(remotePath, (err, stats) => {
87
+ if (err) {
88
+ reject(err)
89
+ } else {
90
+ resolve(stats)
91
+ }
92
+ })
93
+ })
94
+ const totalSize = stats.size
95
+
96
+ // 确保本地目录存在
97
+ const localDir = path.dirname(localPath)
98
+ if (!fs.existsSync(localDir)) {
99
+ fs.mkdirSync(localDir, {recursive: true})
100
+ }
101
+
102
+ return new Promise((resolve, reject) => {
103
+ const readStream = sftp.createReadStream(remotePath)
104
+ const writeStream = fs.createWriteStream(localPath)
105
+ let settled = false
106
+
107
+ const cleanup = (err?: Error) => {
108
+ if (settled) {
109
+ return
110
+ }
111
+ settled = true
112
+ sftp.end()
113
+ if (err) {
114
+ reject(err)
115
+ }
116
+ }
117
+
118
+ let transferred = 0
119
+
120
+ readStream.on('data', (chunk: Buffer) => {
121
+ transferred += chunk.length
122
+ if (onProgress) {
123
+ onProgress({
124
+ transferred,
125
+ total: totalSize,
126
+ percent: totalSize > 0 ? Math.round((transferred / totalSize) * 100) : 100,
127
+ })
128
+ }
129
+ })
130
+
131
+ readStream.on('error', (err: Error) => cleanup(err))
132
+ writeStream.on('error', (err: Error) => cleanup(err))
133
+
134
+ writeStream.on('close', () => {
135
+ if (!settled) {
136
+ settled = true
137
+ sftp.end()
138
+ resolve({success: true, size: totalSize})
139
+ }
140
+ })
141
+
142
+ readStream.pipe(writeStream)
143
+ })
133
144
  }
134
145
 
135
146
  /**
136
147
  * 读取远程文件内容
137
148
  */
138
149
  export async function readFile(
139
- alias: string,
140
- remotePath: string,
141
- maxBytes: number = 1024 * 1024 // 默认最大 1MB
150
+ alias: string,
151
+ remotePath: string,
152
+ maxBytes: number = 1024 * 1024, // 默认最大 1MB
142
153
  ): Promise<{ content: string; size: number; truncated: boolean }> {
143
- const sftp = await sessionManager.getSftp(alias);
144
-
145
- // 获取文件大小
146
- const stats = await new Promise<Stats>((resolve, reject) => {
147
- sftp.stat(remotePath, (err, stats) => {
148
- if (err) reject(err);
149
- else resolve(stats);
150
- });
151
- });
152
-
153
- const actualSize = stats.size;
154
- const truncated = actualSize > maxBytes;
155
-
156
- // 处理空文件
157
- if (actualSize === 0) {
158
- sftp.end();
159
- return { content: '', size: 0, truncated: false };
160
- }
161
-
162
- const readSize = Math.min(actualSize, maxBytes);
163
-
164
- return new Promise((resolve, reject) => {
165
- const chunks: Buffer[] = [];
166
-
167
- const readStream = sftp.createReadStream(remotePath, {
168
- start: 0,
169
- end: readSize - 1,
170
- });
171
-
172
- readStream.on('data', (chunk: Buffer) => {
173
- chunks.push(chunk);
174
- });
175
-
176
- readStream.on('end', () => {
177
- sftp.end();
178
- const content = Buffer.concat(chunks).toString('utf-8');
179
- resolve({
180
- content,
181
- size: actualSize,
182
- truncated,
183
- });
184
- });
185
-
186
- readStream.on('error', (err: Error) => {
187
- sftp.end();
188
- reject(err);
189
- });
190
- });
154
+ const sftp = await sessionManager.getSftp(alias)
155
+
156
+ // 获取文件大小
157
+ const stats = await new Promise<Stats>((resolve, reject) => {
158
+ sftp.stat(remotePath, (err, stats) => {
159
+ if (err) {
160
+ reject(err)
161
+ } else {
162
+ resolve(stats)
163
+ }
164
+ })
165
+ })
166
+
167
+ const actualSize = stats.size
168
+ const truncated = actualSize > maxBytes
169
+
170
+ // 处理空文件
171
+ if (actualSize === 0) {
172
+ sftp.end()
173
+ return {content: '', size: 0, truncated: false}
174
+ }
175
+
176
+ const readSize = Math.min(actualSize, maxBytes)
177
+
178
+ return new Promise((resolve, reject) => {
179
+ const chunks: Buffer[] = []
180
+
181
+ const readStream = sftp.createReadStream(remotePath, {
182
+ start: 0,
183
+ end: readSize - 1,
184
+ })
185
+
186
+ readStream.on('data', (chunk: Buffer) => {
187
+ chunks.push(chunk)
188
+ })
189
+
190
+ readStream.on('end', () => {
191
+ sftp.end()
192
+ const content = Buffer.concat(chunks).toString('utf-8')
193
+ resolve({
194
+ content,
195
+ size: actualSize,
196
+ truncated,
197
+ })
198
+ })
199
+
200
+ readStream.on('error', (err: Error) => {
201
+ sftp.end()
202
+ reject(err)
203
+ })
204
+ })
191
205
  }
192
206
 
193
207
  /**
194
208
  * 写入远程文件
195
209
  */
196
210
  export async function writeFile(
197
- alias: string,
198
- remotePath: string,
199
- content: string,
200
- append: boolean = false
211
+ alias: string,
212
+ remotePath: string,
213
+ content: string,
214
+ append: boolean = false,
201
215
  ): Promise<{ success: boolean; size: number }> {
202
- const sftp = await sessionManager.getSftp(alias);
203
- const flags = append ? 'a' : 'w';
216
+ const sftp = await sessionManager.getSftp(alias)
217
+ const flags = append ? 'a' : 'w'
204
218
 
205
- return new Promise((resolve, reject) => {
206
- const writeStream = sftp.createWriteStream(remotePath, { flags });
219
+ return new Promise((resolve, reject) => {
220
+ const writeStream = sftp.createWriteStream(remotePath, {flags})
207
221
 
208
- writeStream.on('close', () => {
209
- sftp.end();
210
- resolve({ success: true, size: content.length });
211
- });
222
+ writeStream.on('close', () => {
223
+ sftp.end()
224
+ resolve({success: true, size: content.length})
225
+ })
212
226
 
213
- writeStream.on('error', (err: Error) => {
214
- sftp.end();
215
- reject(err);
216
- });
227
+ writeStream.on('error', (err: Error) => {
228
+ sftp.end()
229
+ reject(err)
230
+ })
217
231
 
218
- writeStream.write(content);
219
- writeStream.end();
220
- });
232
+ writeStream.write(content)
233
+ writeStream.end()
234
+ })
221
235
  }
222
236
 
223
237
  /**
224
238
  * 列出目录内容
225
239
  */
226
240
  export async function listDir(
227
- alias: string,
228
- remotePath: string,
229
- showHidden: boolean = false
241
+ alias: string,
242
+ remotePath: string,
243
+ showHidden: boolean = false,
230
244
  ): Promise<FileInfo[]> {
231
- const sftp = await sessionManager.getSftp(alias);
232
-
233
- return new Promise((resolve, reject) => {
234
- sftp.readdir(remotePath, (err, list) => {
235
- if (err) {
236
- sftp.end();
237
- reject(err);
238
- return;
239
- }
240
-
241
- const files: FileInfo[] = list
242
- .filter((item) => showHidden || !item.filename.startsWith('.'))
243
- .map((item) => ({
244
- name: item.filename,
245
- path: path.posix.join(remotePath, item.filename),
246
- size: item.attrs.size,
247
- isDirectory: (item.attrs.mode & 0o40000) !== 0,
248
- isFile: (item.attrs.mode & 0o100000) !== 0,
249
- isSymlink: (item.attrs.mode & 0o120000) !== 0,
250
- permissions: formatPermissions(item.attrs.mode),
251
- owner: item.attrs.uid,
252
- group: item.attrs.gid,
253
- mtime: new Date(item.attrs.mtime * 1000),
254
- atime: new Date(item.attrs.atime * 1000),
255
- }))
256
- .sort((a, b) => {
257
- // 目录在前
258
- if (a.isDirectory !== b.isDirectory) {
259
- return a.isDirectory ? -1 : 1;
260
- }
261
- return a.name.localeCompare(b.name);
262
- });
263
-
264
- sftp.end();
265
- resolve(files);
266
- });
267
- });
245
+ const sftp = await sessionManager.getSftp(alias)
246
+
247
+ return new Promise((resolve, reject) => {
248
+ sftp.readdir(remotePath, (err, list) => {
249
+ if (err) {
250
+ sftp.end()
251
+ reject(err)
252
+ return
253
+ }
254
+
255
+ const files: FileInfo[] = list
256
+ .filter((item) => showHidden || !item.filename.startsWith('.'))
257
+ .map((item) => ({
258
+ name: item.filename,
259
+ path: path.posix.join(remotePath, item.filename),
260
+ size: item.attrs.size,
261
+ isDirectory: (item.attrs.mode & 0o40000) !== 0,
262
+ isFile: (item.attrs.mode & 0o100000) !== 0,
263
+ isSymlink: (item.attrs.mode & 0o120000) !== 0,
264
+ permissions: formatPermissions(item.attrs.mode),
265
+ owner: item.attrs.uid,
266
+ group: item.attrs.gid,
267
+ mtime: new Date(item.attrs.mtime * 1000),
268
+ atime: new Date(item.attrs.atime * 1000),
269
+ }))
270
+ .sort((a, b) => {
271
+ // 目录在前
272
+ if (a.isDirectory !== b.isDirectory) {
273
+ return a.isDirectory ? -1 : 1
274
+ }
275
+ return a.name.localeCompare(b.name)
276
+ })
277
+
278
+ sftp.end()
279
+ resolve(files)
280
+ })
281
+ })
268
282
  }
269
283
 
270
284
  /**
271
285
  * 获取文件信息
272
286
  */
273
287
  export async function getFileInfo(
274
- alias: string,
275
- remotePath: string
288
+ alias: string,
289
+ remotePath: string,
276
290
  ): Promise<FileInfo> {
277
- const sftp = await sessionManager.getSftp(alias);
278
-
279
- return new Promise((resolve, reject) => {
280
- sftp.stat(remotePath, (err, stats) => {
281
- sftp.end();
282
-
283
- if (err) {
284
- reject(err);
285
- return;
286
- }
287
-
288
- resolve({
289
- name: path.posix.basename(remotePath),
290
- path: remotePath,
291
- size: stats.size,
292
- isDirectory: (stats.mode & 0o40000) !== 0,
293
- isFile: (stats.mode & 0o100000) !== 0,
294
- isSymlink: (stats.mode & 0o120000) !== 0,
295
- permissions: formatPermissions(stats.mode),
296
- owner: stats.uid,
297
- group: stats.gid,
298
- mtime: new Date(stats.mtime * 1000),
299
- atime: new Date(stats.atime * 1000),
300
- });
301
- });
302
- });
291
+ const sftp = await sessionManager.getSftp(alias)
292
+
293
+ return new Promise((resolve, reject) => {
294
+ sftp.stat(remotePath, (err, stats) => {
295
+ sftp.end()
296
+
297
+ if (err) {
298
+ reject(err)
299
+ return
300
+ }
301
+
302
+ resolve({
303
+ name: path.posix.basename(remotePath),
304
+ path: remotePath,
305
+ size: stats.size,
306
+ isDirectory: (stats.mode & 0o40000) !== 0,
307
+ isFile: (stats.mode & 0o100000) !== 0,
308
+ isSymlink: (stats.mode & 0o120000) !== 0,
309
+ permissions: formatPermissions(stats.mode),
310
+ owner: stats.uid,
311
+ group: stats.gid,
312
+ mtime: new Date(stats.mtime * 1000),
313
+ atime: new Date(stats.atime * 1000),
314
+ })
315
+ })
316
+ })
303
317
  }
304
318
 
305
319
  /**
306
320
  * 检查文件是否存在
307
321
  */
308
322
  export async function fileExists(
309
- alias: string,
310
- remotePath: string
323
+ alias: string,
324
+ remotePath: string,
311
325
  ): Promise<boolean> {
312
- const sftp = await sessionManager.getSftp(alias);
313
-
314
- return new Promise((resolve) => {
315
- sftp.stat(remotePath, (err) => {
316
- sftp.end();
317
- resolve(!err);
318
- });
319
- });
326
+ const sftp = await sessionManager.getSftp(alias)
327
+
328
+ return new Promise((resolve) => {
329
+ sftp.stat(remotePath, (err) => {
330
+ sftp.end()
331
+ resolve(!err)
332
+ })
333
+ })
320
334
  }
321
335
 
322
336
  /**
323
337
  * 创建目录
324
338
  */
325
339
  export async function mkdir(
326
- alias: string,
327
- remotePath: string,
328
- recursive: boolean = false
340
+ alias: string,
341
+ remotePath: string,
342
+ recursive: boolean = false,
329
343
  ): Promise<boolean> {
330
- if (recursive) {
331
- // 通过 exec 实现递归创建
332
- const result = await sessionManager.exec(alias, `mkdir -p "${remotePath}"`);
333
- return result.exitCode === 0;
334
- }
335
-
336
- const sftp = await sessionManager.getSftp(alias);
337
- return new Promise((resolve, reject) => {
338
- sftp.mkdir(remotePath, (err) => {
339
- sftp.end();
340
- if (err) reject(err);
341
- else resolve(true);
342
- });
343
- });
344
+ if (recursive) {
345
+ // 通过 exec 实现递归创建
346
+ const result = await sessionManager.exec(alias, `mkdir -p "${remotePath}"`)
347
+ return result.exitCode === 0
348
+ }
349
+
350
+ const sftp = await sessionManager.getSftp(alias)
351
+ return new Promise((resolve, reject) => {
352
+ sftp.mkdir(remotePath, (err) => {
353
+ sftp.end()
354
+ if (err) {
355
+ reject(err)
356
+ } else {
357
+ resolve(true)
358
+ }
359
+ })
360
+ })
344
361
  }
345
362
 
346
363
  /**
347
364
  * 删除文件
348
365
  */
349
366
  export async function removeFile(
350
- alias: string,
351
- remotePath: string
367
+ alias: string,
368
+ remotePath: string,
352
369
  ): Promise<boolean> {
353
- const sftp = await sessionManager.getSftp(alias);
354
- return new Promise((resolve, reject) => {
355
- sftp.unlink(remotePath, (err) => {
356
- sftp.end();
357
- if (err) reject(err);
358
- else resolve(true);
359
- });
360
- });
370
+ const sftp = await sessionManager.getSftp(alias)
371
+ return new Promise((resolve, reject) => {
372
+ sftp.unlink(remotePath, (err) => {
373
+ sftp.end()
374
+ if (err) {
375
+ reject(err)
376
+ } else {
377
+ resolve(true)
378
+ }
379
+ })
380
+ })
361
381
  }
362
382
 
363
383
  /**
364
384
  * 检查远程是否安装 rsync
365
385
  */
366
386
  export async function checkRsync(alias: string): Promise<boolean> {
367
- try {
368
- const result = await sessionManager.exec(alias, 'which rsync');
369
- return result.exitCode === 0 && result.stdout.trim().length > 0;
370
- } catch {
371
- return false;
372
- }
387
+ try {
388
+ const result = await sessionManager.exec(alias, 'which rsync')
389
+ return result.exitCode === 0 && result.stdout.trim().length > 0
390
+ } catch {
391
+ return false
392
+ }
373
393
  }
374
394
 
375
395
  /**
@@ -382,40 +402,40 @@ export async function checkRsync(alias: string): Promise<boolean> {
382
402
  * @param options 同步选项
383
403
  */
384
404
  export async function syncFiles(
385
- alias: string,
386
- localPath: string,
387
- remotePath: string,
388
- direction: 'upload' | 'download',
389
- options: {
390
- delete?: boolean; // 删除目标端多余文件
391
- dryRun?: boolean; // 仅显示将执行的操作
392
- exclude?: string[]; // 排除模式
393
- recursive?: boolean; // 递归同步目录
394
- } = {}
405
+ alias: string,
406
+ localPath: string,
407
+ remotePath: string,
408
+ direction: 'upload' | 'download',
409
+ options: {
410
+ delete?: boolean; // 删除目标端多余文件
411
+ dryRun?: boolean; // 仅显示将执行的操作
412
+ exclude?: string[]; // 排除模式
413
+ recursive?: boolean; // 递归同步目录
414
+ } = {},
395
415
  ): Promise<{
396
- success: boolean;
397
- method: 'rsync' | 'sftp';
398
- filesTransferred?: number;
399
- bytesTransferred?: number;
400
- output?: string;
416
+ success: boolean;
417
+ method: 'rsync' | 'sftp';
418
+ filesTransferred?: number;
419
+ bytesTransferred?: number;
420
+ output?: string;
401
421
  }> {
402
- // 检查远程 rsync
403
- const hasRsync = await checkRsync(alias);
404
-
405
- if (hasRsync) {
406
- // 使用 rsync(通过远程端执行)
407
- return syncWithRsync(alias, localPath, remotePath, direction, options);
408
- } else {
409
- // 回退到 SFTP
410
- return syncWithSftp(alias, localPath, remotePath, direction, options);
411
- }
422
+ // 检查远程 rsync
423
+ const hasRsync = await checkRsync(alias)
424
+
425
+ if (hasRsync) {
426
+ // 使用 rsync(通过远程端执行)
427
+ return syncWithRsync(alias, localPath, remotePath, direction, options)
428
+ } else {
429
+ // 回退到 SFTP
430
+ return syncWithSftp(alias, localPath, remotePath, direction, options)
431
+ }
412
432
  }
413
433
 
414
434
  /**
415
435
  * 转义 shell 路径参数
416
436
  */
417
437
  function escapeShellPath(p: string): string {
418
- return `'${p.replace(/'/g, "'\\''")}'`;
438
+ return `'${p.replace(/'/g, '\'\\\'\'')}'`
419
439
  }
420
440
 
421
441
  /**
@@ -423,308 +443,313 @@ function escapeShellPath(p: string): string {
423
443
  * 通过本地执行 rsync 连接到远程(需要密钥认证或 ssh-agent)
424
444
  */
425
445
  async function syncWithRsync(
426
- alias: string,
427
- localPath: string,
428
- remotePath: string,
429
- direction: 'upload' | 'download',
430
- options: {
431
- delete?: boolean;
432
- dryRun?: boolean;
433
- exclude?: string[];
434
- recursive?: boolean;
435
- }
446
+ alias: string,
447
+ localPath: string,
448
+ remotePath: string,
449
+ direction: 'upload' | 'download',
450
+ options: {
451
+ delete?: boolean;
452
+ dryRun?: boolean;
453
+ exclude?: string[];
454
+ recursive?: boolean;
455
+ },
436
456
  ): Promise<{
437
- success: boolean;
438
- method: 'rsync' | 'sftp';
439
- filesTransferred?: number;
440
- bytesTransferred?: number;
441
- output?: string;
457
+ success: boolean;
458
+ method: 'rsync' | 'sftp';
459
+ filesTransferred?: number;
460
+ bytesTransferred?: number;
461
+ output?: string;
442
462
  }> {
443
- // 检查本地是否有 rsync
444
- let hasLocalRsync = false;
445
- try {
446
- execSync('which rsync', { stdio: 'pipe' });
447
- hasLocalRsync = true;
448
- } catch {}
449
-
450
- if (!hasLocalRsync) {
451
- // 本地没有 rsync,回退到 SFTP
452
- return syncWithSftp(alias, localPath, remotePath, direction, options);
453
- }
454
-
455
- // 获取会话信息以构建 rsync 命令
456
- const sessions = sessionManager.listSessions();
457
- const sessionInfo = sessions.find(s => s.alias === alias);
458
- if (!sessionInfo) {
459
- throw new Error(`Session '${alias}' not found`);
460
- }
461
-
462
- // 构建 rsync 参数
463
- const args: string[] = ['-avz', '--progress'];
464
-
465
- if (options.delete) {
466
- args.push('--delete');
467
- }
468
- if (options.dryRun) {
469
- args.push('--dry-run');
470
- }
471
- if (options.recursive === false) {
472
- args.push('--dirs'); // 不递归,只传输目录本身
473
- }
474
- if (options.exclude) {
475
- for (const pattern of options.exclude) {
476
- args.push(`--exclude=${escapeShellPath(pattern)}`);
463
+ // 检查本地是否有 rsync
464
+ let hasLocalRsync = false
465
+ try {
466
+ execSync('which rsync', {stdio: 'pipe'})
467
+ hasLocalRsync = true
468
+ } catch {
469
+ }
470
+
471
+ if (!hasLocalRsync) {
472
+ // 本地没有 rsync,回退到 SFTP
473
+ return syncWithSftp(alias, localPath, remotePath, direction, options)
474
+ }
475
+
476
+ // 获取会话信息以构建 rsync 命令
477
+ const sessions = sessionManager.listSessions()
478
+ const sessionInfo = sessions.find(s => s.alias === alias)
479
+ if (!sessionInfo) {
480
+ throw new Error(`Session '${alias}' not found`)
481
+ }
482
+
483
+ // 构建 rsync 参数
484
+ const args: string[] = ['-avz', '--progress']
485
+
486
+ if (options.delete) {
487
+ args.push('--delete')
488
+ }
489
+ if (options.dryRun) {
490
+ args.push('--dry-run')
477
491
  }
478
- }
479
-
480
- // 构建 rsync 命令(本地执行)
481
- // 注意:这需要密钥认证或 ssh-agent,密码认证不支持
482
- const sshCmd = `ssh -p ${sessionInfo.port} -o StrictHostKeyChecking=no -o BatchMode=yes`;
483
- const remoteSpec = `${sessionInfo.username}@${sessionInfo.host}:${escapeShellPath(remotePath)}`;
484
- const rsyncCmd = direction === 'upload'
485
- ? `rsync ${args.join(' ')} -e "${sshCmd}" ${escapeShellPath(localPath)} ${remoteSpec}`
486
- : `rsync ${args.join(' ')} -e "${sshCmd}" ${remoteSpec} ${escapeShellPath(localPath)}`;
487
-
488
- try {
489
- const result = execSync(rsyncCmd, {
490
- encoding: 'utf-8',
491
- timeout: 600000, // 10 分钟超时
492
- stdio: ['pipe', 'pipe', 'pipe']
493
- });
494
-
495
- // 解析 rsync 输出统计文件数
496
- const lines = result.split('\n');
497
- let filesTransferred = 0;
498
- for (const line of lines) {
499
- if (line.trim() && !line.startsWith('sending') && !line.startsWith('receiving') && !line.startsWith('total')) {
500
- filesTransferred++;
501
- }
492
+ if (options.recursive === false) {
493
+ args.push('--dirs') // 不递归,只传输目录本身
502
494
  }
495
+ if (options.exclude) {
496
+ for (const pattern of options.exclude) {
497
+ args.push(`--exclude=${escapeShellPath(pattern)}`)
498
+ }
499
+ }
500
+
501
+ // 构建 rsync 命令(本地执行)
502
+ // 注意:这需要密钥认证或 ssh-agent,密码认证不支持
503
+ const sshCmd = `ssh -p ${sessionInfo.port} -o StrictHostKeyChecking=no -o BatchMode=yes`
504
+ const remoteSpec = `${sessionInfo.username}@${sessionInfo.host}:${escapeShellPath(remotePath)}`
505
+ const rsyncCmd = direction === 'upload'
506
+ ? `rsync ${args.join(' ')} -e "${sshCmd}" ${escapeShellPath(localPath)} ${remoteSpec}`
507
+ : `rsync ${args.join(' ')} -e "${sshCmd}" ${remoteSpec} ${escapeShellPath(localPath)}`
508
+
509
+ try {
510
+ const result = execSync(rsyncCmd, {
511
+ encoding: 'utf-8',
512
+ timeout: 600000, // 10 分钟超时
513
+ stdio: ['pipe', 'pipe', 'pipe'],
514
+ })
515
+
516
+ // 解析 rsync 输出统计文件数
517
+ const lines = result.split('\n')
518
+ let filesTransferred = 0
519
+ for (const line of lines) {
520
+ if (line.trim() &&
521
+ !line.startsWith('sending') &&
522
+ !line.startsWith('receiving') &&
523
+ !line.startsWith('total')) {
524
+ filesTransferred++
525
+ }
526
+ }
503
527
 
504
- return {
505
- success: true,
506
- method: 'rsync',
507
- filesTransferred,
508
- output: result,
509
- };
510
- } catch {
511
- // rsync 失败(可能是密码认证),回退到 SFTP
512
- return syncWithSftp(alias, localPath, remotePath, direction, options);
513
- }
528
+ return {
529
+ success: true,
530
+ method: 'rsync',
531
+ filesTransferred,
532
+ output: result,
533
+ }
534
+ } catch {
535
+ // rsync 失败(可能是密码认证),回退到 SFTP
536
+ return syncWithSftp(alias, localPath, remotePath, direction, options)
537
+ }
514
538
  }
515
539
 
516
540
  /**
517
541
  * 使用 SFTP 同步文件
518
542
  */
519
543
  async function syncWithSftp(
520
- alias: string,
521
- localPath: string,
522
- remotePath: string,
523
- direction: 'upload' | 'download',
524
- options: {
525
- delete?: boolean;
526
- dryRun?: boolean;
527
- exclude?: string[];
528
- recursive?: boolean;
529
- }
544
+ alias: string,
545
+ localPath: string,
546
+ remotePath: string,
547
+ direction: 'upload' | 'download',
548
+ options: {
549
+ delete?: boolean;
550
+ dryRun?: boolean;
551
+ exclude?: string[];
552
+ recursive?: boolean;
553
+ },
530
554
  ): Promise<{
531
- success: boolean;
532
- method: 'rsync' | 'sftp';
533
- filesTransferred?: number;
534
- bytesTransferred?: number;
535
- output?: string;
555
+ success: boolean;
556
+ method: 'rsync' | 'sftp';
557
+ filesTransferred?: number;
558
+ bytesTransferred?: number;
559
+ output?: string;
536
560
  }> {
537
- // SFTP 模式不支持 delete 选项
538
- const warnings: string[] = [];
539
- if (options.delete) {
540
- warnings.push('delete option is not supported in SFTP mode (requires rsync)');
541
- }
542
-
543
- if (options.dryRun) {
544
- return {
545
- success: true,
546
- method: 'sftp',
547
- output: 'Dry run mode: would transfer files via SFTP' + (warnings.length ? `. Warning: ${warnings.join('; ')}` : ''),
548
- };
549
- }
550
-
551
- try {
552
- let result: { fileCount: number; totalSize: number } | { success: boolean; size: number };
553
-
554
- if (direction === 'upload') {
555
- // 检查是否是目录
556
- const stats = fs.statSync(localPath);
557
- if (stats.isDirectory() && options.recursive !== false) {
558
- result = await uploadDirectory(alias, localPath, remotePath, options.exclude);
559
- return {
560
- success: true,
561
- method: 'sftp',
562
- filesTransferred: result.fileCount,
563
- bytesTransferred: result.totalSize,
564
- output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
565
- };
566
- } else {
567
- result = await uploadFile(alias, localPath, remotePath);
568
- return {
569
- success: result.success,
570
- method: 'sftp',
571
- filesTransferred: 1,
572
- bytesTransferred: result.size,
573
- output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
574
- };
575
- }
576
- } else {
577
- // 下载
578
- const info = await getFileInfo(alias, remotePath);
579
- if (info.isDirectory && options.recursive !== false) {
580
- result = await downloadDirectory(alias, remotePath, localPath, options.exclude);
561
+ // SFTP 模式不支持 delete 选项
562
+ const warnings: string[] = []
563
+ if (options.delete) {
564
+ warnings.push('delete option is not supported in SFTP mode (requires rsync)')
565
+ }
566
+
567
+ if (options.dryRun) {
581
568
  return {
582
- success: true,
583
- method: 'sftp',
584
- filesTransferred: result.fileCount,
585
- bytesTransferred: result.totalSize,
586
- output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
587
- };
588
- } else {
589
- result = await downloadFile(alias, remotePath, localPath);
569
+ success: true,
570
+ method: 'sftp',
571
+ output: 'Dry run mode: would transfer files via SFTP' +
572
+ (warnings.length ? `. Warning: ${warnings.join('; ')}` : ''),
573
+ }
574
+ }
575
+
576
+ try {
577
+ let result: { fileCount: number; totalSize: number } | { success: boolean; size: number }
578
+
579
+ if (direction === 'upload') {
580
+ // 检查是否是目录
581
+ const stats = fs.statSync(localPath)
582
+ if (stats.isDirectory() && options.recursive !== false) {
583
+ result = await uploadDirectory(alias, localPath, remotePath, options.exclude)
584
+ return {
585
+ success: true,
586
+ method: 'sftp',
587
+ filesTransferred: result.fileCount,
588
+ bytesTransferred: result.totalSize,
589
+ output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
590
+ }
591
+ } else {
592
+ result = await uploadFile(alias, localPath, remotePath)
593
+ return {
594
+ success: result.success,
595
+ method: 'sftp',
596
+ filesTransferred: 1,
597
+ bytesTransferred: result.size,
598
+ output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
599
+ }
600
+ }
601
+ } else {
602
+ // 下载
603
+ const info = await getFileInfo(alias, remotePath)
604
+ if (info.isDirectory && options.recursive !== false) {
605
+ result = await downloadDirectory(alias, remotePath, localPath, options.exclude)
606
+ return {
607
+ success: true,
608
+ method: 'sftp',
609
+ filesTransferred: result.fileCount,
610
+ bytesTransferred: result.totalSize,
611
+ output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
612
+ }
613
+ } else {
614
+ result = await downloadFile(alias, remotePath, localPath)
615
+ return {
616
+ success: result.success,
617
+ method: 'sftp',
618
+ filesTransferred: 1,
619
+ bytesTransferred: result.size,
620
+ output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
621
+ }
622
+ }
623
+ }
624
+ } catch (err: any) {
590
625
  return {
591
- success: result.success,
592
- method: 'sftp',
593
- filesTransferred: 1,
594
- bytesTransferred: result.size,
595
- output: warnings.length ? `Warning: ${warnings.join('; ')}` : undefined,
596
- };
597
- }
626
+ success: false,
627
+ method: 'sftp',
628
+ output: err.message,
629
+ }
598
630
  }
599
- } catch (err: any) {
600
- return {
601
- success: false,
602
- method: 'sftp',
603
- output: err.message,
604
- };
605
- }
606
631
  }
607
632
 
608
633
  /**
609
634
  * 递归上传目录
610
635
  */
611
636
  async function uploadDirectory(
612
- alias: string,
613
- localPath: string,
614
- remotePath: string,
615
- exclude?: string[]
637
+ alias: string,
638
+ localPath: string,
639
+ remotePath: string,
640
+ exclude?: string[],
616
641
  ): Promise<{ fileCount: number; totalSize: number }> {
617
- let fileCount = 0;
618
- let totalSize = 0;
619
-
620
- // 确保远程目录存在
621
- await mkdir(alias, remotePath, true);
622
-
623
- const items = fs.readdirSync(localPath);
624
- for (const item of items) {
625
- // 检查排除模式
626
- if (exclude && exclude.some(pattern => matchPattern(item, pattern))) {
627
- continue;
628
- }
629
-
630
- const itemLocalPath = path.join(localPath, item);
631
- const itemRemotePath = path.posix.join(remotePath, item);
632
- const stats = fs.statSync(itemLocalPath);
633
-
634
- if (stats.isDirectory()) {
635
- const result = await uploadDirectory(alias, itemLocalPath, itemRemotePath, exclude);
636
- fileCount += result.fileCount;
637
- totalSize += result.totalSize;
638
- } else if (stats.isFile()) {
639
- await uploadFile(alias, itemLocalPath, itemRemotePath);
640
- fileCount++;
641
- totalSize += stats.size;
642
+ let fileCount = 0
643
+ let totalSize = 0
644
+
645
+ // 确保远程目录存在
646
+ await mkdir(alias, remotePath, true)
647
+
648
+ const items = fs.readdirSync(localPath)
649
+ for (const item of items) {
650
+ // 检查排除模式
651
+ if (exclude && exclude.some(pattern => matchPattern(item, pattern))) {
652
+ continue
653
+ }
654
+
655
+ const itemLocalPath = path.join(localPath, item)
656
+ const itemRemotePath = path.posix.join(remotePath, item)
657
+ const stats = fs.statSync(itemLocalPath)
658
+
659
+ if (stats.isDirectory()) {
660
+ const result = await uploadDirectory(alias, itemLocalPath, itemRemotePath, exclude)
661
+ fileCount += result.fileCount
662
+ totalSize += result.totalSize
663
+ } else if (stats.isFile()) {
664
+ await uploadFile(alias, itemLocalPath, itemRemotePath)
665
+ fileCount++
666
+ totalSize += stats.size
667
+ }
642
668
  }
643
- }
644
669
 
645
- return { fileCount, totalSize };
670
+ return {fileCount, totalSize}
646
671
  }
647
672
 
648
673
  /**
649
674
  * 递归下载目录
650
675
  */
651
676
  async function downloadDirectory(
652
- alias: string,
653
- remotePath: string,
654
- localPath: string,
655
- exclude?: string[]
677
+ alias: string,
678
+ remotePath: string,
679
+ localPath: string,
680
+ exclude?: string[],
656
681
  ): Promise<{ fileCount: number; totalSize: number }> {
657
- let fileCount = 0;
658
- let totalSize = 0;
659
-
660
- // 确保本地目录存在
661
- if (!fs.existsSync(localPath)) {
662
- fs.mkdirSync(localPath, { recursive: true });
663
- }
664
-
665
- const items = await listDir(alias, remotePath, true);
666
- for (const item of items) {
667
- // 检查排除模式
668
- if (exclude && exclude.some(pattern => matchPattern(item.name, pattern))) {
669
- continue;
670
- }
682
+ let fileCount = 0
683
+ let totalSize = 0
671
684
 
672
- const itemLocalPath = path.join(localPath, item.name);
685
+ // 确保本地目录存在
686
+ if (!fs.existsSync(localPath)) {
687
+ fs.mkdirSync(localPath, {recursive: true})
688
+ }
673
689
 
674
- if (item.isDirectory) {
675
- const result = await downloadDirectory(alias, item.path, itemLocalPath, exclude);
676
- fileCount += result.fileCount;
677
- totalSize += result.totalSize;
678
- } else if (item.isFile) {
679
- await downloadFile(alias, item.path, itemLocalPath);
680
- fileCount++;
681
- totalSize += item.size;
690
+ const items = await listDir(alias, remotePath, true)
691
+ for (const item of items) {
692
+ // 检查排除模式
693
+ if (exclude && exclude.some(pattern => matchPattern(item.name, pattern))) {
694
+ continue
695
+ }
696
+
697
+ const itemLocalPath = path.join(localPath, item.name)
698
+
699
+ if (item.isDirectory) {
700
+ const result = await downloadDirectory(alias, item.path, itemLocalPath, exclude)
701
+ fileCount += result.fileCount
702
+ totalSize += result.totalSize
703
+ } else if (item.isFile) {
704
+ await downloadFile(alias, item.path, itemLocalPath)
705
+ fileCount++
706
+ totalSize += item.size
707
+ }
682
708
  }
683
- }
684
709
 
685
- return { fileCount, totalSize };
710
+ return {fileCount, totalSize}
686
711
  }
687
712
 
688
713
  /**
689
714
  * 简单的模式匹配(支持 * 和 ?)
690
715
  */
691
716
  function matchPattern(name: string, pattern: string): boolean {
692
- const regexPattern = pattern
693
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // 转义特殊字符
694
- .replace(/\*/g, '.*')
695
- .replace(/\?/g, '.');
696
- return new RegExp(`^${regexPattern}$`).test(name);
717
+ const regexPattern = pattern
718
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // 转义特殊字符
719
+ .replace(/\*/g, '.*')
720
+ .replace(/\?/g, '.')
721
+ return new RegExp(`^${regexPattern}$`).test(name)
697
722
  }
698
723
 
699
724
  /**
700
725
  * 格式化权限字符串
701
726
  */
702
727
  function formatPermissions(mode: number): string {
703
- const types: Record<number, string> = {
704
- 0o40000: 'd',
705
- 0o120000: 'l',
706
- 0o100000: '-',
707
- };
708
-
709
- let type = '-';
710
- for (const [mask, char] of Object.entries(types)) {
711
- if ((mode & parseInt(mask)) !== 0) {
712
- type = char;
713
- break;
728
+ const types: Record<number, string> = {
729
+ 0o40000: 'd',
730
+ 0o120000: 'l',
731
+ 0o100000: '-',
714
732
  }
715
- }
716
-
717
- const perms = [
718
- (mode & 0o400) ? 'r' : '-',
719
- (mode & 0o200) ? 'w' : '-',
720
- (mode & 0o100) ? 'x' : '-',
721
- (mode & 0o040) ? 'r' : '-',
722
- (mode & 0o020) ? 'w' : '-',
723
- (mode & 0o010) ? 'x' : '-',
724
- (mode & 0o004) ? 'r' : '-',
725
- (mode & 0o002) ? 'w' : '-',
726
- (mode & 0o001) ? 'x' : '-',
727
- ];
728
-
729
- return type + perms.join('');
733
+
734
+ let type = '-'
735
+ for (const [mask, char] of Object.entries(types)) {
736
+ if ((mode & parseInt(mask)) !== 0) {
737
+ type = char
738
+ break
739
+ }
740
+ }
741
+
742
+ const perms = [
743
+ (mode & 0o400) ? 'r' : '-',
744
+ (mode & 0o200) ? 'w' : '-',
745
+ (mode & 0o100) ? 'x' : '-',
746
+ (mode & 0o040) ? 'r' : '-',
747
+ (mode & 0o020) ? 'w' : '-',
748
+ (mode & 0o010) ? 'x' : '-',
749
+ (mode & 0o004) ? 'r' : '-',
750
+ (mode & 0o002) ? 'w' : '-',
751
+ (mode & 0o001) ? 'x' : '-',
752
+ ]
753
+
754
+ return type + perms.join('')
730
755
  }