@prmichaelsen/acp-mcp 0.5.1 → 0.6.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,123 @@
1
+ /**
2
+ * Comprehensive file entry with metadata
3
+ */
4
+ export interface FileEntry {
5
+ /** Filename (without path) */
6
+ name: string;
7
+
8
+ /** Absolute path to file */
9
+ path: string;
10
+
11
+ /** File type */
12
+ type: 'file' | 'directory' | 'symlink' | 'other';
13
+
14
+ /** File size in bytes */
15
+ size: number;
16
+
17
+ /** File permissions */
18
+ permissions: {
19
+ /** Octal mode (e.g., 0o644) */
20
+ mode: number;
21
+
22
+ /** Human-readable string (e.g., "rw-r--r--") */
23
+ string: string;
24
+
25
+ /** Owner permissions */
26
+ owner: {
27
+ read: boolean;
28
+ write: boolean;
29
+ execute: boolean;
30
+ };
31
+
32
+ /** Group permissions */
33
+ group: {
34
+ read: boolean;
35
+ write: boolean;
36
+ execute: boolean;
37
+ };
38
+
39
+ /** Others permissions */
40
+ others: {
41
+ read: boolean;
42
+ write: boolean;
43
+ execute: boolean;
44
+ };
45
+ };
46
+
47
+ /** File ownership */
48
+ owner: {
49
+ /** User ID */
50
+ uid: number;
51
+
52
+ /** Group ID */
53
+ gid: number;
54
+ };
55
+
56
+ /** File timestamps */
57
+ timestamps: {
58
+ /** Last access time (ISO 8601) */
59
+ accessed: string;
60
+
61
+ /** Last modification time (ISO 8601) */
62
+ modified: string;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Convert Unix mode to human-readable permission string
68
+ * @param mode - Unix file mode (e.g., 33188 for -rw-r--r--)
69
+ * @returns Permission string (e.g., "rw-r--r--")
70
+ */
71
+ export function modeToPermissionString(mode: number): string {
72
+ const perms = [
73
+ (mode & 0o400) ? 'r' : '-',
74
+ (mode & 0o200) ? 'w' : '-',
75
+ (mode & 0o100) ? 'x' : '-',
76
+ (mode & 0o040) ? 'r' : '-',
77
+ (mode & 0o020) ? 'w' : '-',
78
+ (mode & 0o010) ? 'x' : '-',
79
+ (mode & 0o004) ? 'r' : '-',
80
+ (mode & 0o002) ? 'w' : '-',
81
+ (mode & 0o001) ? 'x' : '-',
82
+ ];
83
+ return perms.join('');
84
+ }
85
+
86
+ /**
87
+ * Parse Unix mode into structured permissions object
88
+ * @param mode - Unix file mode
89
+ * @returns Structured permissions object
90
+ */
91
+ export function parsePermissions(mode: number) {
92
+ return {
93
+ mode,
94
+ string: modeToPermissionString(mode),
95
+ owner: {
96
+ read: (mode & 0o400) !== 0,
97
+ write: (mode & 0o200) !== 0,
98
+ execute: (mode & 0o100) !== 0,
99
+ },
100
+ group: {
101
+ read: (mode & 0o040) !== 0,
102
+ write: (mode & 0o020) !== 0,
103
+ execute: (mode & 0o010) !== 0,
104
+ },
105
+ others: {
106
+ read: (mode & 0o004) !== 0,
107
+ write: (mode & 0o002) !== 0,
108
+ execute: (mode & 0o001) !== 0,
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Determine file type from SFTP stats
115
+ * @param stats - SFTP file stats
116
+ * @returns File type string
117
+ */
118
+ export function getFileType(stats: any): 'file' | 'directory' | 'symlink' | 'other' {
119
+ if (stats.isDirectory()) return 'directory';
120
+ if (stats.isFile()) return 'file';
121
+ if (stats.isSymbolicLink()) return 'symlink';
122
+ return 'other';
123
+ }
@@ -1,5 +1,6 @@
1
1
  import { Client, SFTPWrapper } from 'ssh2';
2
2
  import { SSHConfig } from '../types/ssh-config.js';
3
+ import { FileEntry, parsePermissions, getFileType } from '../types/file-entry.js';
3
4
  import { logger } from './logger.js';
4
5
 
5
6
  /**
@@ -179,24 +180,141 @@ export class SSHConnectionManager {
179
180
  }
180
181
 
181
182
  /**
182
- * List files in a directory using SFTP
183
+ * List files in a directory with comprehensive metadata
184
+ * Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
185
+ *
186
+ * @param path - Directory path to list
187
+ * @param includeHidden - Whether to include hidden files (default: true)
188
+ * @returns Array of FileEntry objects with complete metadata
183
189
  */
184
- async listFiles(path: string): Promise<Array<{ name: string; isDirectory: boolean }>> {
190
+ async listFiles(path: string, includeHidden: boolean = true): Promise<FileEntry[]> {
191
+ const startTime = Date.now();
192
+ logger.debug('Listing files', { path, includeHidden });
193
+
194
+ try {
195
+ // Step 1: Use shell command to get ALL filenames (including hidden)
196
+ const lsFlag = includeHidden ? '-A' : '';
197
+ const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
198
+ const result = await this.execWithTimeout(command, 10);
199
+
200
+ if (result.exitCode !== 0) {
201
+ throw new Error(`ls command failed: ${result.stderr}`);
202
+ }
203
+
204
+ const filenames = result.stdout
205
+ .split('\n')
206
+ .map(f => f.trim())
207
+ .filter(f => f !== '' && f !== '.' && f !== '..');
208
+
209
+ logger.debug('Filenames retrieved via shell', {
210
+ path,
211
+ count: filenames.length,
212
+ method: 'shell',
213
+ });
214
+
215
+ // Step 2: Get rich metadata for each file using SFTP stat()
216
+ const sftp = await this.getSFTP();
217
+ const entries: FileEntry[] = [];
218
+
219
+ for (const filename of filenames) {
220
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
221
+
222
+ try {
223
+ const stats = await new Promise<any>((resolve, reject) => {
224
+ sftp.stat(fullPath, (err, stats) => {
225
+ if (err) reject(err);
226
+ else resolve(stats);
227
+ });
228
+ });
229
+
230
+ entries.push({
231
+ name: filename,
232
+ path: fullPath,
233
+ type: getFileType(stats),
234
+ size: stats.size,
235
+ permissions: parsePermissions(stats.mode),
236
+ owner: {
237
+ uid: stats.uid,
238
+ gid: stats.gid,
239
+ },
240
+ timestamps: {
241
+ accessed: new Date(stats.atime * 1000).toISOString(),
242
+ modified: new Date(stats.mtime * 1000).toISOString(),
243
+ },
244
+ });
245
+ } catch (error) {
246
+ // Skip files we can't stat (permissions, race conditions, etc.)
247
+ logger.warn('Failed to stat file, skipping', {
248
+ path: fullPath,
249
+ error: error instanceof Error ? error.message : String(error),
250
+ });
251
+ }
252
+ }
253
+
254
+ const duration = Date.now() - startTime;
255
+ logger.debug('Files listed successfully', {
256
+ path,
257
+ count: entries.length,
258
+ duration: `${duration}ms`,
259
+ method: 'hybrid',
260
+ });
261
+
262
+ return entries;
263
+ } catch (error) {
264
+ // Fallback to SFTP readdir if shell command fails
265
+ logger.warn('Shell ls command failed, falling back to SFTP readdir', {
266
+ path,
267
+ error: error instanceof Error ? error.message : String(error),
268
+ });
269
+
270
+ return this.listFilesViaSFTP(path, includeHidden);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Fallback method: List files using SFTP readdir (may miss hidden files)
276
+ * @private
277
+ */
278
+ private async listFilesViaSFTP(path: string, includeHidden: boolean): Promise<FileEntry[]> {
185
279
  const sftp = await this.getSFTP();
186
280
 
187
281
  return new Promise((resolve, reject) => {
188
282
  sftp.readdir(path, (err, list) => {
189
283
  if (err) {
284
+ logger.error('SFTP readdir failed', { path, error: err.message });
190
285
  reject(err);
191
286
  return;
192
287
  }
193
288
 
194
- const files = list.map((item) => ({
289
+ let entries = list.map((item) => ({
195
290
  name: item.filename,
196
- isDirectory: item.attrs.isDirectory(),
291
+ path: `${path}/${item.filename}`.replace(/\/+/g, '/'),
292
+ type: getFileType(item.attrs),
293
+ size: item.attrs.size,
294
+ permissions: parsePermissions(item.attrs.mode),
295
+ owner: {
296
+ uid: item.attrs.uid,
297
+ gid: item.attrs.gid,
298
+ },
299
+ timestamps: {
300
+ accessed: new Date(item.attrs.atime * 1000).toISOString(),
301
+ modified: new Date(item.attrs.mtime * 1000).toISOString(),
302
+ },
197
303
  }));
198
304
 
199
- resolve(files);
305
+ // SFTP readdir doesn't return hidden files, so filter if requested
306
+ if (!includeHidden) {
307
+ entries = entries.filter(e => !e.name.startsWith('.'));
308
+ }
309
+
310
+ logger.debug('Files listed via SFTP fallback', {
311
+ path,
312
+ count: entries.length,
313
+ method: 'sftp',
314
+ note: 'Hidden files may be missing (SFTP limitation)',
315
+ });
316
+
317
+ resolve(entries);
200
318
  });
201
319
  });
202
320
  }