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