@pyrokine/mcp-ssh 1.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.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * SSH File Operations - 文件操作
3
+ */
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';
11
+
12
+ /**
13
+ * 上传文件
14
+ */
15
+ export async function uploadFile(
16
+ alias: string,
17
+ localPath: string,
18
+ remotePath: string,
19
+ onProgress?: (progress: TransferProgress) => void
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
+ });
67
+ }
68
+
69
+ /**
70
+ * 下载文件
71
+ */
72
+ export async function downloadFile(
73
+ alias: string,
74
+ remotePath: string,
75
+ localPath: string,
76
+ onProgress?: (progress: TransferProgress) => void
77
+ ): 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
+ });
133
+ }
134
+
135
+ /**
136
+ * 读取远程文件内容
137
+ */
138
+ export async function readFile(
139
+ alias: string,
140
+ remotePath: string,
141
+ maxBytes: number = 1024 * 1024 // 默认最大 1MB
142
+ ): 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
+ });
191
+ }
192
+
193
+ /**
194
+ * 写入远程文件
195
+ */
196
+ export async function writeFile(
197
+ alias: string,
198
+ remotePath: string,
199
+ content: string,
200
+ append: boolean = false
201
+ ): Promise<{ success: boolean; size: number }> {
202
+ const sftp = await sessionManager.getSftp(alias);
203
+ const flags = append ? 'a' : 'w';
204
+
205
+ return new Promise((resolve, reject) => {
206
+ const writeStream = sftp.createWriteStream(remotePath, { flags });
207
+
208
+ writeStream.on('close', () => {
209
+ sftp.end();
210
+ resolve({ success: true, size: content.length });
211
+ });
212
+
213
+ writeStream.on('error', (err: Error) => {
214
+ sftp.end();
215
+ reject(err);
216
+ });
217
+
218
+ writeStream.write(content);
219
+ writeStream.end();
220
+ });
221
+ }
222
+
223
+ /**
224
+ * 列出目录内容
225
+ */
226
+ export async function listDir(
227
+ alias: string,
228
+ remotePath: string,
229
+ showHidden: boolean = false
230
+ ): 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
+ });
268
+ }
269
+
270
+ /**
271
+ * 获取文件信息
272
+ */
273
+ export async function getFileInfo(
274
+ alias: string,
275
+ remotePath: string
276
+ ): 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
+ });
303
+ }
304
+
305
+ /**
306
+ * 检查文件是否存在
307
+ */
308
+ export async function fileExists(
309
+ alias: string,
310
+ remotePath: string
311
+ ): 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
+ });
320
+ }
321
+
322
+ /**
323
+ * 创建目录
324
+ */
325
+ export async function mkdir(
326
+ alias: string,
327
+ remotePath: string,
328
+ recursive: boolean = false
329
+ ): 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
+ }
345
+
346
+ /**
347
+ * 删除文件
348
+ */
349
+ export async function removeFile(
350
+ alias: string,
351
+ remotePath: string
352
+ ): 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
+ });
361
+ }
362
+
363
+ /**
364
+ * 检查远程是否安装 rsync
365
+ */
366
+ 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
+ }
373
+ }
374
+
375
+ /**
376
+ * 智能文件同步(优先使用 rsync)
377
+ *
378
+ * @param alias SSH 连接别名
379
+ * @param localPath 本地路径
380
+ * @param remotePath 远程路径
381
+ * @param direction 同步方向:'upload' 或 'download'
382
+ * @param options 同步选项
383
+ */
384
+ 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
+ } = {}
395
+ ): Promise<{
396
+ success: boolean;
397
+ method: 'rsync' | 'sftp';
398
+ filesTransferred?: number;
399
+ bytesTransferred?: number;
400
+ output?: string;
401
+ }> {
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
+ }
412
+ }
413
+
414
+ /**
415
+ * 转义 shell 路径参数
416
+ */
417
+ function escapeShellPath(p: string): string {
418
+ return `'${p.replace(/'/g, "'\\''")}'`;
419
+ }
420
+
421
+ /**
422
+ * 使用 rsync 同步文件
423
+ * 通过本地执行 rsync 连接到远程(需要密钥认证或 ssh-agent)
424
+ */
425
+ 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
+ }
436
+ ): Promise<{
437
+ success: boolean;
438
+ method: 'rsync' | 'sftp';
439
+ filesTransferred?: number;
440
+ bytesTransferred?: number;
441
+ output?: string;
442
+ }> {
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)}`);
477
+ }
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
+ }
502
+ }
503
+
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
+ }
514
+ }
515
+
516
+ /**
517
+ * 使用 SFTP 同步文件
518
+ */
519
+ 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
+ }
530
+ ): Promise<{
531
+ success: boolean;
532
+ method: 'rsync' | 'sftp';
533
+ filesTransferred?: number;
534
+ bytesTransferred?: number;
535
+ output?: string;
536
+ }> {
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);
581
+ 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);
590
+ 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
+ }
598
+ }
599
+ } catch (err: any) {
600
+ return {
601
+ success: false,
602
+ method: 'sftp',
603
+ output: err.message,
604
+ };
605
+ }
606
+ }
607
+
608
+ /**
609
+ * 递归上传目录
610
+ */
611
+ async function uploadDirectory(
612
+ alias: string,
613
+ localPath: string,
614
+ remotePath: string,
615
+ exclude?: string[]
616
+ ): 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
+ }
643
+ }
644
+
645
+ return { fileCount, totalSize };
646
+ }
647
+
648
+ /**
649
+ * 递归下载目录
650
+ */
651
+ async function downloadDirectory(
652
+ alias: string,
653
+ remotePath: string,
654
+ localPath: string,
655
+ exclude?: string[]
656
+ ): 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
+ }
671
+
672
+ const itemLocalPath = path.join(localPath, item.name);
673
+
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;
682
+ }
683
+ }
684
+
685
+ return { fileCount, totalSize };
686
+ }
687
+
688
+ /**
689
+ * 简单的模式匹配(支持 * 和 ?)
690
+ */
691
+ 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);
697
+ }
698
+
699
+ /**
700
+ * 格式化权限字符串
701
+ */
702
+ 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;
714
+ }
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('');
730
+ }