@prmichaelsen/acp-mcp 0.5.1 → 0.7.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 +66 -0
- package/README.md +15 -6
- package/agent/design/local.progress-streaming.md +940 -0
- package/agent/milestones/milestone-4-progress-streaming-server.md +84 -0
- package/agent/milestones/milestone-5-progress-streaming-wrapper.md +71 -0
- package/agent/milestones/milestone-6-progress-streaming-client.md +79 -0
- package/agent/progress.yaml +145 -16
- package/agent/tasks/milestone-4-progress-streaming-server/task-6-add-ssh-stream-execution.md +149 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-7-implement-progress-streaming.md +191 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-8-update-server-handlers.md +109 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-9-testing-documentation.md +192 -0
- package/agent/tasks/task-5-fix-incomplete-directory-listings.md +170 -0
- package/dist/server-factory.js +299 -28
- package/dist/server-factory.js.map +4 -4
- package/dist/server.js +299 -28
- package/dist/server.js.map +4 -4
- package/dist/tools/acp-remote-execute-command.d.ts +4 -1
- package/dist/types/file-entry.d.ts +88 -0
- package/dist/utils/ssh-connection.d.ts +26 -5
- package/package.json +1 -1
- package/src/server-factory.ts +3 -2
- package/src/server.ts +3 -2
- package/src/tools/acp-remote-execute-command.ts +116 -7
- package/src/tools/acp-remote-list-files.ts +27 -21
- package/src/types/file-entry.ts +123 -0
- package/src/utils/ssh-connection.ts +189 -5
|
@@ -4,7 +4,7 @@ import { logger } from '../utils/logger.js';
|
|
|
4
4
|
|
|
5
5
|
export const acpRemoteExecuteCommandTool: Tool = {
|
|
6
6
|
name: 'acp_remote_execute_command',
|
|
7
|
-
description: 'Execute a shell command on the remote machine via SSH',
|
|
7
|
+
description: 'Execute a shell command on the remote machine via SSH. Supports real-time progress streaming if client provides progressToken.',
|
|
8
8
|
inputSchema: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
@@ -18,7 +18,7 @@ export const acpRemoteExecuteCommandTool: Tool = {
|
|
|
18
18
|
},
|
|
19
19
|
timeout: {
|
|
20
20
|
type: 'number',
|
|
21
|
-
description: 'Timeout in seconds (default: 30)',
|
|
21
|
+
description: 'Timeout in seconds (default: 30). Ignored if progress streaming is used.',
|
|
22
22
|
default: 30,
|
|
23
23
|
},
|
|
24
24
|
},
|
|
@@ -37,27 +37,38 @@ interface ExecuteCommandResult {
|
|
|
37
37
|
stderr: string;
|
|
38
38
|
exitCode: number;
|
|
39
39
|
timedOut: boolean;
|
|
40
|
+
streamed?: boolean;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Handle the acp_remote_execute_command tool invocation
|
|
44
45
|
* Executes a shell command on the remote machine via SSH
|
|
46
|
+
* Supports progress streaming when progressToken is provided
|
|
45
47
|
*
|
|
46
48
|
* @param args - Tool arguments containing command, cwd, and timeout
|
|
47
49
|
* @param sshConnection - SSH connection manager for remote operations
|
|
50
|
+
* @param extra - Optional extra parameters including progressToken
|
|
51
|
+
* @param server - Server instance for sending progress notifications (optional)
|
|
48
52
|
*/
|
|
49
53
|
export async function handleAcpRemoteExecuteCommand(
|
|
50
54
|
args: any,
|
|
51
|
-
sshConnection: SSHConnectionManager
|
|
55
|
+
sshConnection: SSHConnectionManager,
|
|
56
|
+
extra?: any,
|
|
57
|
+
server?: any
|
|
52
58
|
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
53
59
|
const { command, cwd, timeout = 30 } = args as ExecuteCommandArgs;
|
|
60
|
+
const progressToken = extra?._meta?.progressToken;
|
|
54
61
|
|
|
55
|
-
logger.debug('Executing remote command', { command, cwd, timeout });
|
|
62
|
+
logger.debug('Executing remote command', { command, cwd, timeout, hasProgressToken: !!progressToken });
|
|
56
63
|
|
|
57
64
|
try {
|
|
58
|
-
//
|
|
59
|
-
|
|
65
|
+
// If progress token provided and server available, use streaming
|
|
66
|
+
if (progressToken && server) {
|
|
67
|
+
return await executeWithProgress(command, cwd, sshConnection, progressToken, server);
|
|
68
|
+
}
|
|
60
69
|
|
|
70
|
+
// Otherwise, use existing timeout-based execution (fallback)
|
|
71
|
+
const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
|
|
61
72
|
const result = await sshConnection.execWithTimeout(fullCommand, timeout);
|
|
62
73
|
|
|
63
74
|
logger.debug('Command execution result', {
|
|
@@ -67,7 +78,6 @@ export async function handleAcpRemoteExecuteCommand(
|
|
|
67
78
|
stderrLength: result.stderr.length,
|
|
68
79
|
});
|
|
69
80
|
|
|
70
|
-
// Format output as JSON for structured response
|
|
71
81
|
const output: ExecuteCommandResult = {
|
|
72
82
|
stdout: result.stdout,
|
|
73
83
|
stderr: result.stderr,
|
|
@@ -101,3 +111,102 @@ export async function handleAcpRemoteExecuteCommand(
|
|
|
101
111
|
};
|
|
102
112
|
}
|
|
103
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Execute command with progress streaming
|
|
117
|
+
* Sends real-time progress notifications as output is received
|
|
118
|
+
*
|
|
119
|
+
* @param command - Command to execute
|
|
120
|
+
* @param cwd - Working directory
|
|
121
|
+
* @param sshConnection - SSH connection
|
|
122
|
+
* @param progressToken - Token for progress notifications
|
|
123
|
+
* @param server - Server instance for sending notifications
|
|
124
|
+
*/
|
|
125
|
+
async function executeWithProgress(
|
|
126
|
+
command: string,
|
|
127
|
+
cwd: string | undefined,
|
|
128
|
+
sshConnection: SSHConnectionManager,
|
|
129
|
+
progressToken: string | number,
|
|
130
|
+
server: any
|
|
131
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
132
|
+
logger.debug('Starting streaming execution', { command, cwd, progressToken });
|
|
133
|
+
|
|
134
|
+
const { stream, stderr: stderrStream, exitCode } = await sshConnection.execStream(command, cwd);
|
|
135
|
+
|
|
136
|
+
let stdout = '';
|
|
137
|
+
let stderr = '';
|
|
138
|
+
let bytesReceived = 0;
|
|
139
|
+
let lastProgressTime = 0;
|
|
140
|
+
const MIN_PROGRESS_INTERVAL = 100; // 100ms rate limiting
|
|
141
|
+
|
|
142
|
+
// Stream stdout with progress notifications
|
|
143
|
+
stream.on('data', (chunk: Buffer) => {
|
|
144
|
+
const text = chunk.toString();
|
|
145
|
+
stdout += text;
|
|
146
|
+
bytesReceived += chunk.length;
|
|
147
|
+
|
|
148
|
+
// Rate limiting: only send progress if enough time elapsed
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (now - lastProgressTime >= MIN_PROGRESS_INTERVAL) {
|
|
151
|
+
try {
|
|
152
|
+
server.notification({
|
|
153
|
+
method: 'notifications/progress',
|
|
154
|
+
params: {
|
|
155
|
+
progressToken,
|
|
156
|
+
progress: bytesReceived,
|
|
157
|
+
total: undefined, // Unknown total for streaming
|
|
158
|
+
message: text,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
lastProgressTime = now;
|
|
162
|
+
logger.debug('Progress notification sent', {
|
|
163
|
+
progressToken,
|
|
164
|
+
bytes: bytesReceived,
|
|
165
|
+
chunkSize: chunk.length
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.warn('Failed to send progress notification', {
|
|
169
|
+
error: error instanceof Error ? error.message : String(error)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Collect stderr (no progress for errors)
|
|
176
|
+
stderrStream.on('data', (chunk: Buffer) => {
|
|
177
|
+
stderr += chunk.toString();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Handle stream errors
|
|
181
|
+
stream.on('error', (error: Error) => {
|
|
182
|
+
logger.error('Stream error during execution', {
|
|
183
|
+
command,
|
|
184
|
+
error: error.message
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Wait for completion
|
|
189
|
+
const finalExitCode = await exitCode;
|
|
190
|
+
|
|
191
|
+
logger.debug('Streaming execution completed', {
|
|
192
|
+
command,
|
|
193
|
+
exitCode: finalExitCode,
|
|
194
|
+
stdoutBytes: stdout.length,
|
|
195
|
+
stderrBytes: stderr.length,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const output: ExecuteCommandResult = {
|
|
199
|
+
stdout,
|
|
200
|
+
stderr,
|
|
201
|
+
exitCode: finalExitCode,
|
|
202
|
+
timedOut: false,
|
|
203
|
+
streamed: true, // Indicate this was streamed
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
content: [{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: JSON.stringify(output, null, 2),
|
|
210
|
+
}],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { SSHConnectionManager } from '../utils/ssh-connection.js';
|
|
3
|
+
import { FileEntry } from '../types/file-entry.js';
|
|
3
4
|
|
|
4
5
|
export const acpRemoteListFilesTool: Tool = {
|
|
5
6
|
name: 'acp_remote_list_files',
|
|
6
|
-
description: 'List files and directories in a specified path on the remote machine via SSH',
|
|
7
|
+
description: 'List files and directories in a specified path on the remote machine via SSH. Returns comprehensive metadata including permissions, timestamps, size, and ownership. Includes hidden files by default.',
|
|
7
8
|
inputSchema: {
|
|
8
9
|
type: 'object',
|
|
9
10
|
properties: {
|
|
@@ -16,6 +17,11 @@ export const acpRemoteListFilesTool: Tool = {
|
|
|
16
17
|
description: 'Whether to list files recursively',
|
|
17
18
|
default: false,
|
|
18
19
|
},
|
|
20
|
+
includeHidden: {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
description: 'Whether to include hidden files (starting with .)',
|
|
23
|
+
default: true,
|
|
24
|
+
},
|
|
19
25
|
},
|
|
20
26
|
required: ['path'],
|
|
21
27
|
},
|
|
@@ -24,6 +30,7 @@ export const acpRemoteListFilesTool: Tool = {
|
|
|
24
30
|
interface ListFilesArgs {
|
|
25
31
|
path: string;
|
|
26
32
|
recursive?: boolean;
|
|
33
|
+
includeHidden?: boolean;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/**
|
|
@@ -37,16 +44,19 @@ export async function handleAcpRemoteListFiles(
|
|
|
37
44
|
args: any,
|
|
38
45
|
sshConnection: SSHConnectionManager
|
|
39
46
|
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
40
|
-
const { path, recursive = false } = args as ListFilesArgs;
|
|
47
|
+
const { path, recursive = false, includeHidden = true } = args as ListFilesArgs;
|
|
41
48
|
|
|
42
49
|
try {
|
|
43
|
-
const
|
|
50
|
+
const entries = await listRemoteFiles(sshConnection, path, recursive, includeHidden);
|
|
51
|
+
|
|
52
|
+
// Format as JSON for structured output
|
|
53
|
+
const output = JSON.stringify(entries, null, 2);
|
|
44
54
|
|
|
45
55
|
return {
|
|
46
56
|
content: [
|
|
47
57
|
{
|
|
48
58
|
type: 'text',
|
|
49
|
-
text:
|
|
59
|
+
text: output,
|
|
50
60
|
},
|
|
51
61
|
],
|
|
52
62
|
};
|
|
@@ -65,30 +75,26 @@ export async function handleAcpRemoteListFiles(
|
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* Recursively list files in a remote directory via SSH
|
|
68
|
-
* Returns
|
|
78
|
+
* Returns FileEntry objects with comprehensive metadata
|
|
69
79
|
*/
|
|
70
80
|
async function listRemoteFiles(
|
|
71
81
|
ssh: SSHConnectionManager,
|
|
72
82
|
dirPath: string,
|
|
73
|
-
recursive: boolean
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
83
|
+
recursive: boolean,
|
|
84
|
+
includeHidden: boolean
|
|
85
|
+
): Promise<FileEntry[]> {
|
|
86
|
+
const entries = await ssh.listFiles(dirPath, includeHidden);
|
|
87
|
+
const allEntries: FileEntry[] = [...entries];
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (recursive) {
|
|
85
|
-
const subFiles = await listRemoteFiles(ssh, fullPath, recursive);
|
|
86
|
-
files.push(...subFiles); // Sub-files already have absolute paths
|
|
89
|
+
// Recursively list subdirectories if requested
|
|
90
|
+
if (recursive) {
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.type === 'directory') {
|
|
93
|
+
const subEntries = await listRemoteFiles(ssh, entry.path, recursive, includeHidden);
|
|
94
|
+
allEntries.push(...subEntries);
|
|
87
95
|
}
|
|
88
|
-
} else {
|
|
89
|
-
files.push(fullPath); // Return absolute path
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
return
|
|
99
|
+
return allEntries;
|
|
94
100
|
}
|
|
@@ -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
|
/**
|
|
@@ -159,6 +160,72 @@ export class SSHConnectionManager {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Execute a command on the remote server with streaming output
|
|
165
|
+
* Returns streams instead of buffered output for real-time progress
|
|
166
|
+
*
|
|
167
|
+
* @param command - Shell command to execute
|
|
168
|
+
* @param cwd - Optional working directory
|
|
169
|
+
* @returns Object with stdout stream, stderr stream, and exit code promise
|
|
170
|
+
*/
|
|
171
|
+
async execStream(
|
|
172
|
+
command: string,
|
|
173
|
+
cwd?: string
|
|
174
|
+
): Promise<{
|
|
175
|
+
stream: NodeJS.ReadableStream;
|
|
176
|
+
stderr: NodeJS.ReadableStream;
|
|
177
|
+
exitCode: Promise<number>;
|
|
178
|
+
}> {
|
|
179
|
+
if (!this.connected) {
|
|
180
|
+
await this.connect();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const fullCommand = cwd ? `cd "${cwd}" && ${command}` : command;
|
|
184
|
+
const startTime = Date.now();
|
|
185
|
+
logger.sshCommand(fullCommand, cwd);
|
|
186
|
+
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
this.client.exec(fullCommand, (err, stream) => {
|
|
189
|
+
if (err) {
|
|
190
|
+
logger.error('SSH exec failed', {
|
|
191
|
+
command: fullCommand,
|
|
192
|
+
error: err.message
|
|
193
|
+
});
|
|
194
|
+
reject(err);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.debug('SSH stream started', { command: fullCommand });
|
|
199
|
+
|
|
200
|
+
const exitCodePromise = new Promise<number>((resolveExit) => {
|
|
201
|
+
stream.on('close', (code: number) => {
|
|
202
|
+
const duration = Date.now() - startTime;
|
|
203
|
+
logger.debug('SSH stream closed', {
|
|
204
|
+
command: fullCommand,
|
|
205
|
+
exitCode: code,
|
|
206
|
+
duration: `${duration}ms`
|
|
207
|
+
});
|
|
208
|
+
resolveExit(code);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Handle stream errors
|
|
213
|
+
stream.on('error', (error: Error) => {
|
|
214
|
+
logger.error('SSH stream error', {
|
|
215
|
+
command: fullCommand,
|
|
216
|
+
error: error.message
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
resolve({
|
|
221
|
+
stream: stream,
|
|
222
|
+
stderr: stream.stderr,
|
|
223
|
+
exitCode: exitCodePromise,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
/**
|
|
163
230
|
* Get SFTP wrapper for file operations
|
|
164
231
|
*/
|
|
@@ -179,24 +246,141 @@ export class SSHConnectionManager {
|
|
|
179
246
|
}
|
|
180
247
|
|
|
181
248
|
/**
|
|
182
|
-
* List files in a directory
|
|
249
|
+
* List files in a directory with comprehensive metadata
|
|
250
|
+
* Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
|
|
251
|
+
*
|
|
252
|
+
* @param path - Directory path to list
|
|
253
|
+
* @param includeHidden - Whether to include hidden files (default: true)
|
|
254
|
+
* @returns Array of FileEntry objects with complete metadata
|
|
183
255
|
*/
|
|
184
|
-
async listFiles(path: string
|
|
256
|
+
async listFiles(path: string, includeHidden: boolean = true): Promise<FileEntry[]> {
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
logger.debug('Listing files', { path, includeHidden });
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Step 1: Use shell command to get ALL filenames (including hidden)
|
|
262
|
+
const lsFlag = includeHidden ? '-A' : '';
|
|
263
|
+
const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
|
|
264
|
+
const result = await this.execWithTimeout(command, 10);
|
|
265
|
+
|
|
266
|
+
if (result.exitCode !== 0) {
|
|
267
|
+
throw new Error(`ls command failed: ${result.stderr}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const filenames = result.stdout
|
|
271
|
+
.split('\n')
|
|
272
|
+
.map(f => f.trim())
|
|
273
|
+
.filter(f => f !== '' && f !== '.' && f !== '..');
|
|
274
|
+
|
|
275
|
+
logger.debug('Filenames retrieved via shell', {
|
|
276
|
+
path,
|
|
277
|
+
count: filenames.length,
|
|
278
|
+
method: 'shell',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Step 2: Get rich metadata for each file using SFTP stat()
|
|
282
|
+
const sftp = await this.getSFTP();
|
|
283
|
+
const entries: FileEntry[] = [];
|
|
284
|
+
|
|
285
|
+
for (const filename of filenames) {
|
|
286
|
+
const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const stats = await new Promise<any>((resolve, reject) => {
|
|
290
|
+
sftp.stat(fullPath, (err, stats) => {
|
|
291
|
+
if (err) reject(err);
|
|
292
|
+
else resolve(stats);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
entries.push({
|
|
297
|
+
name: filename,
|
|
298
|
+
path: fullPath,
|
|
299
|
+
type: getFileType(stats),
|
|
300
|
+
size: stats.size,
|
|
301
|
+
permissions: parsePermissions(stats.mode),
|
|
302
|
+
owner: {
|
|
303
|
+
uid: stats.uid,
|
|
304
|
+
gid: stats.gid,
|
|
305
|
+
},
|
|
306
|
+
timestamps: {
|
|
307
|
+
accessed: new Date(stats.atime * 1000).toISOString(),
|
|
308
|
+
modified: new Date(stats.mtime * 1000).toISOString(),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// Skip files we can't stat (permissions, race conditions, etc.)
|
|
313
|
+
logger.warn('Failed to stat file, skipping', {
|
|
314
|
+
path: fullPath,
|
|
315
|
+
error: error instanceof Error ? error.message : String(error),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const duration = Date.now() - startTime;
|
|
321
|
+
logger.debug('Files listed successfully', {
|
|
322
|
+
path,
|
|
323
|
+
count: entries.length,
|
|
324
|
+
duration: `${duration}ms`,
|
|
325
|
+
method: 'hybrid',
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return entries;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// Fallback to SFTP readdir if shell command fails
|
|
331
|
+
logger.warn('Shell ls command failed, falling back to SFTP readdir', {
|
|
332
|
+
path,
|
|
333
|
+
error: error instanceof Error ? error.message : String(error),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return this.listFilesViaSFTP(path, includeHidden);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Fallback method: List files using SFTP readdir (may miss hidden files)
|
|
342
|
+
* @private
|
|
343
|
+
*/
|
|
344
|
+
private async listFilesViaSFTP(path: string, includeHidden: boolean): Promise<FileEntry[]> {
|
|
185
345
|
const sftp = await this.getSFTP();
|
|
186
346
|
|
|
187
347
|
return new Promise((resolve, reject) => {
|
|
188
348
|
sftp.readdir(path, (err, list) => {
|
|
189
349
|
if (err) {
|
|
350
|
+
logger.error('SFTP readdir failed', { path, error: err.message });
|
|
190
351
|
reject(err);
|
|
191
352
|
return;
|
|
192
353
|
}
|
|
193
354
|
|
|
194
|
-
|
|
355
|
+
let entries = list.map((item) => ({
|
|
195
356
|
name: item.filename,
|
|
196
|
-
|
|
357
|
+
path: `${path}/${item.filename}`.replace(/\/+/g, '/'),
|
|
358
|
+
type: getFileType(item.attrs),
|
|
359
|
+
size: item.attrs.size,
|
|
360
|
+
permissions: parsePermissions(item.attrs.mode),
|
|
361
|
+
owner: {
|
|
362
|
+
uid: item.attrs.uid,
|
|
363
|
+
gid: item.attrs.gid,
|
|
364
|
+
},
|
|
365
|
+
timestamps: {
|
|
366
|
+
accessed: new Date(item.attrs.atime * 1000).toISOString(),
|
|
367
|
+
modified: new Date(item.attrs.mtime * 1000).toISOString(),
|
|
368
|
+
},
|
|
197
369
|
}));
|
|
198
370
|
|
|
199
|
-
|
|
371
|
+
// SFTP readdir doesn't return hidden files, so filter if requested
|
|
372
|
+
if (!includeHidden) {
|
|
373
|
+
entries = entries.filter(e => !e.name.startsWith('.'));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
logger.debug('Files listed via SFTP fallback', {
|
|
377
|
+
path,
|
|
378
|
+
count: entries.length,
|
|
379
|
+
method: 'sftp',
|
|
380
|
+
note: 'Hidden files may be missing (SFTP limitation)',
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
resolve(entries);
|
|
200
384
|
});
|
|
201
385
|
});
|
|
202
386
|
}
|