# Directory Structure
```
├── .gitignore
├── Dockerfile
├── package.json
├── PROJ_INFO_4AI.md
├── README_CN.md
├── README.md
├── smithery.yaml
├── src
│ ├── executor.ts
│ ├── index.ts
│ └── ssh.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Terminal MCP Server
2 | [](https://smithery.ai/server/@weidwonder/terminal-mcp-server)
3 |
4 | ## Notice 注意事项
5 | Current Project not in maintance anymore. I recommend you guys to use more advanced command tool —— [Desktop Commander](https://desktopcommander.app/)
6 | 当前项目已经不在维护。我建议大家用更先进的终端MCP工具 [Desktop Commander](https://desktopcommander.app/)
7 |
8 |
9 | *[中文文档](README_CN.md)*
10 |
11 | Terminal MCP Server is a Model Context Protocol (MCP) server that allows executing commands on local or remote hosts. It provides a simple yet powerful interface for AI models and other applications to execute system commands, either on the local machine or on remote hosts via SSH.
12 |
13 | ## Features
14 |
15 | - **Local Command Execution**: Execute commands directly on the local machine
16 | - **Remote Command Execution**: Execute commands on remote hosts via SSH
17 | - **Session Persistence**: Support for persistent sessions that reuse the same terminal environment for a specified time (default 20 minutes)
18 | - **Environment Variables**: Set custom environment variables for commands
19 | - **Multiple Connection Methods**: Connect via stdio or SSE (Server-Sent Events)
20 |
21 | ## Installation
22 |
23 | ### Installing via Smithery
24 |
25 | To install terminal-mcp-server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@weidwonder/terminal-mcp-server):
26 |
27 | ```bash
28 | npx -y @smithery/cli install @weidwonder/terminal-mcp-server --client claude
29 | ```
30 |
31 | ### Manual Installation
32 | ```bash
33 | # Clone the repository
34 | git clone https://github.com/weidwonder/terminal-mcp-server.git
35 | cd terminal-mcp-server
36 |
37 | # Install dependencies
38 | npm install
39 |
40 | # Build the project
41 | npm run build
42 | ```
43 |
44 | ## Usage
45 |
46 | ### Starting the Server
47 |
48 | ```bash
49 | # Start the server using stdio (default mode)
50 | npm start
51 |
52 | # Or run the built file directly
53 | node build/index.js
54 | ```
55 |
56 | ### Starting the Server in SSE Mode
57 |
58 | The SSE (Server-Sent Events) mode allows you to connect to the server remotely via HTTP.
59 |
60 | ```bash
61 | # Start the server in SSE mode
62 | npm run start:sse
63 |
64 | # Or run the built file directly with SSE flag
65 | node build/index.js --sse
66 | ```
67 |
68 | You can customize the SSE server with the following command-line options:
69 |
70 | | Option | Description | Default |
71 | |--------|-------------|---------|
72 | | `--port` or `-p` | The port to listen on | 8080 |
73 | | `--endpoint` or `-e` | The endpoint path | /sse |
74 | | `--host` or `-h` | The host to bind to | localhost |
75 |
76 | Example with custom options:
77 |
78 | ```bash
79 | # Start SSE server on port 3000, endpoint /mcp, and bind to all interfaces
80 | node build/index.js --sse --port 3000 --endpoint /mcp --host 0.0.0.0
81 | ```
82 |
83 | This will start the server and listen for SSE connections at `http://0.0.0.0:3000/mcp`.
84 |
85 | ### Testing with MCP Inspector
86 |
87 | ```bash
88 | # Start the MCP Inspector tool
89 | npm run inspector
90 | ```
91 |
92 | ## The execute_command Tool
93 |
94 | The execute_command tool is the core functionality provided by Terminal MCP Server, used to execute commands on local or remote hosts.
95 |
96 | ### Parameters
97 |
98 | | Parameter | Type | Required | Description |
99 | |-----------|------|----------|-------------|
100 | | command | string | Yes | The command to execute |
101 | | host | string | No | The remote host to connect to. If not provided, the command will be executed locally |
102 | | username | string | Required when host is specified | The username for SSH connection |
103 | | session | string | No | Session name, defaults to "default". The same session name will reuse the same terminal environment for 20 minutes |
104 | | env | object | No | Environment variables, defaults to an empty object |
105 |
106 | ### Examples
107 |
108 | #### Executing a Command Locally
109 |
110 | ```json
111 | {
112 | "command": "ls -la",
113 | "session": "my-local-session",
114 | "env": {
115 | "NODE_ENV": "development"
116 | }
117 | }
118 | ```
119 |
120 | #### Executing a Command on a Remote Host
121 |
122 | ```json
123 | {
124 | "host": "example.com",
125 | "username": "user",
126 | "command": "ls -la",
127 | "session": "my-remote-session",
128 | "env": {
129 | "NODE_ENV": "production"
130 | }
131 | }
132 | ```
133 |
134 | ## Configuring with AI Assistants
135 |
136 | ### Configuring with Roo Code
137 |
138 | 1. Open VSCode and install the Roo Code extension
139 | 2. Open the Roo Code settings file: `~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json`
140 | 3. Add the following configuration:
141 |
142 | #### For stdio mode (local connection)
143 |
144 | ```json
145 | {
146 | "mcpServers": {
147 | "terminal-mcp": {
148 | "command": "node",
149 | "args": ["/path/to/terminal-mcp-server/build/index.js"],
150 | "env": {}
151 | }
152 | }
153 | }
154 | ```
155 |
156 | #### For SSE mode (remote connection)
157 |
158 | ```json
159 | {
160 | "mcpServers": {
161 | "terminal-mcp-sse": {
162 | "url": "http://localhost:8080/sse",
163 | "headers": {}
164 | }
165 | }
166 | }
167 | ```
168 |
169 | Replace `localhost:8080/sse` with your actual server address, port, and endpoint if you've customized them.
170 |
171 | ### Configuring with Cline
172 |
173 | 1. Open the Cline settings file: `~/.cline/config.json`
174 | 2. Add the following configuration:
175 |
176 | #### For stdio mode (local connection)
177 |
178 | ```json
179 | {
180 | "mcpServers": {
181 | "terminal-mcp": {
182 | "command": "node",
183 | "args": ["/path/to/terminal-mcp-server/build/index.js"],
184 | "env": {}
185 | }
186 | }
187 | }
188 | ```
189 |
190 | #### For SSE mode (remote connection)
191 |
192 | ```json
193 | {
194 | "mcpServers": {
195 | "terminal-mcp-sse": {
196 | "url": "http://localhost:8080/sse",
197 | "headers": {}
198 | }
199 | }
200 | }
201 | ```
202 |
203 | ### Configuring with Claude Desktop
204 |
205 | 1. Open the Claude Desktop settings file: `~/Library/Application Support/Claude/claude_desktop_config.json`
206 | 2. Add the following configuration:
207 |
208 | #### For stdio mode (local connection)
209 |
210 | ```json
211 | {
212 | "mcpServers": {
213 | "terminal-mcp": {
214 | "command": "node",
215 | "args": ["/path/to/terminal-mcp-server/build/index.js"],
216 | "env": {}
217 | }
218 | }
219 | }
220 | ```
221 |
222 | #### For SSE mode (remote connection)
223 |
224 | ```json
225 | {
226 | "mcpServers": {
227 | "terminal-mcp-sse": {
228 | "url": "http://localhost:8080/sse",
229 | "headers": {}
230 | }
231 | }
232 | }
233 | ```
234 |
235 | ## Best Practices
236 |
237 | ### Command Execution
238 |
239 | - Before running commands, it's best to determine the system type (Mac, Linux, etc.)
240 | - Use full paths to avoid path-related issues
241 | - For command sequences that need to maintain environment, use `&&` to connect multiple commands
242 | - For long-running commands, consider using `nohup` or `screen`/`tmux`
243 |
244 | ### SSH Connection
245 |
246 | - Ensure SSH key-based authentication is set up
247 | - If connection fails, check if the key file exists (default path: `~/.ssh/id_rsa`)
248 | - Make sure the SSH service is running on the remote host
249 |
250 | ### Session Management
251 |
252 | - Use the session parameter to maintain environment between related commands
253 | - For operations requiring specific environments, use the same session name
254 | - Note that sessions will automatically close after 20 minutes of inactivity
255 |
256 | ### Error Handling
257 |
258 | - Command execution results include both stdout and stderr
259 | - Check stderr to determine if the command executed successfully
260 | - For complex operations, add verification steps to ensure success
261 |
262 | ## Important Notes
263 |
264 | - For remote command execution, SSH key-based authentication must be set up in advance
265 | - For local command execution, commands will run in the context of the user who started the server
266 | - Session timeout is 20 minutes, after which the connection will be automatically closed
267 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | FROM node:lts-alpine
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Install app dependencies
8 | # A wildcard is used to ensure both package.json and package-lock.json are copied
9 | COPY package*.json ./
10 |
11 | # Install dependencies (skip prepare script to avoid build issues, we'll explicitly run build step)
12 | RUN npm install --ignore-scripts
13 |
14 | # Copy app source code
15 | COPY . .
16 |
17 | # Build the application
18 | RUN npm run build
19 |
20 | # Expose port 8080 for SSE mode (if used)
21 | EXPOSE 8080
22 |
23 | # Command to run the application
24 | CMD ["node", "build/index.js"]
25 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "terminal-mcp-server",
3 | "version": "0.1.0",
4 | "description": "操作X主机",
5 | "private": true,
6 | "type": "module",
7 | "bin": {
8 | "terminal-mcp-server": "./build/index.js"
9 | },
10 | "files": [
11 | "build"
12 | ],
13 | "scripts": {
14 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
15 | "prepare": "npm run build",
16 | "watch": "tsc --watch",
17 | "inspector": "npx @modelcontextprotocol/inspector build/index.js",
18 | "start": "node build/index.js"
19 | },
20 | "dependencies": {
21 | "@modelcontextprotocol/sdk": "^1.6.0",
22 | "ssh2": "^1.16.0"
23 | },
24 | "devDependencies": {
25 | "@types/node": "^20.11.24",
26 | "@types/ssh2": "^1.15.4",
27 | "typescript": "^5.3.3"
28 | }
29 | }
30 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | type: object
8 | properties:
9 | sse:
10 | type: boolean
11 | default: false
12 | description: If true, run the server in SSE mode instead of default stdio mode.
13 | port:
14 | type: number
15 | default: 8080
16 | description: Port to listen on when running in SSE mode.
17 | endpoint:
18 | type: string
19 | default: /sse
20 | description: Endpoint path for SSE connections.
21 | host:
22 | type: string
23 | default: localhost
24 | description: Host to bind to when running in SSE mode.
25 | commandFunction:
26 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
27 | |-
28 | (config) => { const args = []; if(config.sse){ args.push('--sse'); if(config.port) { args.push('--port', config.port.toString()); } if(config.endpoint){ args.push('--endpoint', config.endpoint); } if(config.host){ args.push('--host', config.host); } } return { command: 'node', args: ['build/index.js', ...args] }; }
29 | exampleConfig:
30 | sse: true
31 | port: 3000
32 | endpoint: /mcp
33 | host: 0.0.0.0
34 |
```
--------------------------------------------------------------------------------
/src/ssh.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client } from 'ssh2';
2 | import * as fs from 'fs';
3 | import * as path from 'path';
4 | import * as os from 'os';
5 |
6 | export class SSHManager {
7 | private client: Client | null = null;
8 | private connection: Promise<void> | null = null;
9 | private timeout: NodeJS.Timeout | null = null;
10 | private sessionTimeout: number = 20 * 60 * 1000; // 20 minutes
11 |
12 | constructor() {
13 | this.client = new Client();
14 | }
15 |
16 | async connect(host: string): Promise<void> {
17 | if (this.connection) {
18 | return this.connection;
19 | }
20 |
21 | const privateKey = fs.readFileSync(path.join(os.homedir(), '.ssh', 'id_rsa'));
22 |
23 | this.connection = new Promise((resolve, reject) => {
24 | this.client = new Client();
25 | this.client
26 | .on('ready', () => {
27 | this.resetTimeout();
28 | resolve();
29 | })
30 | .on('error', (err) => {
31 | reject(err);
32 | })
33 | .connect({
34 | host: host,
35 | username: 'weidwonder',
36 | privateKey: privateKey,
37 | });
38 | });
39 |
40 | return this.connection;
41 | }
42 |
43 | private resetTimeout(): void {
44 | if (this.timeout) {
45 | clearTimeout(this.timeout);
46 | }
47 |
48 | this.timeout = setTimeout(async () => {
49 | console.log('SSH session timeout, disconnecting');
50 | await this.disconnect();
51 | }, this.sessionTimeout);
52 | }
53 |
54 | async executeCommand(host: string, command: string, env: Record<string, string> | {} = {}): Promise<{stdout: string; stderr: string}> {
55 | if (!this.client) {
56 | await this.connect(host);
57 | }
58 | this.resetTimeout();
59 |
60 | return new Promise((resolve, reject) => {
61 | // 使用完整的shell路径,以交互式登录模式执行命令
62 | // 这更接近于用户直接SSH登录的体验
63 |
64 | // 构建环境变量设置命令
65 | const envSetup = Object.entries(env as Record<string, string>)
66 | .map(([key, value]) => `export ${key}="${String(value).replace(/"/g, '\\"')}"`)
67 | .join(' && ');
68 |
69 | // 如果有环境变量,先设置环境变量,再执行命令
70 | const fullCommand = envSetup ? `${envSetup} && ${command}` : command;
71 |
72 | this.client?.exec(`/bin/bash --login -c "${fullCommand}"`, (err, stream) => {
73 | if (err) {
74 | reject(err);
75 | return
76 | }
77 |
78 | let stdout = ""
79 | let stderr = '';
80 |
81 | stream
82 | .on("data", (data: Buffer) => {
83 | this.resetTimeout();
84 | stdout += data.toString();
85 | })
86 | .stderr.on('data', (data: Buffer) => {
87 | stderr += data.toString();
88 | })
89 | .on('close', () => {
90 | resolve({ stdout, stderr });
91 | })
92 | .on('error', (err) => {
93 | reject(err);
94 | });
95 | });
96 | });
97 | }
98 |
99 | async disconnect(): Promise<void> {
100 | if (this.client) {
101 | this.client.end();
102 | this.client = null;
103 | this.connection = null;
104 | }
105 | }
106 | }
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import {
6 | CallToolRequestSchema,
7 | ListToolsRequestSchema,
8 | ErrorCode,
9 | McpError,
10 | } from "@modelcontextprotocol/sdk/types.js";
11 | import { CommandExecutor } from "./executor.js";
12 |
13 | // 全局日志函数,确保所有日志都通过stderr输出
14 | export const log = {
15 | debug: (message: string, ...args: any[]) => {
16 | if (process.env.DEBUG === 'true') {
17 | console.error(`[DEBUG] ${message}`, ...args);
18 | }
19 | },
20 | info: (message: string, ...args: any[]) => {
21 | console.error(`[INFO] ${message}`, ...args);
22 | },
23 | warn: (message: string, ...args: any[]) => {
24 | console.error(`[WARN] ${message}`, ...args);
25 | },
26 | error: (message: string, ...args: any[]) => {
27 | console.error(`[ERROR] ${message}`, ...args);
28 | }
29 | };
30 |
31 | const commandExecutor = new CommandExecutor();
32 |
33 | // 创建服务器
34 | function createServer() {
35 | const server = new Server(
36 | {
37 | name: "remote-ops-server",
38 | version: "0.1.0",
39 | },
40 | {
41 | capabilities: {
42 | tools: {},
43 | },
44 | }
45 | );
46 |
47 | server.setRequestHandler(ListToolsRequestSchema, async () => {
48 | return {
49 | tools: [
50 | {
51 | name: "execute_command",
52 | description: "Execute commands on remote hosts or locally (This tool can be used for both remote hosts and the current machine)",
53 | inputSchema: {
54 | type: "object",
55 | properties: {
56 | host: {
57 | type: "string",
58 | description: "Host to connect to (optional, if not provided the command will be executed locally)"
59 | },
60 | username: {
61 | type: "string",
62 | description: "Username for SSH connection (required when host is specified)"
63 | },
64 | session: {
65 | type: "string",
66 | description: "Session name, defaults to 'default'. The same session name will reuse the same terminal environment for 20 minutes, which is useful for operations requiring specific environments like conda.",
67 | default: "default"
68 | },
69 | command: {
70 | type: "string",
71 | description: "Command to execute. Before running commands, it's best to determine the system type (Mac, Linux, etc.)"
72 | },
73 | env: {
74 | type: "object",
75 | description: "Environment variables",
76 | default: {}
77 | }
78 | },
79 | required: ["command"]
80 | }
81 | }
82 | ]
83 | };
84 | });
85 |
86 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
87 | try {
88 | if (request.params.name !== "execute_command") {
89 | throw new McpError(ErrorCode.MethodNotFound, "Unknown tool");
90 | }
91 |
92 | const host = request.params.arguments?.host ? String(request.params.arguments.host) : undefined;
93 | const username = request.params.arguments?.username ? String(request.params.arguments.username) : undefined;
94 | const session = String(request.params.arguments?.session || "default");
95 | const command = String(request.params.arguments?.command);
96 | if (!command) {
97 | throw new McpError(ErrorCode.InvalidParams, "Command is required");
98 | }
99 | const env = request.params.arguments?.env || {};
100 |
101 | // 如果指定了host但没有指定username
102 | if (host && !username) {
103 | throw new McpError(ErrorCode.InvalidParams, "Username is required when host is specified");
104 | }
105 |
106 | try {
107 | const result = await commandExecutor.executeCommand(command, {
108 | host,
109 | username,
110 | session,
111 | env: env as Record<string, string>
112 | });
113 |
114 | return {
115 | content: [{
116 | type: "text",
117 | text: `Command Output:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
118 | }]
119 | };
120 | } catch (error) {
121 | if (error instanceof Error && error.message.includes('SSH')) {
122 | throw new McpError(
123 | ErrorCode.InternalError,
124 | `SSH connection error: ${error.message}. Please ensure SSH key-based authentication is set up.`
125 | );
126 | }
127 | throw error;
128 | }
129 | } catch (error) {
130 | if (error instanceof McpError) {
131 | throw error;
132 | }
133 | throw new McpError(
134 | ErrorCode.InternalError,
135 | error instanceof Error ? error.message : String(error)
136 | );
137 | }
138 | });
139 |
140 | return server;
141 | }
142 |
143 | async function main() {
144 | try {
145 | // 使用标准输入输出
146 | const server = createServer();
147 |
148 | // 设置MCP错误处理程序
149 | server.onerror = (error) => {
150 | log.error(`MCP Error: ${error.message}`);
151 | };
152 |
153 | const transport = new StdioServerTransport();
154 | await server.connect(transport);
155 | log.info("Remote Ops MCP server running on stdio");
156 |
157 | // 处理进程退出
158 | process.on('SIGINT', async () => {
159 | log.info("Shutting down server...");
160 | await commandExecutor.disconnect();
161 | process.exit(0);
162 | });
163 | } catch (error) {
164 | log.error("Server error:", error);
165 | process.exit(1);
166 | }
167 | }
168 |
169 | main().catch((error) => {
170 | log.error("Server error:", error);
171 | process.exit(1);
172 | });
173 |
```
--------------------------------------------------------------------------------
/src/executor.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client } from 'ssh2';
2 | import * as fs from 'fs';
3 | import * as path from 'path';
4 | import * as os from 'os';
5 | import { exec } from 'child_process';
6 | import { log } from './index.js';
7 |
8 | export class CommandExecutor {
9 | private sessions: Map<string, {
10 | client: Client | null;
11 | connection: Promise<void> | null;
12 | timeout: NodeJS.Timeout | null;
13 | host?: string;
14 | env?: Record<string, string>; // 添加环境变量存储
15 | shell?: any; // 添加shell会话
16 | shellReady?: boolean; // shell是否准备好
17 | }> = new Map();
18 |
19 | private sessionTimeout: number = 20 * 60 * 1000; // 20 minutes
20 |
21 | constructor() {}
22 |
23 | private getSessionKey(host: string | undefined, sessionName: string): string {
24 | return `${host || 'local'}-${sessionName}`;
25 | }
26 |
27 | async connect(host: string, username: string, sessionName: string = 'default'): Promise<void> {
28 | const sessionKey = this.getSessionKey(host, sessionName);
29 | const session = this.sessions.get(sessionKey);
30 |
31 | // 如果会话存在且连接有效,直接返回现有连接
32 | if (session?.connection && session?.client) {
33 | // 检查客户端是否仍然连接
34 | if (session.client.listenerCount('ready') > 0 || session.client.listenerCount('data') > 0) {
35 | log.info(`Reusing existing session: ${sessionKey}`);
36 | return session.connection;
37 | }
38 | // 如果客户端已断开连接,清理旧会话
39 | log.info(`Session ${sessionKey} disconnected, creating new session`);
40 | this.sessions.delete(sessionKey);
41 | }
42 |
43 | try {
44 | const privateKey = fs.readFileSync(path.join(os.homedir(), '.ssh', 'id_rsa'));
45 |
46 | const client = new Client();
47 | const connection = new Promise<void>((resolve, reject) => {
48 | client
49 | .on('ready', () => {
50 | log.info(`Session ${sessionKey} connected`);
51 | this.resetTimeout(sessionKey);
52 |
53 | // 创建一个交互式shell
54 | client.shell((err, stream) => {
55 | if (err) {
56 | log.error(`Failed to create interactive shell: ${err.message}`);
57 | reject(err);
58 | return;
59 | }
60 |
61 | log.info(`Creating interactive shell for session ${sessionKey}`);
62 |
63 | // 获取会话对象
64 | const sessionData = this.sessions.get(sessionKey);
65 | if (sessionData) {
66 | // 设置shell和shellReady标志
67 | sessionData.shell = stream;
68 | sessionData.shellReady = true;
69 |
70 | // 更新会话
71 | this.sessions.set(sessionKey, sessionData);
72 | }
73 |
74 | // 处理shell关闭事件
75 | stream.on('close', () => {
76 | log.info(`Interactive shell for session ${sessionKey} closed`);
77 | const sessionData = this.sessions.get(sessionKey);
78 | if (sessionData) {
79 | sessionData.shellReady = false;
80 | this.sessions.set(sessionKey, sessionData);
81 | }
82 | });
83 |
84 | // 等待shell准备好
85 | stream.write('echo "Shell ready"\n');
86 |
87 | // 解析promise
88 | resolve();
89 | });
90 | })
91 | .on('error', (err) => {
92 | log.error(`会话 ${sessionKey} 连接错误:`, err.message);
93 | reject(err);
94 | })
95 | .connect({
96 | host: host,
97 | username: username,
98 | privateKey: privateKey,
99 | keepaliveInterval: 60000, // 每分钟发送一次keepalive包
100 | });
101 | });
102 |
103 | log.info(`Creating new session: ${sessionKey}`);
104 | this.sessions.set(sessionKey, {
105 | client,
106 | connection,
107 | timeout: null,
108 | host,
109 | shell: null,
110 | shellReady: false
111 | });
112 |
113 | return connection;
114 | } catch (error) {
115 | if (error instanceof Error && error.message.includes('ENOENT')) {
116 | throw new Error('SSH key file does not exist, please ensure SSH key-based authentication is set up');
117 | }
118 | throw error;
119 | }
120 | }
121 |
122 | private resetTimeout(sessionKey: string): void {
123 | const session = this.sessions.get(sessionKey);
124 | if (!session) return;
125 |
126 | if (session.timeout) {
127 | clearTimeout(session.timeout);
128 | }
129 |
130 | session.timeout = setTimeout(async () => {
131 | log.info(`Session ${sessionKey} timeout, disconnecting`);
132 | await this.disconnectSession(sessionKey);
133 | }, this.sessionTimeout);
134 |
135 | this.sessions.set(sessionKey, session);
136 | }
137 |
138 | async executeCommand(
139 | command: string,
140 | options: {
141 | host?: string;
142 | username?: string;
143 | session?: string;
144 | env?: Record<string, string>;
145 | } = {}
146 | ): Promise<{stdout: string; stderr: string}> {
147 | const { host, username, session = 'default', env = {} } = options;
148 | const sessionKey = this.getSessionKey(host, session);
149 |
150 | // 如果指定了host,则使用SSH执行命令
151 | if (host) {
152 | if (!username) {
153 | throw new Error('Username is required when using SSH');
154 | }
155 |
156 | let sessionData = this.sessions.get(sessionKey);
157 |
158 | // 检查会话是否存在且有效
159 | let needNewConnection = false;
160 | if (!sessionData || sessionData.host !== host) {
161 | needNewConnection = true;
162 | } else if (sessionData.client) {
163 | // 检查客户端是否仍然连接
164 | if (sessionData.client.listenerCount('ready') === 0 && sessionData.client.listenerCount('data') === 0) {
165 | log.info(`Session ${sessionKey} disconnected, reconnecting`);
166 | needNewConnection = true;
167 | }
168 | } else {
169 | needNewConnection = true;
170 | }
171 |
172 | // 如果需要新连接,则创建
173 | if (needNewConnection) {
174 | log.info(`Creating new connection for command execution: ${sessionKey}`);
175 | await this.connect(host, username, session);
176 | sessionData = this.sessions.get(sessionKey);
177 | } else {
178 | log.info(`Reusing existing session for command execution: ${sessionKey}`);
179 | }
180 |
181 | if (!sessionData || !sessionData.client) {
182 | throw new Error(`无法创建到 ${host} 的SSH会话`);
183 | }
184 |
185 | this.resetTimeout(sessionKey);
186 |
187 | // 检查是否有交互式shell可用
188 | if (sessionData.shellReady && sessionData.shell) {
189 | log.info(`Executing command using interactive shell: ${command}`);
190 | return new Promise((resolve, reject) => {
191 | let stdout = "";
192 | let stderr = "";
193 | let commandFinished = false;
194 | const uniqueMarker = `CMD_END_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
195 |
196 | // 构建环境变量设置命令
197 | const envSetup = Object.entries(env)
198 | .map(([key, value]) => `export ${key}="${String(value).replace(/"/g, '\\"')}"`)
199 | .join(' && ');
200 |
201 | // 如果有环境变量,先设置环境变量,再执行命令
202 | const fullCommand = envSetup ? `${envSetup} && ${command}` : command;
203 |
204 | // 添加数据处理器
205 | const dataHandler = (data: Buffer) => {
206 | const str = data.toString();
207 | log.debug(`Shell数据: ${str}`);
208 |
209 | if (str.includes(uniqueMarker)) {
210 | // 命令执行完成
211 | commandFinished = true;
212 |
213 | // 提取命令输出(从命令开始到标记之前的内容)
214 | const lines = stdout.split('\n');
215 | let commandOutput = '';
216 | let foundCommand = false;
217 |
218 | for (const line of lines) {
219 | if (foundCommand) {
220 | if (line.includes(uniqueMarker)) {
221 | break;
222 | }
223 | commandOutput += line + '\n';
224 | } else if (line.includes(fullCommand)) {
225 | foundCommand = true;
226 | }
227 | }
228 |
229 | // 解析输出
230 | resolve({ stdout: commandOutput.trim(), stderr });
231 |
232 | // 移除处理器
233 | sessionData.shell.removeListener('data', dataHandler);
234 | clearTimeout(timeout);
235 | } else if (!commandFinished) {
236 | stdout += str;
237 | }
238 | };
239 |
240 | // 添加错误处理器
241 | const errorHandler = (err: Error) => {
242 | stderr += err.message;
243 | reject(err);
244 | sessionData.shell.removeListener('data', dataHandler);
245 | sessionData.shell.removeListener('error', errorHandler);
246 | };
247 |
248 | // 监听数据和错误
249 | sessionData.shell.on('data', dataHandler);
250 | sessionData.shell.on('error', errorHandler);
251 |
252 | // 执行命令并添加唯一标记
253 | // 使用一个更明确的方式来执行命令和捕获输出
254 | sessionData.shell.write(`echo "Starting command execution: ${fullCommand}"\n`);
255 | sessionData.shell.write(`${fullCommand}\n`);
256 | sessionData.shell.write(`echo "${uniqueMarker}"\n`);
257 |
258 | // 设置超时
259 | const timeout = setTimeout(() => {
260 | if (!commandFinished) {
261 | stderr += "Command execution timed out";
262 | resolve({ stdout, stderr });
263 | sessionData.shell.removeListener('data', dataHandler);
264 | sessionData.shell.removeListener('error', errorHandler);
265 | }
266 | }, 30000); // 30秒超时
267 | });
268 | } else {
269 | log.info(`Executing command using exec: ${command}`);
270 | return new Promise((resolve, reject) => {
271 | // 构建环境变量设置命令
272 | const envSetup = Object.entries(env)
273 | .map(([key, value]) => `export ${key}="${String(value).replace(/"/g, '\\"')}"`)
274 | .join(' && ');
275 |
276 | // 如果有环境变量,先设置环境变量,再执行命令
277 | const fullCommand = envSetup ? `${envSetup} && ${command}` : command;
278 |
279 | sessionData?.client?.exec(`/bin/bash --login -c "${fullCommand.replace(/"/g, '\\"')}"`, (err, stream) => {
280 | if (err) {
281 | reject(err);
282 | return;
283 | }
284 |
285 | let stdout = "";
286 | let stderr = '';
287 |
288 | stream
289 | .on("data", (data: Buffer) => {
290 | this.resetTimeout(sessionKey);
291 | stdout += data.toString();
292 | })
293 | .stderr.on('data', (data: Buffer) => {
294 | stderr += data.toString();
295 | })
296 | .on('close', () => {
297 | resolve({ stdout, stderr });
298 | })
299 | .on('error', (err) => {
300 | reject(err);
301 | });
302 | });
303 | });
304 | }
305 | }
306 | // 否则在本地执行命令
307 | else {
308 | // 在本地执行命令时,也使用会话机制来保持环境变量
309 | log.info(`Executing command using local session: ${sessionKey}`);
310 |
311 | // 检查是否已有本地会话
312 | let sessionData = this.sessions.get(sessionKey);
313 | let sessionEnv = {};
314 |
315 | if (!sessionData) {
316 | // 为本地会话创建一个空条目,以便跟踪超时
317 | sessionData = {
318 | client: null,
319 | connection: null,
320 | timeout: null,
321 | host: undefined,
322 | env: { ...env } // 保存初始环境变量
323 | };
324 | this.sessions.set(sessionKey, sessionData);
325 | log.info(`Creating new local session: ${sessionKey}`);
326 | sessionEnv = env;
327 | } else {
328 | log.info(`Reusing existing local session: ${sessionKey}`);
329 | // 合并现有会话环境变量和新的环境变量
330 | if (!sessionData.env) {
331 | sessionData.env = {};
332 | }
333 | sessionData.env = { ...sessionData.env, ...env };
334 | sessionEnv = sessionData.env;
335 | // 更新会话
336 | this.sessions.set(sessionKey, sessionData);
337 | }
338 |
339 | this.resetTimeout(sessionKey);
340 |
341 | return new Promise((resolve, reject) => {
342 | // 构建环境变量,优先级:系统环境变量 < 会话环境变量 < 当前命令环境变量
343 | const envVars = { ...process.env, ...sessionEnv };
344 |
345 | // 执行命令
346 | log.info(`Executing local command: ${command}`);
347 | exec(command, { env: envVars }, (error, stdout, stderr) => {
348 | if (error && error.code !== 0) {
349 | // 我们不直接拒绝,而是返回错误信息作为stderr
350 | resolve({ stdout, stderr: stderr || error.message });
351 | } else {
352 | resolve({ stdout, stderr });
353 | }
354 | });
355 | });
356 | }
357 | }
358 |
359 | private async disconnectSession(sessionKey: string): Promise<void> {
360 | const session = this.sessions.get(sessionKey);
361 | if (session) {
362 | if (session.shell) {
363 | log.info(`Closing interactive shell for session ${sessionKey}`);
364 | session.shell.end();
365 | session.shellReady = false;
366 | }
367 | if (session.client) {
368 | log.info(`Disconnecting SSH connection for session ${sessionKey}`);
369 | session.client.end();
370 | }
371 | if (session.timeout) {
372 | clearTimeout(session.timeout);
373 | }
374 | log.info(`Disconnecting session: ${sessionKey}`);
375 | this.sessions.delete(sessionKey);
376 | }
377 | }
378 |
379 | async disconnect(): Promise<void> {
380 | const disconnectPromises = Array.from(this.sessions.keys()).map(
381 | sessionKey => this.disconnectSession(sessionKey)
382 | );
383 |
384 | await Promise.all(disconnectPromises);
385 | this.sessions.clear();
386 | }
387 | }
```