@yivan-lab/pretty-please 1.0.0 → 1.2.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/README.md +381 -28
- package/bin/pls.tsx +1138 -109
- package/dist/bin/pls.d.ts +1 -1
- package/dist/bin/pls.js +994 -91
- package/dist/package.json +80 -0
- package/dist/src/ai.d.ts +1 -41
- package/dist/src/ai.js +9 -190
- package/dist/src/alias.d.ts +41 -0
- package/dist/src/alias.js +240 -0
- package/dist/src/builtin-detector.d.ts +14 -8
- package/dist/src/builtin-detector.js +36 -16
- package/dist/src/chat-history.d.ts +16 -11
- package/dist/src/chat-history.js +35 -4
- package/dist/src/components/Chat.js +5 -4
- package/dist/src/components/CodeColorizer.js +26 -20
- package/dist/src/components/CommandBox.js +3 -17
- package/dist/src/components/ConfirmationPrompt.d.ts +2 -1
- package/dist/src/components/ConfirmationPrompt.js +9 -4
- package/dist/src/components/Duration.js +2 -1
- package/dist/src/components/InlineRenderer.js +2 -1
- package/dist/src/components/MarkdownDisplay.js +2 -1
- package/dist/src/components/MultiStepCommandGenerator.d.ts +5 -1
- package/dist/src/components/MultiStepCommandGenerator.js +127 -14
- package/dist/src/components/TableRenderer.js +2 -1
- package/dist/src/config.d.ts +59 -9
- package/dist/src/config.js +147 -48
- package/dist/src/history.d.ts +19 -5
- package/dist/src/history.js +26 -11
- package/dist/src/mastra-agent.d.ts +0 -1
- package/dist/src/mastra-agent.js +3 -4
- package/dist/src/mastra-chat.d.ts +28 -0
- package/dist/src/mastra-chat.js +93 -0
- package/dist/src/multi-step.d.ts +23 -7
- package/dist/src/multi-step.js +29 -6
- package/dist/src/prompts.d.ts +11 -0
- package/dist/src/prompts.js +140 -0
- package/dist/src/remote-history.d.ts +63 -0
- package/dist/src/remote-history.js +315 -0
- package/dist/src/remote.d.ts +113 -0
- package/dist/src/remote.js +634 -0
- package/dist/src/shell-hook.d.ts +87 -12
- package/dist/src/shell-hook.js +315 -17
- package/dist/src/sysinfo.d.ts +9 -5
- package/dist/src/sysinfo.js +2 -2
- package/dist/src/ui/theme.d.ts +27 -24
- package/dist/src/ui/theme.js +71 -21
- package/dist/src/upgrade.d.ts +41 -0
- package/dist/src/upgrade.js +348 -0
- package/dist/src/utils/console.d.ts +11 -11
- package/dist/src/utils/console.js +26 -17
- package/package.json +11 -9
- package/src/alias.ts +301 -0
- package/src/builtin-detector.ts +126 -0
- package/src/chat-history.ts +140 -0
- package/src/components/Chat.tsx +6 -5
- package/src/components/CodeColorizer.tsx +27 -19
- package/src/components/CommandBox.tsx +3 -17
- package/src/components/ConfirmationPrompt.tsx +11 -3
- package/src/components/Duration.tsx +2 -1
- package/src/components/InlineRenderer.tsx +2 -1
- package/src/components/MarkdownDisplay.tsx +2 -1
- package/src/components/MultiStepCommandGenerator.tsx +167 -16
- package/src/components/TableRenderer.tsx +2 -1
- package/src/config.ts +394 -0
- package/src/history.ts +160 -0
- package/src/mastra-agent.ts +3 -4
- package/src/mastra-chat.ts +124 -0
- package/src/multi-step.ts +45 -8
- package/src/prompts.ts +154 -0
- package/src/remote-history.ts +390 -0
- package/src/remote.ts +800 -0
- package/src/shell-hook.ts +754 -0
- package/src/{sysinfo.js → sysinfo.ts} +28 -16
- package/src/ui/theme.ts +101 -24
- package/src/upgrade.ts +397 -0
- package/src/utils/{console.js → console.ts} +36 -27
- package/bin/pls.js +0 -681
- package/src/ai.js +0 -324
- package/src/builtin-detector.js +0 -98
- package/src/chat-history.js +0 -94
- package/src/components/ChatStatus.tsx +0 -53
- package/src/components/CommandGenerator.tsx +0 -184
- package/src/components/ConfigDisplay.tsx +0 -64
- package/src/components/ConfigWizard.tsx +0 -101
- package/src/components/HistoryDisplay.tsx +0 -69
- package/src/components/HookManager.tsx +0 -150
- package/src/config.js +0 -221
- package/src/history.js +0 -131
- package/src/shell-hook.js +0 -393
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程执行器模块
|
|
3
|
+
* 通过 SSH 在远程服务器上执行命令
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import readline from 'readline';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { CONFIG_DIR, getConfig, saveConfig } from './config.js';
|
|
12
|
+
import { getCurrentTheme } from './ui/theme.js';
|
|
13
|
+
// 获取主题颜色
|
|
14
|
+
function getColors() {
|
|
15
|
+
const theme = getCurrentTheme();
|
|
16
|
+
return {
|
|
17
|
+
primary: theme.primary,
|
|
18
|
+
secondary: theme.secondary,
|
|
19
|
+
success: theme.success,
|
|
20
|
+
error: theme.error,
|
|
21
|
+
warning: theme.warning,
|
|
22
|
+
muted: theme.text.muted,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// 远程服务器数据目录
|
|
26
|
+
const REMOTES_DIR = path.join(CONFIG_DIR, 'remotes');
|
|
27
|
+
// SSH ControlMaster 配置
|
|
28
|
+
const SSH_CONTROL_PERSIST = '10m'; // 连接保持 10 分钟
|
|
29
|
+
/**
|
|
30
|
+
* 确保远程服务器数据目录存在
|
|
31
|
+
*/
|
|
32
|
+
function ensureRemotesDir() {
|
|
33
|
+
if (!fs.existsSync(REMOTES_DIR)) {
|
|
34
|
+
fs.mkdirSync(REMOTES_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 获取远程服务器数据目录
|
|
39
|
+
*/
|
|
40
|
+
function getRemoteDataDir(name) {
|
|
41
|
+
return path.join(REMOTES_DIR, name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 获取 SSH ControlMaster socket 路径
|
|
45
|
+
*/
|
|
46
|
+
function getSSHSocketPath(name) {
|
|
47
|
+
return path.join(REMOTES_DIR, name, 'ssh.sock');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 检查 ControlMaster 连接是否存在
|
|
51
|
+
*/
|
|
52
|
+
function isControlMasterActive(name) {
|
|
53
|
+
const socketPath = getSSHSocketPath(name);
|
|
54
|
+
return fs.existsSync(socketPath);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 关闭 ControlMaster 连接
|
|
58
|
+
*/
|
|
59
|
+
export async function closeControlMaster(name) {
|
|
60
|
+
const remote = getRemote(name);
|
|
61
|
+
if (!remote)
|
|
62
|
+
return;
|
|
63
|
+
const socketPath = getSSHSocketPath(name);
|
|
64
|
+
if (!fs.existsSync(socketPath))
|
|
65
|
+
return;
|
|
66
|
+
// 使用 ssh -O exit 关闭 master 连接
|
|
67
|
+
const args = ['-O', 'exit', '-o', `ControlPath=${socketPath}`, `${remote.user}@${remote.host}`];
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const child = spawn('ssh', args, { stdio: 'ignore' });
|
|
70
|
+
child.on('close', () => {
|
|
71
|
+
// 确保 socket 文件被删除
|
|
72
|
+
if (fs.existsSync(socketPath)) {
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(socketPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// 忽略错误
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
resolve();
|
|
81
|
+
});
|
|
82
|
+
child.on('error', () => resolve());
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 确保远程服务器数据目录存在
|
|
87
|
+
*/
|
|
88
|
+
function ensureRemoteDataDir(name) {
|
|
89
|
+
const dir = getRemoteDataDir(name);
|
|
90
|
+
if (!fs.existsSync(dir)) {
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ================== 远程服务器管理 ==================
|
|
95
|
+
/**
|
|
96
|
+
* 获取所有远程服务器配置
|
|
97
|
+
*/
|
|
98
|
+
export function getRemotes() {
|
|
99
|
+
const config = getConfig();
|
|
100
|
+
return config.remotes || {};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 获取单个远程服务器配置
|
|
104
|
+
*/
|
|
105
|
+
export function getRemote(name) {
|
|
106
|
+
const remotes = getRemotes();
|
|
107
|
+
return remotes[name] || null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 解析 user@host:port 格式
|
|
111
|
+
*/
|
|
112
|
+
function parseHostString(hostStr) {
|
|
113
|
+
let user = '';
|
|
114
|
+
let host = hostStr;
|
|
115
|
+
let port = 22;
|
|
116
|
+
// 解析 user@host
|
|
117
|
+
if (hostStr.includes('@')) {
|
|
118
|
+
const atIndex = hostStr.indexOf('@');
|
|
119
|
+
user = hostStr.substring(0, atIndex);
|
|
120
|
+
host = hostStr.substring(atIndex + 1);
|
|
121
|
+
}
|
|
122
|
+
// 解析 host:port
|
|
123
|
+
if (host.includes(':')) {
|
|
124
|
+
const colonIndex = host.lastIndexOf(':');
|
|
125
|
+
const portStr = host.substring(colonIndex + 1);
|
|
126
|
+
const parsedPort = parseInt(portStr, 10);
|
|
127
|
+
if (!isNaN(parsedPort)) {
|
|
128
|
+
port = parsedPort;
|
|
129
|
+
host = host.substring(0, colonIndex);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { user, host, port };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 添加远程服务器
|
|
136
|
+
*/
|
|
137
|
+
export function addRemote(name, hostStr, options = {}) {
|
|
138
|
+
// 验证名称
|
|
139
|
+
if (!name || !name.trim()) {
|
|
140
|
+
throw new Error('服务器名称不能为空');
|
|
141
|
+
}
|
|
142
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
143
|
+
throw new Error('服务器名称只能包含字母、数字、下划线和连字符');
|
|
144
|
+
}
|
|
145
|
+
// 解析 host 字符串
|
|
146
|
+
const { user, host, port } = parseHostString(hostStr);
|
|
147
|
+
if (!host) {
|
|
148
|
+
throw new Error('主机地址不能为空');
|
|
149
|
+
}
|
|
150
|
+
if (!user) {
|
|
151
|
+
throw new Error('用户名不能为空,请使用 user@host 格式');
|
|
152
|
+
}
|
|
153
|
+
// 验证密钥文件
|
|
154
|
+
if (options.key) {
|
|
155
|
+
const keyPath = options.key.replace(/^~/, os.homedir());
|
|
156
|
+
if (!fs.existsSync(keyPath)) {
|
|
157
|
+
throw new Error(`密钥文件不存在: ${options.key}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const config = getConfig();
|
|
161
|
+
if (!config.remotes) {
|
|
162
|
+
config.remotes = {};
|
|
163
|
+
}
|
|
164
|
+
// 检查是否已存在
|
|
165
|
+
if (config.remotes[name]) {
|
|
166
|
+
throw new Error(`服务器 "${name}" 已存在,请使用其他名称或先删除`);
|
|
167
|
+
}
|
|
168
|
+
config.remotes[name] = {
|
|
169
|
+
host,
|
|
170
|
+
user,
|
|
171
|
+
port,
|
|
172
|
+
key: options.key,
|
|
173
|
+
password: options.password,
|
|
174
|
+
};
|
|
175
|
+
saveConfig(config);
|
|
176
|
+
// 创建数据目录
|
|
177
|
+
ensureRemoteDataDir(name);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 删除远程服务器
|
|
181
|
+
*/
|
|
182
|
+
export function removeRemote(name) {
|
|
183
|
+
const config = getConfig();
|
|
184
|
+
if (!config.remotes || !config.remotes[name]) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
delete config.remotes[name];
|
|
188
|
+
saveConfig(config);
|
|
189
|
+
// 删除数据目录
|
|
190
|
+
const dataDir = getRemoteDataDir(name);
|
|
191
|
+
if (fs.existsSync(dataDir)) {
|
|
192
|
+
fs.rmSync(dataDir, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 显示所有远程服务器
|
|
198
|
+
*/
|
|
199
|
+
export function displayRemotes() {
|
|
200
|
+
const remotes = getRemotes();
|
|
201
|
+
const config = getConfig();
|
|
202
|
+
const colors = getColors();
|
|
203
|
+
const names = Object.keys(remotes);
|
|
204
|
+
console.log('');
|
|
205
|
+
if (names.length === 0) {
|
|
206
|
+
console.log(chalk.gray(' 暂无远程服务器'));
|
|
207
|
+
console.log('');
|
|
208
|
+
console.log(chalk.gray(' 使用 pls remote add <name> <user@host> 添加服务器'));
|
|
209
|
+
console.log('');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.log(chalk.bold('远程服务器:'));
|
|
213
|
+
console.log(chalk.gray('━'.repeat(60)));
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
const remote = remotes[name];
|
|
216
|
+
const authType = remote.password ? '密码' : remote.key ? '密钥' : '默认密钥';
|
|
217
|
+
const isDefault = config.defaultRemote === name;
|
|
218
|
+
// 服务器名称,如果是默认则显示标记
|
|
219
|
+
if (isDefault) {
|
|
220
|
+
console.log(` ${chalk.hex(colors.primary)(name)} ${chalk.hex(colors.success)('(default)')}`);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(` ${chalk.hex(colors.primary)(name)}`);
|
|
224
|
+
}
|
|
225
|
+
console.log(` ${chalk.gray('→')} ${remote.user}@${remote.host}:${remote.port}`);
|
|
226
|
+
console.log(` ${chalk.gray('认证:')} ${authType}${remote.key ? ` (${remote.key})` : ''}`);
|
|
227
|
+
// 显示工作目录
|
|
228
|
+
if (remote.workDir) {
|
|
229
|
+
console.log(` ${chalk.gray('工作目录:')} ${remote.workDir}`);
|
|
230
|
+
}
|
|
231
|
+
// 检查是否有缓存的系统信息
|
|
232
|
+
const sysInfo = getRemoteSysInfo(name);
|
|
233
|
+
if (sysInfo) {
|
|
234
|
+
console.log(` ${chalk.gray('系统:')} ${sysInfo.os} ${sysInfo.osVersion} (${sysInfo.shell})`);
|
|
235
|
+
}
|
|
236
|
+
console.log('');
|
|
237
|
+
}
|
|
238
|
+
console.log(chalk.gray('━'.repeat(60)));
|
|
239
|
+
console.log(chalk.gray('使用: pls -r <name> <prompt> 在远程服务器执行'));
|
|
240
|
+
console.log('');
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 设置远程服务器工作目录
|
|
244
|
+
*/
|
|
245
|
+
export function setRemoteWorkDir(name, workDir) {
|
|
246
|
+
const config = getConfig();
|
|
247
|
+
if (!config.remotes || !config.remotes[name]) {
|
|
248
|
+
throw new Error(`远程服务器 "${name}" 不存在`);
|
|
249
|
+
}
|
|
250
|
+
// 清除工作目录
|
|
251
|
+
if (!workDir || workDir === '-') {
|
|
252
|
+
delete config.remotes[name].workDir;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
config.remotes[name].workDir = workDir;
|
|
256
|
+
}
|
|
257
|
+
saveConfig(config);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 获取远程服务器工作目录
|
|
261
|
+
*/
|
|
262
|
+
export function getRemoteWorkDir(name) {
|
|
263
|
+
const remote = getRemote(name);
|
|
264
|
+
return remote?.workDir;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 读取密码(交互式)
|
|
268
|
+
*/
|
|
269
|
+
async function readPassword(prompt) {
|
|
270
|
+
return new Promise((resolve) => {
|
|
271
|
+
const rl = readline.createInterface({
|
|
272
|
+
input: process.stdin,
|
|
273
|
+
output: process.stdout,
|
|
274
|
+
});
|
|
275
|
+
// 隐藏输入
|
|
276
|
+
const stdin = process.stdin;
|
|
277
|
+
if (stdin.isTTY) {
|
|
278
|
+
stdin.setRawMode(true);
|
|
279
|
+
}
|
|
280
|
+
process.stdout.write(prompt);
|
|
281
|
+
let password = '';
|
|
282
|
+
stdin.on('data', (char) => {
|
|
283
|
+
const c = char.toString();
|
|
284
|
+
switch (c) {
|
|
285
|
+
case '\n':
|
|
286
|
+
case '\r':
|
|
287
|
+
case '\u0004': // Ctrl+D
|
|
288
|
+
if (stdin.isTTY) {
|
|
289
|
+
stdin.setRawMode(false);
|
|
290
|
+
}
|
|
291
|
+
console.log('');
|
|
292
|
+
rl.close();
|
|
293
|
+
resolve(password);
|
|
294
|
+
break;
|
|
295
|
+
case '\u0003': // Ctrl+C
|
|
296
|
+
if (stdin.isTTY) {
|
|
297
|
+
stdin.setRawMode(false);
|
|
298
|
+
}
|
|
299
|
+
console.log('');
|
|
300
|
+
rl.close();
|
|
301
|
+
process.exit(0);
|
|
302
|
+
case '\u007F': // Backspace
|
|
303
|
+
if (password.length > 0) {
|
|
304
|
+
password = password.slice(0, -1);
|
|
305
|
+
process.stdout.write('\b \b');
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
default:
|
|
309
|
+
password += c;
|
|
310
|
+
process.stdout.write('*');
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 构建 SSH 命令参数
|
|
318
|
+
* @param remote 远程服务器配置
|
|
319
|
+
* @param command 要执行的命令
|
|
320
|
+
* @param options.password 密码(用于首次建立连接)
|
|
321
|
+
* @param options.socketPath ControlMaster socket 路径
|
|
322
|
+
* @param options.isMaster 是否建立 master 连接
|
|
323
|
+
*/
|
|
324
|
+
function buildSSHArgs(remote, command, options = {}) {
|
|
325
|
+
const args = [];
|
|
326
|
+
// 使用 sshpass 处理密码认证(仅在建立新连接时需要)
|
|
327
|
+
let cmd = 'ssh';
|
|
328
|
+
if (options.password) {
|
|
329
|
+
cmd = 'sshpass';
|
|
330
|
+
args.push('-p', options.password, 'ssh');
|
|
331
|
+
}
|
|
332
|
+
// SSH 选项
|
|
333
|
+
args.push('-o', 'StrictHostKeyChecking=accept-new');
|
|
334
|
+
args.push('-o', 'ConnectTimeout=10');
|
|
335
|
+
// ControlMaster 选项
|
|
336
|
+
if (options.socketPath) {
|
|
337
|
+
if (options.isMaster) {
|
|
338
|
+
// 建立 master 连接
|
|
339
|
+
args.push('-o', 'ControlMaster=yes');
|
|
340
|
+
args.push('-o', `ControlPersist=${SSH_CONTROL_PERSIST}`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// 复用已有连接
|
|
344
|
+
args.push('-o', 'ControlMaster=no');
|
|
345
|
+
}
|
|
346
|
+
args.push('-o', `ControlPath=${options.socketPath}`);
|
|
347
|
+
}
|
|
348
|
+
// 端口
|
|
349
|
+
if (remote.port !== 22) {
|
|
350
|
+
args.push('-p', remote.port.toString());
|
|
351
|
+
}
|
|
352
|
+
// 密钥
|
|
353
|
+
if (remote.key) {
|
|
354
|
+
const keyPath = remote.key.replace(/^~/, os.homedir());
|
|
355
|
+
args.push('-i', keyPath);
|
|
356
|
+
}
|
|
357
|
+
// 目标
|
|
358
|
+
args.push(`${remote.user}@${remote.host}`);
|
|
359
|
+
// 命令
|
|
360
|
+
args.push(command);
|
|
361
|
+
return { cmd, args };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 执行 SSH 命令的内部实现
|
|
365
|
+
*/
|
|
366
|
+
function spawnSSH(cmd, args, options) {
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
let stdout = '';
|
|
369
|
+
let stderr = '';
|
|
370
|
+
const child = spawn(cmd, args, {
|
|
371
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
372
|
+
});
|
|
373
|
+
// 超时处理
|
|
374
|
+
let timeoutId = null;
|
|
375
|
+
if (options.timeout) {
|
|
376
|
+
timeoutId = setTimeout(() => {
|
|
377
|
+
child.kill('SIGTERM');
|
|
378
|
+
reject(new Error(`命令执行超时 (${options.timeout}ms)`));
|
|
379
|
+
}, options.timeout);
|
|
380
|
+
}
|
|
381
|
+
child.stdout.on('data', (data) => {
|
|
382
|
+
const str = data.toString();
|
|
383
|
+
stdout += str;
|
|
384
|
+
options.onStdout?.(str);
|
|
385
|
+
});
|
|
386
|
+
child.stderr.on('data', (data) => {
|
|
387
|
+
const str = data.toString();
|
|
388
|
+
stderr += str;
|
|
389
|
+
options.onStderr?.(str);
|
|
390
|
+
});
|
|
391
|
+
// 写入 stdin
|
|
392
|
+
if (options.stdin) {
|
|
393
|
+
child.stdin.write(options.stdin);
|
|
394
|
+
child.stdin.end();
|
|
395
|
+
}
|
|
396
|
+
child.on('close', (code) => {
|
|
397
|
+
if (timeoutId) {
|
|
398
|
+
clearTimeout(timeoutId);
|
|
399
|
+
}
|
|
400
|
+
resolve({
|
|
401
|
+
exitCode: code || 0,
|
|
402
|
+
stdout,
|
|
403
|
+
stderr,
|
|
404
|
+
output: stdout + stderr,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
child.on('error', (err) => {
|
|
408
|
+
if (timeoutId) {
|
|
409
|
+
clearTimeout(timeoutId);
|
|
410
|
+
}
|
|
411
|
+
// 检查是否是 sshpass 未安装
|
|
412
|
+
if (err.message.includes('ENOENT') && cmd === 'sshpass') {
|
|
413
|
+
reject(new Error('密码认证需要安装 sshpass,请运行: brew install hudochenkov/sshpass/sshpass'));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
reject(err);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* 通过 SSH 执行命令
|
|
423
|
+
* 使用 ControlMaster 实现连接复用,密码认证只需输入一次
|
|
424
|
+
*/
|
|
425
|
+
export async function sshExec(name, command, options = {}) {
|
|
426
|
+
const remote = getRemote(name);
|
|
427
|
+
if (!remote) {
|
|
428
|
+
throw new Error(`远程服务器 "${name}" 不存在`);
|
|
429
|
+
}
|
|
430
|
+
// 确保数据目录存在
|
|
431
|
+
ensureRemoteDataDir(name);
|
|
432
|
+
const socketPath = getSSHSocketPath(name);
|
|
433
|
+
const masterActive = isControlMasterActive(name);
|
|
434
|
+
// 如果需要密码认证且没有活跃的 master 连接
|
|
435
|
+
if (remote.password && !masterActive) {
|
|
436
|
+
// 读取密码并建立 master 连接
|
|
437
|
+
const password = await readPassword(`${name} 密码: `);
|
|
438
|
+
// 建立 master 连接(执行一个简单命令来建立连接)
|
|
439
|
+
const { cmd: masterCmd, args: masterArgs } = buildSSHArgs(remote, 'true', {
|
|
440
|
+
password,
|
|
441
|
+
socketPath,
|
|
442
|
+
isMaster: true,
|
|
443
|
+
});
|
|
444
|
+
try {
|
|
445
|
+
const masterResult = await spawnSSH(masterCmd, masterArgs, { timeout: 30000 });
|
|
446
|
+
if (masterResult.exitCode !== 0) {
|
|
447
|
+
throw new Error(`SSH 连接失败: ${masterResult.stderr}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// 使用 ControlMaster 连接(或直接连接)执行命令
|
|
455
|
+
const useSocket = remote.password || isControlMasterActive(name);
|
|
456
|
+
const { cmd, args } = buildSSHArgs(remote, command, {
|
|
457
|
+
socketPath: useSocket ? socketPath : undefined,
|
|
458
|
+
isMaster: false,
|
|
459
|
+
});
|
|
460
|
+
return spawnSSH(cmd, args, options);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 测试远程连接
|
|
464
|
+
*/
|
|
465
|
+
export async function testRemoteConnection(name) {
|
|
466
|
+
const colors = getColors();
|
|
467
|
+
try {
|
|
468
|
+
const result = await sshExec(name, 'echo "pls-connection-test"', { timeout: 15000 });
|
|
469
|
+
if (result.exitCode === 0 && result.stdout.includes('pls-connection-test')) {
|
|
470
|
+
return { success: true, message: chalk.hex(colors.success)('连接成功') };
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
return { success: false, message: chalk.hex(colors.error)(`连接失败,退出码: ${result.exitCode}`) };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
478
|
+
return { success: false, message: chalk.hex(colors.error)(`连接失败: ${message}`) };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// ================== 系统信息采集 ==================
|
|
482
|
+
/**
|
|
483
|
+
* 获取缓存的远程系统信息
|
|
484
|
+
*/
|
|
485
|
+
export function getRemoteSysInfo(name) {
|
|
486
|
+
const dataDir = getRemoteDataDir(name);
|
|
487
|
+
const sysInfoPath = path.join(dataDir, 'sysinfo.json');
|
|
488
|
+
if (!fs.existsSync(sysInfoPath)) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const content = fs.readFileSync(sysInfoPath, 'utf-8');
|
|
493
|
+
return JSON.parse(content);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 保存远程系统信息
|
|
501
|
+
*/
|
|
502
|
+
function saveRemoteSysInfo(name, sysInfo) {
|
|
503
|
+
ensureRemoteDataDir(name);
|
|
504
|
+
const dataDir = getRemoteDataDir(name);
|
|
505
|
+
const sysInfoPath = path.join(dataDir, 'sysinfo.json');
|
|
506
|
+
fs.writeFileSync(sysInfoPath, JSON.stringify(sysInfo, null, 2));
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* 采集远程系统信息
|
|
510
|
+
*/
|
|
511
|
+
export async function collectRemoteSysInfo(name, force = false) {
|
|
512
|
+
// 检查缓存
|
|
513
|
+
if (!force) {
|
|
514
|
+
const cached = getRemoteSysInfo(name);
|
|
515
|
+
if (cached) {
|
|
516
|
+
// 检查缓存是否过期(7天)
|
|
517
|
+
const cachedAt = new Date(cached.cachedAt);
|
|
518
|
+
const now = new Date();
|
|
519
|
+
const daysDiff = (now.getTime() - cachedAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
520
|
+
if (daysDiff < 7) {
|
|
521
|
+
return cached;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// 采集系统信息
|
|
526
|
+
const collectScript = `
|
|
527
|
+
echo "OS:$(uname -s)"
|
|
528
|
+
echo "OS_VERSION:$(uname -r)"
|
|
529
|
+
echo "SHELL:$(basename "$SHELL")"
|
|
530
|
+
echo "HOSTNAME:$(hostname)"
|
|
531
|
+
`.trim();
|
|
532
|
+
const result = await sshExec(name, collectScript, { timeout: 30000 });
|
|
533
|
+
if (result.exitCode !== 0) {
|
|
534
|
+
throw new Error(`无法采集系统信息: ${result.stderr}`);
|
|
535
|
+
}
|
|
536
|
+
// 解析输出
|
|
537
|
+
const lines = result.stdout.split('\n');
|
|
538
|
+
const info = {};
|
|
539
|
+
for (const line of lines) {
|
|
540
|
+
const colonIndex = line.indexOf(':');
|
|
541
|
+
if (colonIndex > 0) {
|
|
542
|
+
const key = line.substring(0, colonIndex).trim();
|
|
543
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
544
|
+
info[key] = value;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const sysInfo = {
|
|
548
|
+
os: info['OS'] || 'unknown',
|
|
549
|
+
osVersion: info['OS_VERSION'] || 'unknown',
|
|
550
|
+
shell: info['SHELL'] || 'bash',
|
|
551
|
+
hostname: info['HOSTNAME'] || 'unknown',
|
|
552
|
+
cachedAt: new Date().toISOString(),
|
|
553
|
+
};
|
|
554
|
+
// 保存缓存
|
|
555
|
+
saveRemoteSysInfo(name, sysInfo);
|
|
556
|
+
return sysInfo;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 格式化远程系统信息供 AI 使用
|
|
560
|
+
*/
|
|
561
|
+
export function formatRemoteSysInfoForAI(name, sysInfo) {
|
|
562
|
+
const remote = getRemote(name);
|
|
563
|
+
if (!remote)
|
|
564
|
+
return '';
|
|
565
|
+
let info = `【远程服务器信息】
|
|
566
|
+
服务器: ${name} (${remote.user}@${remote.host})
|
|
567
|
+
操作系统: ${sysInfo.os} ${sysInfo.osVersion}
|
|
568
|
+
Shell: ${sysInfo.shell}
|
|
569
|
+
主机名: ${sysInfo.hostname}`;
|
|
570
|
+
// 如果有工作目录,告知 AI 当前工作目录(执行时会自动 cd)
|
|
571
|
+
if (remote.workDir) {
|
|
572
|
+
info += `\n当前工作目录: ${remote.workDir}`;
|
|
573
|
+
}
|
|
574
|
+
return info;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* 批量远程执行命令
|
|
578
|
+
* 每个服务器单独生成命令,支持异构环境
|
|
579
|
+
*/
|
|
580
|
+
export async function generateBatchRemoteCommands(serverNames, userPrompt, options = {}) {
|
|
581
|
+
const { generateMultiStepCommand } = await import('./multi-step.js');
|
|
582
|
+
const { fetchRemoteShellHistory } = await import('./remote-history.js');
|
|
583
|
+
// 1. 验证所有服务器是否存在
|
|
584
|
+
const invalidServers = serverNames.filter(name => !getRemote(name));
|
|
585
|
+
if (invalidServers.length > 0) {
|
|
586
|
+
throw new Error(`以下服务器不存在: ${invalidServers.join(', ')}`);
|
|
587
|
+
}
|
|
588
|
+
// 2. 并发采集所有服务器的系统信息
|
|
589
|
+
const servers = await Promise.all(serverNames.map(async (name) => ({
|
|
590
|
+
name,
|
|
591
|
+
sysInfo: await collectRemoteSysInfo(name),
|
|
592
|
+
shellHistory: await fetchRemoteShellHistory(name),
|
|
593
|
+
})));
|
|
594
|
+
// 3. 并发为每个服务器生成命令
|
|
595
|
+
const commandResults = await Promise.all(servers.map(async (server) => {
|
|
596
|
+
const remoteContext = {
|
|
597
|
+
name: server.name,
|
|
598
|
+
sysInfo: server.sysInfo,
|
|
599
|
+
shellHistory: server.shellHistory,
|
|
600
|
+
};
|
|
601
|
+
const result = await generateMultiStepCommand(userPrompt, [], // 批量执行不支持多步骤,只生成单个命令
|
|
602
|
+
{ debug: options.debug, remoteContext });
|
|
603
|
+
return {
|
|
604
|
+
server: server.name,
|
|
605
|
+
command: result.stepData.command,
|
|
606
|
+
sysInfo: server.sysInfo,
|
|
607
|
+
};
|
|
608
|
+
}));
|
|
609
|
+
return commandResults;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* 执行批量远程命令
|
|
613
|
+
*/
|
|
614
|
+
export async function executeBatchRemoteCommands(commands) {
|
|
615
|
+
// 并发执行所有命令
|
|
616
|
+
const results = await Promise.all(commands.map(async ({ server, command, sysInfo }) => {
|
|
617
|
+
let stdout = '';
|
|
618
|
+
let stderr = '';
|
|
619
|
+
const result = await sshExec(server, command, {
|
|
620
|
+
onStdout: (data) => { stdout += data; },
|
|
621
|
+
onStderr: (data) => { stderr += data; },
|
|
622
|
+
});
|
|
623
|
+
return {
|
|
624
|
+
server,
|
|
625
|
+
command,
|
|
626
|
+
exitCode: result.exitCode,
|
|
627
|
+
stdout,
|
|
628
|
+
stderr,
|
|
629
|
+
output: stdout + stderr,
|
|
630
|
+
sysInfo,
|
|
631
|
+
};
|
|
632
|
+
}));
|
|
633
|
+
return results;
|
|
634
|
+
}
|