@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.
- package/CHANGELOG.md +35 -0
- package/README.md +5 -3
- package/agent/progress.yaml +62 -13
- package/agent/tasks/task-5-fix-incomplete-directory-listings.md +170 -0
- package/dist/server-factory.js +169 -22
- package/dist/server-factory.js.map +4 -4
- package/dist/server.js +169 -22
- package/dist/server.js.map +4 -4
- package/dist/types/file-entry.d.ts +88 -0
- package/dist/utils/ssh-connection.d.ts +13 -5
- package/package.json +1 -1
- package/src/tools/acp-remote-list-files.ts +27 -21
- package/src/types/file-entry.ts +123 -0
- package/src/utils/ssh-connection.ts +123 -5
|
@@ -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
|
|
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
|
|
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
|
-
|
|
289
|
+
let entries = list.map((item) => ({
|
|
195
290
|
name: item.filename,
|
|
196
|
-
|
|
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
|
-
|
|
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
|
}
|