@yivan-lab/pretty-please 1.4.0 → 1.5.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 (94) hide show
  1. package/README.md +30 -2
  2. package/bin/pls.tsx +153 -35
  3. package/dist/bin/pls.js +126 -23
  4. package/dist/package.json +10 -2
  5. package/dist/src/__integration__/command-generation.test.d.ts +5 -0
  6. package/dist/src/__integration__/command-generation.test.js +508 -0
  7. package/dist/src/__integration__/error-recovery.test.d.ts +5 -0
  8. package/dist/src/__integration__/error-recovery.test.js +511 -0
  9. package/dist/src/__integration__/shell-hook-workflow.test.d.ts +5 -0
  10. package/dist/src/__integration__/shell-hook-workflow.test.js +375 -0
  11. package/dist/src/__tests__/alias.test.d.ts +5 -0
  12. package/dist/src/__tests__/alias.test.js +421 -0
  13. package/dist/src/__tests__/chat-history.test.d.ts +5 -0
  14. package/dist/src/__tests__/chat-history.test.js +372 -0
  15. package/dist/src/__tests__/config.test.d.ts +5 -0
  16. package/dist/src/__tests__/config.test.js +822 -0
  17. package/dist/src/__tests__/history.test.d.ts +5 -0
  18. package/dist/src/__tests__/history.test.js +439 -0
  19. package/dist/src/__tests__/remote-history.test.d.ts +5 -0
  20. package/dist/src/__tests__/remote-history.test.js +641 -0
  21. package/dist/src/__tests__/remote.test.d.ts +5 -0
  22. package/dist/src/__tests__/remote.test.js +689 -0
  23. package/dist/src/__tests__/shell-hook-install.test.d.ts +5 -0
  24. package/dist/src/__tests__/shell-hook-install.test.js +413 -0
  25. package/dist/src/__tests__/shell-hook-remote.test.d.ts +5 -0
  26. package/dist/src/__tests__/shell-hook-remote.test.js +507 -0
  27. package/dist/src/__tests__/shell-hook.test.d.ts +5 -0
  28. package/dist/src/__tests__/shell-hook.test.js +440 -0
  29. package/dist/src/__tests__/sysinfo.test.d.ts +5 -0
  30. package/dist/src/__tests__/sysinfo.test.js +572 -0
  31. package/dist/src/__tests__/system-history.test.d.ts +5 -0
  32. package/dist/src/__tests__/system-history.test.js +457 -0
  33. package/dist/src/components/Chat.js +9 -28
  34. package/dist/src/config.d.ts +2 -0
  35. package/dist/src/config.js +30 -2
  36. package/dist/src/mastra-chat.js +6 -3
  37. package/dist/src/multi-step.js +6 -3
  38. package/dist/src/project-context.d.ts +22 -0
  39. package/dist/src/project-context.js +168 -0
  40. package/dist/src/prompts.d.ts +4 -4
  41. package/dist/src/prompts.js +23 -6
  42. package/dist/src/shell-hook.d.ts +13 -0
  43. package/dist/src/shell-hook.js +163 -33
  44. package/dist/src/sysinfo.d.ts +38 -9
  45. package/dist/src/sysinfo.js +245 -21
  46. package/dist/src/system-history.d.ts +5 -0
  47. package/dist/src/system-history.js +64 -18
  48. package/dist/src/ui/__tests__/theme.test.d.ts +5 -0
  49. package/dist/src/ui/__tests__/theme.test.js +688 -0
  50. package/dist/src/upgrade.js +3 -0
  51. package/dist/src/user-preferences.d.ts +44 -0
  52. package/dist/src/user-preferences.js +147 -0
  53. package/dist/src/utils/__tests__/platform-capabilities.test.d.ts +5 -0
  54. package/dist/src/utils/__tests__/platform-capabilities.test.js +214 -0
  55. package/dist/src/utils/__tests__/platform-exec.test.d.ts +5 -0
  56. package/dist/src/utils/__tests__/platform-exec.test.js +212 -0
  57. package/dist/src/utils/__tests__/platform-shell.test.d.ts +5 -0
  58. package/dist/src/utils/__tests__/platform-shell.test.js +300 -0
  59. package/dist/src/utils/__tests__/platform.test.d.ts +5 -0
  60. package/dist/src/utils/__tests__/platform.test.js +137 -0
  61. package/dist/src/utils/platform.d.ts +88 -0
  62. package/dist/src/utils/platform.js +331 -0
  63. package/package.json +10 -2
  64. package/src/__integration__/command-generation.test.ts +602 -0
  65. package/src/__integration__/error-recovery.test.ts +620 -0
  66. package/src/__integration__/shell-hook-workflow.test.ts +457 -0
  67. package/src/__tests__/alias.test.ts +545 -0
  68. package/src/__tests__/chat-history.test.ts +462 -0
  69. package/src/__tests__/config.test.ts +1043 -0
  70. package/src/__tests__/history.test.ts +538 -0
  71. package/src/__tests__/remote-history.test.ts +791 -0
  72. package/src/__tests__/remote.test.ts +866 -0
  73. package/src/__tests__/shell-hook-install.test.ts +510 -0
  74. package/src/__tests__/shell-hook-remote.test.ts +679 -0
  75. package/src/__tests__/shell-hook.test.ts +564 -0
  76. package/src/__tests__/sysinfo.test.ts +718 -0
  77. package/src/__tests__/system-history.test.ts +608 -0
  78. package/src/components/Chat.tsx +10 -37
  79. package/src/config.ts +29 -2
  80. package/src/mastra-chat.ts +8 -3
  81. package/src/multi-step.ts +7 -2
  82. package/src/project-context.ts +191 -0
  83. package/src/prompts.ts +26 -5
  84. package/src/shell-hook.ts +179 -33
  85. package/src/sysinfo.ts +326 -25
  86. package/src/system-history.ts +67 -14
  87. package/src/ui/__tests__/theme.test.ts +869 -0
  88. package/src/upgrade.ts +5 -0
  89. package/src/user-preferences.ts +178 -0
  90. package/src/utils/__tests__/platform-capabilities.test.ts +265 -0
  91. package/src/utils/__tests__/platform-exec.test.ts +278 -0
  92. package/src/utils/__tests__/platform-shell.test.ts +353 -0
  93. package/src/utils/__tests__/platform.test.ts +170 -0
  94. package/src/utils/platform.ts +431 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Shell Hook 工作流集成测试
3
+ * 测试完整的 Hook 安装、命令记录、配置变更、卸载恢复等工作流
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
7
+
8
+ // Mock child_process 模块
9
+ vi.mock('child_process', () => ({
10
+ exec: vi.fn(),
11
+ execSync: vi.fn(),
12
+ spawn: vi.fn(),
13
+ }))
14
+
15
+ // Mock system-history 模块
16
+ vi.mock('../system-history.js', () => ({
17
+ getSystemShellHistory: vi.fn(() => []),
18
+ }))
19
+
20
+ // Mock fs 模块
21
+ vi.mock('fs', () => ({
22
+ default: {
23
+ existsSync: vi.fn(),
24
+ readFileSync: vi.fn(),
25
+ writeFileSync: vi.fn(),
26
+ appendFileSync: vi.fn(),
27
+ copyFileSync: vi.fn(),
28
+ mkdirSync: vi.fn(),
29
+ unlinkSync: vi.fn(),
30
+ rmSync: vi.fn(),
31
+ statSync: vi.fn(),
32
+ },
33
+ }))
34
+
35
+ // Mock os 模块
36
+ vi.mock('os', () => ({
37
+ default: {
38
+ homedir: vi.fn(() => '/home/testuser'),
39
+ platform: vi.fn(() => 'linux'),
40
+ type: vi.fn(() => 'Linux'),
41
+ release: vi.fn(() => '5.4.0'),
42
+ arch: vi.fn(() => 'x64'),
43
+ userInfo: vi.fn(() => ({ username: 'testuser' })),
44
+ hostname: vi.fn(() => 'testhost'),
45
+ },
46
+ }))
47
+
48
+ // Mock config 模块
49
+ const mockConfig = {
50
+ shellHook: false,
51
+ shellHistoryLimit: 20,
52
+ commandHistoryLimit: 50,
53
+ }
54
+
55
+ vi.mock('../config.js', () => ({
56
+ getConfig: vi.fn(() => mockConfig),
57
+ saveConfig: vi.fn(),
58
+ CONFIG_DIR: '/home/testuser/.please',
59
+ }))
60
+
61
+ // Mock theme 模块
62
+ vi.mock('../ui/theme.js', () => ({
63
+ getCurrentTheme: vi.fn(() => ({
64
+ primary: '#007acc',
65
+ secondary: '#6c757d',
66
+ success: '#4caf50',
67
+ error: '#f44336',
68
+ warning: '#ff9800',
69
+ text: { muted: '#666666' },
70
+ })),
71
+ }))
72
+
73
+ // Mock chalk
74
+ vi.mock('chalk', () => ({
75
+ default: {
76
+ bold: vi.fn((s: string) => s),
77
+ gray: vi.fn((s: string) => s),
78
+ dim: vi.fn((s: string) => s),
79
+ hex: vi.fn(() => (s: string) => s),
80
+ green: vi.fn((s: string) => s),
81
+ red: vi.fn((s: string) => s),
82
+ yellow: vi.fn((s: string) => s),
83
+ },
84
+ }))
85
+
86
+ import fs from 'fs'
87
+ import os from 'os'
88
+ import { exec } from 'child_process'
89
+ import { getConfig, saveConfig, CONFIG_DIR } from '../config.js'
90
+
91
+ const mockFs = vi.mocked(fs)
92
+ const mockOs = vi.mocked(os)
93
+ const mockExec = vi.mocked(exec)
94
+ const mockGetConfig = vi.mocked(getConfig)
95
+ const mockSaveConfig = vi.mocked(saveConfig)
96
+
97
+ // 重置模块辅助函数
98
+ async function resetModules() {
99
+ vi.resetModules()
100
+ return {
101
+ shellHook: await import('../shell-hook.js'),
102
+ history: await import('../history.js'),
103
+ }
104
+ }
105
+
106
+ // 模拟 Shell 配置文件内容
107
+ const ZSHRC_TEMPLATE = `# User configuration
108
+ export PATH="/usr/local/bin:$PATH"
109
+ alias ll="ls -la"
110
+ `
111
+
112
+ const ZSHRC_WITH_HOOK = `# User configuration
113
+ export PATH="/usr/local/bin:$PATH"
114
+ alias ll="ls -la"
115
+
116
+ # >>> pretty-please shell hook >>>
117
+ # 此代码块由 pls 自动生成,请勿手动修改
118
+ autoload -Uz add-zsh-hook
119
+ _pls_preexec() {
120
+ export _PLS_LAST_CMD="$1"
121
+ export _PLS_CMD_START=$(date +%s)
122
+ }
123
+ _pls_precmd() {
124
+ local exit_code=$?
125
+ # Hook code...
126
+ }
127
+ add-zsh-hook preexec _pls_preexec
128
+ add-zsh-hook precmd _pls_precmd
129
+ # <<< pretty-please shell hook <<<
130
+ `
131
+
132
+ // 模拟 Shell 历史记录(JSONL 格式)
133
+ const SHELL_HISTORY_JSONL = `{"cmd":"ls -la","exit":0,"time":"2024-01-01T10:00:00.000Z"}
134
+ {"cmd":"cd /home","exit":0,"time":"2024-01-01T10:01:00.000Z"}
135
+ {"cmd":"git status","exit":0,"time":"2024-01-01T10:02:00.000Z"}
136
+ {"cmd":"invalid-command","exit":127,"time":"2024-01-01T10:03:00.000Z"}
137
+ {"cmd":"pls check disk","exit":0,"time":"2024-01-01T10:04:00.000Z"}
138
+ `
139
+
140
+ beforeEach(() => {
141
+ vi.clearAllMocks()
142
+ mockOs.homedir.mockReturnValue('/home/testuser')
143
+ mockOs.platform.mockReturnValue('linux')
144
+ mockFs.mkdirSync.mockImplementation(() => undefined)
145
+ mockFs.writeFileSync.mockImplementation(() => {})
146
+ mockFs.appendFileSync.mockImplementation(() => {})
147
+ mockFs.copyFileSync.mockImplementation(() => {})
148
+ mockSaveConfig.mockImplementation(() => {})
149
+
150
+ // 重置配置状态
151
+ Object.assign(mockConfig, {
152
+ shellHook: false,
153
+ shellHistoryLimit: 20,
154
+ commandHistoryLimit: 50,
155
+ })
156
+ })
157
+
158
+ afterEach(() => {
159
+ vi.restoreAllMocks()
160
+ })
161
+
162
+ // ============================================================================
163
+ // 完整安装流程测试
164
+ // ============================================================================
165
+
166
+ describe('Shell Hook 完整安装流程', () => {
167
+ it('首次安装应该: 检测Shell → getHookStatus 返回未安装', async () => {
168
+ // 设置环境: Zsh shell, 配置文件存在但无 Hook
169
+ process.env.SHELL = '/bin/zsh'
170
+ mockConfig.shellHook = false
171
+ mockFs.existsSync.mockImplementation((path: any) => {
172
+ if (path.includes('.zshrc')) return true
173
+ if (path.includes('.please')) return true
174
+ if (path.includes('shell_history')) return false
175
+ return false
176
+ })
177
+ mockFs.readFileSync.mockImplementation((path: any) => {
178
+ if (path.toString().includes('.zshrc')) return ZSHRC_TEMPLATE
179
+ return ''
180
+ })
181
+
182
+ const { shellHook } = await resetModules()
183
+
184
+ // 验证 Hook 状态检测
185
+ const status = shellHook.getHookStatus()
186
+
187
+ expect(status.installed).toBe(false)
188
+ })
189
+
190
+ it('已安装时 getHookStatus 应该返回 installed: true', async () => {
191
+ // 确保使用 Unix 平台检测逻辑
192
+ Object.defineProperty(process, 'platform', { value: 'linux' })
193
+ process.env.SHELL = '/bin/zsh'
194
+ mockConfig.shellHook = true
195
+ mockFs.existsSync.mockReturnValue(true)
196
+ mockFs.readFileSync.mockImplementation((path: any) => {
197
+ if (path.toString().includes('.zshrc')) return ZSHRC_WITH_HOOK
198
+ return ''
199
+ })
200
+
201
+ const { shellHook } = await resetModules()
202
+
203
+ // 检测是否已安装
204
+ const status = shellHook.getHookStatus()
205
+
206
+ expect(status.installed).toBe(true)
207
+ })
208
+
209
+ it('Zsh 应该使用 .zshrc 配置文件', async () => {
210
+ const { shellHook } = await resetModules()
211
+
212
+ // Zsh (跨平台)
213
+ expect(shellHook.getShellConfigPath('zsh')).toContain('.zshrc')
214
+ })
215
+ })
216
+
217
+ // ============================================================================
218
+ // 命令记录流程测试
219
+ // ============================================================================
220
+
221
+ describe('命令记录流程', () => {
222
+ it('应该正确解析 JSONL 格式的 Shell 历史', async () => {
223
+ mockConfig.shellHook = true
224
+ mockFs.existsSync.mockReturnValue(true)
225
+ mockFs.readFileSync.mockReturnValue(SHELL_HISTORY_JSONL)
226
+
227
+ const { shellHook } = await resetModules()
228
+ const history = shellHook.getShellHistory()
229
+
230
+ expect(history.length).toBeGreaterThan(0)
231
+ expect(history[0]).toHaveProperty('cmd')
232
+ expect(history[0]).toHaveProperty('exit')
233
+ expect(history[0]).toHaveProperty('time')
234
+ })
235
+
236
+ it('shellHook 未启用时应该返回空数组', async () => {
237
+ mockConfig.shellHook = false
238
+
239
+ const { shellHook } = await resetModules()
240
+ const history = shellHook.getShellHistory()
241
+
242
+ expect(history).toEqual([])
243
+ })
244
+
245
+ it('历史文件不存在时应该返回空数组', async () => {
246
+ mockConfig.shellHook = true
247
+ mockFs.existsSync.mockReturnValue(false)
248
+
249
+ const { shellHook } = await resetModules()
250
+ const history = shellHook.getShellHistory()
251
+
252
+ expect(history).toEqual([])
253
+ })
254
+
255
+ it('应该按 shellHistoryLimit 限制返回数量', async () => {
256
+ mockConfig.shellHook = true
257
+ mockConfig.shellHistoryLimit = 3
258
+
259
+ // 创建 10 条历史记录
260
+ const manyRecords = Array.from({ length: 10 }, (_, i) =>
261
+ JSON.stringify({ cmd: `cmd${i}`, exit: 0, time: new Date().toISOString() })
262
+ ).join('\n')
263
+
264
+ mockFs.existsSync.mockReturnValue(true)
265
+ mockFs.readFileSync.mockReturnValue(manyRecords)
266
+
267
+ const { shellHook } = await resetModules()
268
+ const history = shellHook.getShellHistory()
269
+
270
+ expect(history.length).toBeLessThanOrEqual(3)
271
+ })
272
+
273
+ it('应该跳过无效的 JSON 行', async () => {
274
+ mockConfig.shellHook = true
275
+ const invalidJsonl = `{"cmd":"valid1","exit":0,"time":"2024-01-01"}
276
+ invalid line here
277
+ {"cmd":"valid2","exit":0,"time":"2024-01-01"}
278
+ {broken json
279
+ {"cmd":"valid3","exit":0,"time":"2024-01-01"}`
280
+
281
+ mockFs.existsSync.mockReturnValue(true)
282
+ mockFs.readFileSync.mockReturnValue(invalidJsonl)
283
+
284
+ const { shellHook } = await resetModules()
285
+ const history = shellHook.getShellHistory()
286
+
287
+ expect(history.length).toBe(3) // 只有 3 条有效记录
288
+ })
289
+ })
290
+
291
+ // ============================================================================
292
+ // AI 格式化流程测试
293
+ // ============================================================================
294
+
295
+ describe('Shell 历史 AI 格式化', () => {
296
+ it('formatShellHistoryForAI 应该包含命令和状态', async () => {
297
+ mockConfig.shellHook = true
298
+ mockFs.existsSync.mockReturnValue(true)
299
+ mockFs.readFileSync.mockReturnValue(SHELL_HISTORY_JSONL)
300
+
301
+ const { shellHook } = await resetModules()
302
+ const formatted = shellHook.formatShellHistoryForAI()
303
+
304
+ expect(formatted).toContain('ls -la')
305
+ expect(formatted).toContain('git status')
306
+ })
307
+
308
+ it('失败命令应该显示退出码', async () => {
309
+ mockConfig.shellHook = true
310
+ const failedCommand = `{"cmd":"invalid-command","exit":127,"time":"2024-01-01"}`
311
+ mockFs.existsSync.mockReturnValue(true)
312
+ mockFs.readFileSync.mockReturnValue(failedCommand)
313
+
314
+ const { shellHook } = await resetModules()
315
+ const formatted = shellHook.formatShellHistoryForAI()
316
+
317
+ expect(formatted).toContain('127')
318
+ })
319
+
320
+ it('空历史应该返回空字符串', async () => {
321
+ mockConfig.shellHook = true
322
+ mockFs.existsSync.mockReturnValue(true)
323
+ mockFs.readFileSync.mockReturnValue('')
324
+
325
+ const { shellHook } = await resetModules()
326
+ const formatted = shellHook.formatShellHistoryForAI()
327
+
328
+ expect(formatted).toBe('')
329
+ })
330
+ })
331
+
332
+ // ============================================================================
333
+ // pls 命令历史与 Shell 历史关联测试
334
+ // ============================================================================
335
+
336
+ describe('pls 命令历史与 Shell 历史关联', () => {
337
+ it('addHistory 应该记录用户修改标记', async () => {
338
+ mockFs.existsSync.mockReturnValue(true)
339
+ mockFs.readFileSync.mockReturnValue(JSON.stringify([]))
340
+
341
+ let savedHistory: any[] = []
342
+ mockFs.writeFileSync.mockImplementation((path: any, content: any) => {
343
+ if (path.toString().includes('history.json')) {
344
+ savedHistory = JSON.parse(content)
345
+ }
346
+ })
347
+
348
+ const { history } = await resetModules()
349
+ history.addHistory({
350
+ userPrompt: '检查磁盘',
351
+ command: 'df -h /home',
352
+ aiGeneratedCommand: 'df -h',
353
+ userModified: true,
354
+ executed: true,
355
+ exitCode: 0,
356
+ output: '',
357
+ })
358
+
359
+ expect(savedHistory.length).toBe(1)
360
+ expect(savedHistory[0].userModified).toBe(true)
361
+ expect(savedHistory[0].aiGeneratedCommand).toBe('df -h')
362
+ })
363
+
364
+ it('addHistory 应该记录 builtin 原因', async () => {
365
+ mockFs.existsSync.mockReturnValue(true)
366
+ mockFs.readFileSync.mockReturnValue(JSON.stringify([]))
367
+
368
+ let savedHistory: any[] = []
369
+ mockFs.writeFileSync.mockImplementation((path: any, content: any) => {
370
+ if (path.toString().includes('history.json')) {
371
+ savedHistory = JSON.parse(content)
372
+ }
373
+ })
374
+
375
+ const { history } = await resetModules()
376
+ history.addHistory({
377
+ userPrompt: '删除文件',
378
+ command: 'rm -rf important',
379
+ executed: false,
380
+ exitCode: null,
381
+ output: '',
382
+ reason: 'builtin',
383
+ })
384
+
385
+ expect(savedHistory[0].executed).toBe(false)
386
+ expect(savedHistory[0].reason).toBe('builtin')
387
+ })
388
+ })
389
+
390
+ // ============================================================================
391
+ // 配置变更流程测试
392
+ // ============================================================================
393
+
394
+ describe('配置变更影响 Hook 行为', () => {
395
+ it('修改 shellHistoryLimit 应该影响历史返回数量', async () => {
396
+ mockConfig.shellHook = true
397
+
398
+ // 创建 50 条记录
399
+ const records = Array.from({ length: 50 }, (_, i) =>
400
+ JSON.stringify({ cmd: `cmd${i}`, exit: 0, time: new Date().toISOString() })
401
+ ).join('\n')
402
+ mockFs.existsSync.mockReturnValue(true)
403
+ mockFs.readFileSync.mockReturnValue(records)
404
+
405
+ // 限制为 10
406
+ mockConfig.shellHistoryLimit = 10
407
+ const { shellHook } = await resetModules()
408
+ const history10 = shellHook.getShellHistory()
409
+ expect(history10.length).toBeLessThanOrEqual(10)
410
+
411
+ // 限制为 30
412
+ mockConfig.shellHistoryLimit = 30
413
+ const { shellHook: shellHook2 } = await resetModules()
414
+ const history30 = shellHook2.getShellHistory()
415
+ expect(history30.length).toBeLessThanOrEqual(30)
416
+ })
417
+ })
418
+
419
+ // ============================================================================
420
+ // Shell 检测测试
421
+ // ============================================================================
422
+
423
+ describe('Shell 类型检测', () => {
424
+ const originalPlatform = process.platform
425
+
426
+ beforeEach(() => {
427
+ // Stub process.platform 为 Linux(非 Windows)
428
+ Object.defineProperty(process, 'platform', { value: 'linux' })
429
+ })
430
+
431
+ afterEach(() => {
432
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
433
+ })
434
+
435
+ it('应该从 SHELL 环境变量检测 Zsh', async () => {
436
+ process.env.SHELL = '/bin/zsh'
437
+ const { shellHook } = await resetModules()
438
+
439
+ expect(shellHook.detectShell()).toBe('zsh')
440
+ })
441
+
442
+ it('应该从 SHELL 环境变量检测 Bash', async () => {
443
+ process.env.SHELL = '/bin/bash'
444
+ const { shellHook } = await resetModules()
445
+
446
+ expect(shellHook.detectShell()).toBe('bash')
447
+ })
448
+
449
+ it('getShellConfigPath 对于不支持的 Shell 应该返回 null', async () => {
450
+ const { shellHook } = await resetModules()
451
+
452
+ // 'cmd' 不是支持的 shell
453
+ const path = shellHook.getShellConfigPath('cmd' as any)
454
+
455
+ expect(path).toBeNull()
456
+ })
457
+ })