koishi-plugin-spawn-modified 1.2.7 → 1.2.9

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/src/index.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { exec } from 'child_process'
2
- import { Context, h, Schema, Time } from 'koishi'
3
- import os from 'os'
2
+ import { Context, h } from 'koishi'
4
3
  import path from 'path'
5
- import { pathToFileURL } from 'url'
6
- import AnsiToHtml from 'ansi-to-html'
4
+
5
+ import { Config } from './config'
6
+ import { isCommandBlocked, validateCdCommand, validatePathAccess, maskCurlOutput } from './utils'
7
+ import { renderTerminalImage } from './render'
8
+ import { debugLog, debugLogResult } from './logger'
9
+
10
+ // Re-export config for plugin registration
11
+ export { Config } from './config'
7
12
 
8
13
  declare module 'koishi' {
9
14
  interface Context {
@@ -13,36 +18,6 @@ declare module 'koishi' {
13
18
  }
14
19
  }
15
20
 
16
- const encodings = ['utf8', 'utf16le', 'latin1', 'ucs2'] as const
17
-
18
- export interface Config {
19
- root?: string
20
- shell?: string
21
- encoding?: typeof encodings[number]
22
- timeout?: number
23
- renderImage?: boolean
24
- exemptUsers?: string[]
25
- blockedCommands?: string[]
26
- restrictDirectory?: boolean
27
- authority?: number
28
- commandFilterMode?: 'blacklist' | 'whitelist'
29
- commandList?: string[]
30
- }
31
-
32
- export const Config: Schema<Config> = Schema.object({
33
- root: Schema.string().description('工作路径。').default(''),
34
- shell: Schema.string().description('运行命令的程序。'),
35
- encoding: Schema.union(encodings).description('输出内容编码。').default('utf8'),
36
- timeout: Schema.number().description('最长运行时间。').default(Time.minute),
37
- renderImage: Schema.boolean().description('是否将命令执行结果渲染为图片(需要安装 puppeteer 插件)。').default(false),
38
- exemptUsers: Schema.array(String).description('例外用户列表,格式为 "群组ID:用户ID"。私聊时群组ID为0。匹配的用户将无视一切过滤器。').default([]),
39
- blockedCommands: Schema.array(String).description('违禁命令列表(命令的开头部分)。').default([]),
40
- restrictDirectory: Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
41
- authority: Schema.number().description('exec 命令所需权限等级。').default(4),
42
- commandFilterMode: Schema.union(['blacklist', 'whitelist']).description('命令过滤模式:blacklist/whitelist').default('blacklist'),
43
- commandList: Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([]),
44
- })
45
-
46
21
  export interface State {
47
22
  command: string
48
23
  timeout: number
@@ -61,378 +36,6 @@ export const inject = {
61
36
  // 当前工作目录状态管理
62
37
  const sessionDirs = new Map<string, string>()
63
38
 
64
- // 命令过滤:支持黑名单/白名单模式
65
- function buildRegex(entry: string): RegExp | null {
66
- try {
67
- return new RegExp(entry, 'i')
68
- } catch (_) {
69
- // 回退为逐字匹配,防止用户写了非法正则
70
- const escaped = entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71
- try {
72
- return new RegExp(escaped, 'i')
73
- } catch (_) {
74
- return null
75
- }
76
- }
77
- }
78
-
79
- function isCommandBlocked(command: string, mode: 'blacklist' | 'whitelist', list: string[]): boolean {
80
- if (!list?.length) return false
81
- const trimmedCommand = command.trim()
82
- const hit = list.some(entry => {
83
- const regex = buildRegex(entry)
84
- return regex ? regex.test(trimmedCommand) : false
85
- })
86
- return mode === 'blacklist' ? hit : !hit
87
- }
88
-
89
- function stripQuotes(text: string): string {
90
- return text.replace(/^['"]|['"]$/g, '')
91
- }
92
-
93
- function tokenizeCommand(command: string): string[] {
94
- const tokens: string[] = []
95
- let current = ''
96
- let quote: string | null = null
97
-
98
- for (let i = 0; i < command.length; i++) {
99
- const char = command[i]
100
-
101
- if ((char === '"' || char === "'") && (quote === null || quote === char)) {
102
- quote = quote ? null : char
103
- continue
104
- }
105
-
106
- if (!quote && /\s/.test(char)) {
107
- if (current) {
108
- tokens.push(current)
109
- current = ''
110
- }
111
- continue
112
- }
113
-
114
- current += char
115
- }
116
-
117
- if (current) tokens.push(current)
118
- return tokens
119
- }
120
-
121
- function isPathLike(token: string): boolean {
122
- const trimmed = token.trim()
123
- if (!trimmed) return false
124
- if (/^[|&><]+$/.test(trimmed)) return false
125
- if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed)) return false
126
- if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return false
127
-
128
- const normalized = stripQuotes(trimmed)
129
-
130
- return (
131
- /^[A-Za-z]:[\\/]/.test(normalized) ||
132
- normalized.startsWith('/') ||
133
- normalized.startsWith('~') ||
134
- normalized.startsWith('..') ||
135
- normalized.startsWith('./') ||
136
- normalized.includes('/') ||
137
- normalized.includes('\\')
138
- )
139
- }
140
-
141
- function resolveCandidatePath(candidate: string, currentDir: string): string {
142
- const cleaned = stripQuotes(candidate.trim())
143
- const homeDir = os.homedir?.() || ''
144
-
145
- if (cleaned.startsWith('~')) {
146
- const withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '')
147
- const homeResolved = homeDir ? path.join(homeDir, withoutTilde) : cleaned
148
- return path.resolve(homeResolved)
149
- }
150
-
151
- return path.resolve(currentDir, cleaned)
152
- }
153
-
154
- function extractPathCandidates(command: string): string[] {
155
- const tokens = tokenizeCommand(command)
156
- const candidates: string[] = []
157
-
158
- for (const token of tokens) {
159
- const normalized = stripQuotes(token)
160
- if (isPathLike(normalized)) {
161
- candidates.push(normalized)
162
- continue
163
- }
164
-
165
- const eqIndex = normalized.indexOf('=')
166
- if (eqIndex > 0) {
167
- const value = normalized.slice(eqIndex + 1)
168
- if (isPathLike(value)) {
169
- candidates.push(value)
170
- }
171
- }
172
- }
173
-
174
- return candidates
175
- }
176
-
177
- // 解析 cd 命令并验证路径
178
- function isWithinRoot(rootDir: string, targetPath: string): boolean {
179
- const relative = path.relative(rootDir, targetPath)
180
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
181
- }
182
-
183
- function validatePathAccess(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; error?: string } {
184
- if (!restrictDirectory) return { valid: true }
185
-
186
- const normalizedRoot = path.resolve(rootDir)
187
- const candidates = extractPathCandidates(command)
188
-
189
- for (const candidate of candidates) {
190
- const resolved = resolveCandidatePath(candidate, currentDir)
191
- if (!isWithinRoot(normalizedRoot, resolved)) {
192
- return { valid: false, error: 'restricted-path' }
193
- }
194
- }
195
-
196
- return { valid: true }
197
- }
198
-
199
- function validateCdCommand(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; newDir?: string; error?: string } {
200
- if (!restrictDirectory) return { valid: true }
201
-
202
- const normalizedRoot = path.resolve(rootDir)
203
- const cdMatches: RegExpExecArray[] = []
204
- const cdRegex = /\bcd\s+([^;&|\n]+)/gi
205
- let m: RegExpExecArray | null
206
- while ((m = cdRegex.exec(command)) !== null) {
207
- cdMatches.push(m)
208
- }
209
-
210
- if (!cdMatches.length) return { valid: true }
211
-
212
- // 若命令被链式运算符分隔且包含 cd,则要求所有 cd 目标都在指定 root 下,否则拒绝
213
- for (const match of cdMatches) {
214
- const target = match[1].trim().replace(/['"]/g, '')
215
- const absolutePath = path.resolve(currentDir, target)
216
- if (!isWithinRoot(normalizedRoot, absolutePath)) {
217
- return { valid: false, error: 'restricted-directory' }
218
- }
219
- }
220
-
221
- // 仅当命令是单独的 cd 时才更新会话目录,避免链式命令切换目录后执行其他操作
222
- const singleCdOnly = /^\s*cd\s+[^;&|\n]+\s*$/i.test(command)
223
- if (singleCdOnly) {
224
- const target = cdMatches[0][1].trim().replace(/['"]/g, '')
225
- const absolutePath = path.resolve(currentDir, target)
226
- return { valid: true, newDir: absolutePath }
227
- }
228
-
229
- return { valid: true }
230
- }
231
-
232
- function maskCurlOutput(command: string, output: string): string {
233
- if (!output) return output
234
- if (!/\bcurl\b/i.test(command)) return output
235
-
236
- const ipv4Regex = /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g
237
- return output.replace(ipv4Regex, (ip) => (isPrivateIpv4(ip) ? ip : '*.*.*.*'))
238
- }
239
-
240
- function isPrivateIpv4(ip: string): boolean {
241
- const octets = ip.split('.').map(Number)
242
- if (octets.length !== 4) return false
243
- if (octets.some(octet => Number.isNaN(octet) || octet < 0 || octet > 255)) return false
244
-
245
- const [a, b] = octets
246
-
247
- if (a === 10) return true
248
- if (a === 172 && b >= 16 && b <= 31) return true
249
- if (a === 192 && b === 168) return true
250
- if (a === 127) return true
251
- if (a === 169 && b === 254) return true
252
-
253
- return false
254
- }
255
-
256
- // 渲染终端输出为图片
257
- async function renderTerminalImage(ctx: Context, workingDir: string, command: string, output: string): Promise<h> {
258
- if (!ctx.puppeteer) {
259
- throw new Error('Puppeteer plugin is not available')
260
- }
261
-
262
- const ansiStrip = (text: string) => text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
263
- const normalizeTabs = (text: string) => text.replace(/\t/g, ' ')
264
- const displayOutputRaw = normalizeTabs(output || '(no output)')
265
- const displayOutput = displayOutputRaw.replace(/^\s+/, '')
266
- const lines = displayOutput.split(/\r?\n/)
267
- const commandLineLength = ansiStrip(`${workingDir}$ ${command}`).length
268
- const visibleLineLengths = lines.map(line => ansiStrip(line).length)
269
- const maxLineLength = Math.max(commandLineLength, ...visibleLineLengths) || commandLineLength
270
- const charWidth = 7.1 // refined average width for JetBrains Mono 13px
271
- const horizontalBuffer = 56 // padding + borders + margin buffer
272
- const containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
273
-
274
- const ansi = new AnsiToHtml({
275
- fg: '#cccccc',
276
- bg: '#1e1e1e',
277
- newline: true,
278
- escapeXML: true,
279
- stream: false,
280
- })
281
- const coloredOutputHtml = ansi.toHtml(displayOutput)
282
-
283
- const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href
284
-
285
- const html = `
286
- <!DOCTYPE html>
287
- <html>
288
- <head>
289
- <meta charset="UTF-8">
290
- <style>
291
- @font-face {
292
- font-family: 'JetBrains Mono';
293
- src: url('${fontPath}') format('truetype');
294
- }
295
-
296
- * {
297
- margin: 0;
298
- padding: 0;
299
- box-sizing: border-box;
300
- }
301
-
302
- body {
303
- background: #1e1e1e;
304
- color: #cccccc;
305
- font-family: 'JetBrains Mono', 'Courier New', monospace;
306
- font-weight: 400;
307
- font-size: 13px;
308
- padding: 0;
309
- display: inline-block;
310
- width: ${containerWidth}px;
311
- max-width: 1600px;
312
- min-width: 600px;
313
- }
314
-
315
- .terminal {
316
- background: #1e1e1e;
317
- border: 1px solid #3c3c3c;
318
- border-radius: 8px;
319
- overflow: hidden;
320
- width: 100%;
321
- }
322
-
323
- .title-bar {
324
- background: #2d2d2d;
325
- height: 35px;
326
- display: flex;
327
- align-items: center;
328
- justify-content: space-between;
329
- padding: 0 12px;
330
- border-bottom: 1px solid #3c3c3c;
331
- }
332
-
333
- .title {
334
- color: #cccccc;
335
- font-size: 13px;
336
- font-weight: 500;
337
- }
338
-
339
- .buttons {
340
- display: flex;
341
- gap: 8px;
342
- }
343
-
344
- .button {
345
- width: 12px;
346
- height: 12px;
347
- border-radius: 50%;
348
- }
349
-
350
- .button.minimize { background: #ffbd2e; }
351
- .button.maximize { background: #28c940; }
352
- .button.close { background: #ff5f56; }
353
-
354
- .content {
355
- padding: 8px 12px;
356
- white-space: pre;
357
- word-break: normal;
358
- line-height: 1.18;
359
- overflow-x: auto;
360
- }
361
-
362
- .command-line {
363
- display: flex;
364
- gap: 3px;
365
- align-items: baseline;
366
- margin-bottom: 2px;
367
- }
368
-
369
- .prompt {
370
- color: #4ec9b0;
371
- margin: 0;
372
- flex-shrink: 0;
373
- }
374
-
375
- .command {
376
- color: #dcdcaa;
377
- margin: 0;
378
- word-break: normal;
379
- flex: 1;
380
- }
381
-
382
- .output {
383
- color: #cccccc;
384
- line-height: 1.12;
385
- white-space: pre;
386
- word-break: normal;
387
- overflow-x: auto;
388
- }
389
- </style>
390
- </head>
391
- <body>
392
- <div class="terminal">
393
- <div class="title-bar">
394
- <div class="title">Terminal</div>
395
- <div class="buttons">
396
- <div class="button minimize"></div>
397
- <div class="button maximize"></div>
398
- <div class="button close"></div>
399
- </div>
400
- </div>
401
- <div class="content">
402
- <div class="command-line">
403
- <div class="prompt">${escapeHtml(workingDir)}$</div>
404
- <div class="command">${escapeHtml(command)}</div>
405
- </div>
406
- <div class="output">${coloredOutputHtml}</div>
407
- </div>
408
- </div>
409
- </body>
410
- </html>
411
- `
412
-
413
- const page = await ctx.puppeteer.page()
414
- try {
415
- await page.setContent(html)
416
- await page.waitForNetworkIdle({ timeout: 5000 })
417
-
418
- const element = await page.$('.terminal')
419
- const screenshot = await element.screenshot({ type: 'png' }) as Buffer
420
-
421
- return h.image(screenshot, 'image/png')
422
- } finally {
423
- await page.close()
424
- }
425
- }
426
-
427
- function escapeHtml(text: string): string {
428
- return text
429
- .replace(/&/g, '&amp;')
430
- .replace(/</g, '&lt;')
431
- .replace(/>/g, '&gt;')
432
- .replace(/"/g, '&quot;')
433
- .replace(/'/g, '&#039;')
434
- }
435
-
436
39
  export function apply(ctx: Context, config: Config) {
437
40
  ctx.i18n.define('zh-CN', require('./locales/zh-CN'))
438
41
 
@@ -450,15 +53,26 @@ export function apply(ctx: Context, config: Config) {
450
53
  const userKey = `${guildId}:${userId}`
451
54
  const isExempt = config.exemptUsers?.some(entry => entry === userKey) ?? false
452
55
 
56
+ const sessionId = session.uid || session.channelId
57
+ const rootDir = path.resolve(ctx.baseDir, config.root)
58
+ const currentDir = sessionDirs.get(sessionId) || rootDir
59
+
60
+ // 输出调试信息
61
+ debugLog(ctx, config, {
62
+ guildId,
63
+ userId,
64
+ command,
65
+ isExempt,
66
+ currentDir,
67
+ })
68
+
453
69
  // 检查命令过滤(黑/白名单);仅使用配置提供的正则
454
70
  const filterList = (config.commandList?.length ? config.commandList : config.blockedCommands) || []
455
71
  const filterMode = config.commandFilterMode || 'blacklist'
456
72
  if (!isExempt && isCommandBlocked(command, filterMode, filterList)) {
457
73
  return session.text('.blocked-command')
458
74
  }
459
- const sessionId = session.uid || session.channelId
460
- const rootDir = path.resolve(ctx.baseDir, config.root)
461
- const currentDir = sessionDirs.get(sessionId) || rootDir
75
+
462
76
  // 验证 cd 命令
463
77
  const cdValidation = validateCdCommand(command, currentDir, rootDir, !isExempt && config.restrictDirectory)
464
78
  if (!cdValidation.valid) {
@@ -468,11 +82,13 @@ export function apply(ctx: Context, config: Config) {
468
82
  if (!pathValidation.valid) {
469
83
  return session.text('.restricted-path')
470
84
  }
85
+
471
86
  const { timeout } = config
472
87
  const state: State = { command, timeout, output: '' }
473
88
  if (!config.renderImage) {
474
89
  await session.send(session.text('.started', state))
475
90
  }
91
+
476
92
  return new Promise((resolve) => {
477
93
  const start = Date.now()
478
94
  const child = exec(command, {
@@ -493,10 +109,20 @@ export function apply(ctx: Context, config: Config) {
493
109
  state.signal = signal
494
110
  state.timeUsed = Date.now() - start
495
111
  state.output = maskCurlOutput(command, state.output.trim())
112
+
113
+ // 输出执行结果调试信息
114
+ debugLogResult(ctx, config, code, state.timeUsed)
115
+
496
116
  // 更新当前目录(如果是 cd 命令且执行成功)
497
117
  if (cdValidation.newDir && code === 0) {
498
118
  sessionDirs.set(sessionId, cdValidation.newDir)
499
119
  }
120
+
121
+ // 例外用户先回复"命令成功执行",再尝试渲染结果
122
+ if (isExempt) {
123
+ await session.send('命令成功执行')
124
+ }
125
+
500
126
  // 渲染为图片或返回文本
501
127
  if (config.renderImage && ctx.puppeteer) {
502
128
  try {
package/src/logger.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Context, Logger } from 'koishi'
2
+ import { Config } from './config'
3
+
4
+ const logger = new Logger('spawn')
5
+
6
+ export interface DebugInfo {
7
+ guildId: string
8
+ userId: string
9
+ command: string
10
+ isExempt: boolean
11
+ currentDir: string
12
+ }
13
+
14
+ export function debugLog(ctx: Context, config: Config, info: DebugInfo) {
15
+ if (!config.debug) return
16
+
17
+ logger.info(`[DEBUG] 群组ID: ${info.guildId}, 用户ID: ${info.userId}`)
18
+ logger.info(`[DEBUG] 命令: ${info.command}`)
19
+ logger.info(`[DEBUG] 例外用户: ${info.isExempt ? '是' : '否'}`)
20
+ logger.info(`[DEBUG] 工作目录: ${info.currentDir}`)
21
+ }
22
+
23
+ export function debugLogResult(ctx: Context, config: Config, code: number | undefined, timeUsed: number) {
24
+ if (!config.debug) return
25
+
26
+ logger.info(`[DEBUG] 执行结果: 退出码=${code}, 耗时=${timeUsed}ms`)
27
+ }
package/src/render.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { Context, h } from 'koishi'
2
+ import path from 'path'
3
+ import { pathToFileURL } from 'url'
4
+ import AnsiToHtml from 'ansi-to-html'
5
+ import { escapeHtml } from './utils'
6
+
7
+ // 渲染终端输出为图片
8
+ export async function renderTerminalImage(ctx: Context, workingDir: string, command: string, output: string): Promise<h> {
9
+ if (!ctx.puppeteer) {
10
+ throw new Error('Puppeteer plugin is not available')
11
+ }
12
+
13
+ const ansiStrip = (text: string) => text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
14
+ const normalizeTabs = (text: string) => text.replace(/\t/g, ' ')
15
+ const displayOutputRaw = normalizeTabs(output || '(no output)')
16
+ const displayOutput = displayOutputRaw.replace(/^\s+/, '')
17
+ const lines = displayOutput.split(/\r?\n/)
18
+ const commandLineLength = ansiStrip(`${workingDir}$ ${command}`).length
19
+ const visibleLineLengths = lines.map(line => ansiStrip(line).length)
20
+ const maxLineLength = Math.max(commandLineLength, ...visibleLineLengths) || commandLineLength
21
+ const charWidth = 7.1 // refined average width for JetBrains Mono 13px
22
+ const horizontalBuffer = 56 // padding + borders + margin buffer
23
+ const containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
24
+
25
+ const ansi = new AnsiToHtml({
26
+ fg: '#cccccc',
27
+ bg: '#1e1e1e',
28
+ newline: true,
29
+ escapeXML: true,
30
+ stream: false,
31
+ })
32
+ const coloredOutputHtml = ansi.toHtml(displayOutput)
33
+
34
+ const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href
35
+
36
+ const html = `
37
+ <!DOCTYPE html>
38
+ <html>
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <style>
42
+ @font-face {
43
+ font-family: 'JetBrains Mono';
44
+ src: url('${fontPath}') format('truetype');
45
+ }
46
+
47
+ * {
48
+ margin: 0;
49
+ padding: 0;
50
+ box-sizing: border-box;
51
+ }
52
+
53
+ body {
54
+ background: #1e1e1e;
55
+ color: #cccccc;
56
+ font-family: 'JetBrains Mono', 'Courier New', monospace;
57
+ font-weight: 400;
58
+ font-size: 13px;
59
+ padding: 0;
60
+ display: inline-block;
61
+ width: ${containerWidth}px;
62
+ max-width: 1600px;
63
+ min-width: 600px;
64
+ }
65
+
66
+ .terminal {
67
+ background: #1e1e1e;
68
+ border: 1px solid #3c3c3c;
69
+ border-radius: 8px;
70
+ overflow: hidden;
71
+ width: 100%;
72
+ }
73
+
74
+ .title-bar {
75
+ background: #2d2d2d;
76
+ height: 35px;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ padding: 0 12px;
81
+ border-bottom: 1px solid #3c3c3c;
82
+ }
83
+
84
+ .title {
85
+ color: #cccccc;
86
+ font-size: 13px;
87
+ font-weight: 500;
88
+ }
89
+
90
+ .buttons {
91
+ display: flex;
92
+ gap: 8px;
93
+ }
94
+
95
+ .button {
96
+ width: 12px;
97
+ height: 12px;
98
+ border-radius: 50%;
99
+ }
100
+
101
+ .button.minimize { background: #ffbd2e; }
102
+ .button.maximize { background: #28c940; }
103
+ .button.close { background: #ff5f56; }
104
+
105
+ .content {
106
+ padding: 8px 12px;
107
+ white-space: pre;
108
+ word-break: normal;
109
+ line-height: 1.18;
110
+ overflow-x: auto;
111
+ }
112
+
113
+ .command-line {
114
+ display: flex;
115
+ gap: 3px;
116
+ align-items: baseline;
117
+ margin-bottom: 2px;
118
+ }
119
+
120
+ .prompt {
121
+ color: #4ec9b0;
122
+ margin: 0;
123
+ flex-shrink: 0;
124
+ }
125
+
126
+ .command {
127
+ color: #dcdcaa;
128
+ margin: 0;
129
+ word-break: normal;
130
+ flex: 1;
131
+ }
132
+
133
+ .output {
134
+ color: #cccccc;
135
+ line-height: 1.12;
136
+ white-space: pre;
137
+ word-break: normal;
138
+ overflow-x: auto;
139
+ }
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <div class="terminal">
144
+ <div class="title-bar">
145
+ <div class="title">Terminal</div>
146
+ <div class="buttons">
147
+ <div class="button minimize"></div>
148
+ <div class="button maximize"></div>
149
+ <div class="button close"></div>
150
+ </div>
151
+ </div>
152
+ <div class="content">
153
+ <div class="command-line">
154
+ <div class="prompt">${escapeHtml(workingDir)}$</div>
155
+ <div class="command">${escapeHtml(command)}</div>
156
+ </div>
157
+ <div class="output">${coloredOutputHtml}</div>
158
+ </div>
159
+ </div>
160
+ </body>
161
+ </html>
162
+ `
163
+
164
+ const page = await ctx.puppeteer.page()
165
+ try {
166
+ await page.setContent(html)
167
+ await page.waitForNetworkIdle({ timeout: 5000 })
168
+
169
+ const element = await page.$('.terminal')
170
+ const screenshot = await element.screenshot({ type: 'png' }) as Buffer
171
+
172
+ return h.image(screenshot, 'image/png')
173
+ } finally {
174
+ await page.close()
175
+ }
176
+ }