koishi-plugin-spawn-modified 1.2.0 → 1.2.2

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 (3) hide show
  1. package/lib/index.js +21 -23
  2. package/package.json +4 -1
  3. package/src/index.ts +28 -27
package/lib/index.js CHANGED
@@ -54,7 +54,7 @@ var child_process_1 = require("child_process");
54
54
  var koishi_1 = require("koishi");
55
55
  var path_1 = __importDefault(require("path"));
56
56
  var url_1 = require("url");
57
- var debug_log_1 = require("./debug-log");
57
+ var ansi_to_html_1 = __importDefault(require("ansi-to-html"));
58
58
  var encodings = ['utf8', 'utf16le', 'latin1', 'ucs2'];
59
59
  exports.Config = koishi_1.Schema.object({
60
60
  root: koishi_1.Schema.string().description('工作路径。').default(''),
@@ -101,24 +101,36 @@ function validateCdCommand(command, currentDir, rootDir, restrictDirectory) {
101
101
  // 渲染终端输出为图片
102
102
  function renderTerminalImage(ctx, workingDir, command, output) {
103
103
  return __awaiter(this, void 0, void 0, function () {
104
- var displayOutput, lines, commandLineLength, maxLineLength, charWidth, horizontalBuffer, containerWidth, fontPath, html, page, element, screenshot;
104
+ var ansiStrip, normalizeTabs, displayOutputRaw, displayOutput, lines, commandLineLength, visibleLineLengths, maxLineLength, charWidth, horizontalBuffer, containerWidth, ansi, coloredOutputHtml, fontPath, html, page, element, screenshot;
105
105
  return __generator(this, function (_a) {
106
106
  switch (_a.label) {
107
107
  case 0:
108
108
  if (!ctx.puppeteer) {
109
109
  throw new Error('Puppeteer plugin is not available');
110
110
  }
111
- displayOutput = output || '(no output)';
111
+ ansiStrip = function (text) { return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); };
112
+ normalizeTabs = function (text) { return text.replace(/\t/g, ' '); };
113
+ displayOutputRaw = normalizeTabs(output || '(no output)');
114
+ displayOutput = displayOutputRaw.replace(/^\s+/, '');
112
115
  lines = displayOutput.split(/\r?\n/);
113
- commandLineLength = "".concat(workingDir, "$ ").concat(command).length;
114
- maxLineLength = Math.max.apply(Math, __spreadArray([commandLineLength], lines.map(function (line) { return line.length; }), false)) || commandLineLength;
115
- charWidth = 7.4 // approximate width of JetBrains Mono at 13px
116
+ commandLineLength = ansiStrip("".concat(workingDir, "$ ").concat(command)).length;
117
+ visibleLineLengths = lines.map(function (line) { return ansiStrip(line).length; });
118
+ maxLineLength = Math.max.apply(Math, __spreadArray([commandLineLength], visibleLineLengths, false)) || commandLineLength;
119
+ charWidth = 7.1 // refined average width for JetBrains Mono 13px
116
120
  ;
117
- horizontalBuffer = 48 // padding + borders + margin buffer
121
+ horizontalBuffer = 56 // padding + borders + margin buffer
118
122
  ;
119
- containerWidth = Math.max(600, Math.min(1400, Math.ceil(maxLineLength * charWidth + horizontalBuffer)));
123
+ containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)));
124
+ ansi = new ansi_to_html_1.default({
125
+ fg: '#cccccc',
126
+ bg: '#1e1e1e',
127
+ newline: true,
128
+ escapeXML: true,
129
+ stream: false,
130
+ });
131
+ coloredOutputHtml = ansi.toHtml(displayOutput);
120
132
  fontPath = (0, url_1.pathToFileURL)(path_1.default.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href;
121
- html = "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <style>\n @font-face {\n font-family: 'JetBrains Mono';\n src: url('".concat(fontPath, "') format('truetype');\n }\n \n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n \n body {\n background: #1e1e1e;\n color: #cccccc;\n font-family: 'JetBrains Mono', 'Courier New', monospace;\n font-weight: 400;\n font-size: 13px;\n padding: 0;\n display: inline-block;\n width: ").concat(containerWidth, "px;\n }\n \n .terminal {\n background: #1e1e1e;\n border: 1px solid #3c3c3c;\n border-radius: 8px;\n overflow: hidden;\n width: 100%;\n }\n \n .title-bar {\n background: #2d2d2d;\n height: 35px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 12px;\n border-bottom: 1px solid #3c3c3c;\n }\n \n .title {\n color: #cccccc;\n font-size: 13px;\n font-weight: 500;\n }\n \n .buttons {\n display: flex;\n gap: 8px;\n }\n \n .button {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n }\n \n .button.minimize { background: #ffbd2e; }\n .button.maximize { background: #28c940; }\n .button.close { background: #ff5f56; }\n \n .content {\n padding: 10px 14px;\n white-space: pre;\n word-break: normal;\n line-height: 1.25;\n overflow-x: auto;\n }\n \n .command-line {\n display: flex;\n gap: 4px;\n align-items: baseline;\n margin-bottom: 6px;\n }\n \n .prompt {\n color: #4ec9b0;\n margin: 0;\n flex-shrink: 0;\n }\n \n .command {\n color: #dcdcaa;\n margin: 0;\n word-break: normal;\n flex: 1;\n }\n \n .output {\n color: #cccccc;\n line-height: 1.22;\n white-space: pre;\n word-break: normal;\n overflow-x: auto;\n }\n </style>\n</head>\n<body>\n <div class=\"terminal\">\n <div class=\"title-bar\">\n <div class=\"title\">Terminal</div>\n <div class=\"buttons\">\n <div class=\"button minimize\"></div>\n <div class=\"button maximize\"></div>\n <div class=\"button close\"></div>\n </div>\n </div>\n <div class=\"content\">\n <div class=\"command-line\">\n <div class=\"prompt\">").concat(escapeHtml(workingDir), "$</div>\n <div class=\"command\">").concat(escapeHtml(command), "</div>\n </div>\n <div class=\"output\">").concat(escapeHtml(displayOutput), "</div>\n </div>\n </div>\n</body>\n</html>\n ");
133
+ html = "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <style>\n @font-face {\n font-family: 'JetBrains Mono';\n src: url('".concat(fontPath, "') format('truetype');\n }\n \n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n \n body {\n background: #1e1e1e;\n color: #cccccc;\n font-family: 'JetBrains Mono', 'Courier New', monospace;\n font-weight: 400;\n font-size: 13px;\n padding: 0;\n display: inline-block;\n width: ").concat(containerWidth, "px;\n max-width: 1600px;\n min-width: 600px;\n }\n \n .terminal {\n background: #1e1e1e;\n border: 1px solid #3c3c3c;\n border-radius: 8px;\n overflow: hidden;\n width: 100%;\n }\n \n .title-bar {\n background: #2d2d2d;\n height: 35px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 12px;\n border-bottom: 1px solid #3c3c3c;\n }\n \n .title {\n color: #cccccc;\n font-size: 13px;\n font-weight: 500;\n }\n \n .buttons {\n display: flex;\n gap: 8px;\n }\n \n .button {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n }\n \n .button.minimize { background: #ffbd2e; }\n .button.maximize { background: #28c940; }\n .button.close { background: #ff5f56; }\n \n .content {\n padding: 8px 12px;\n white-space: pre;\n word-break: normal;\n line-height: 1.18;\n overflow-x: auto;\n }\n \n .command-line {\n display: flex;\n gap: 3px;\n align-items: baseline;\n margin-bottom: 2px;\n }\n \n .prompt {\n color: #4ec9b0;\n margin: 0;\n flex-shrink: 0;\n }\n \n .command {\n color: #dcdcaa;\n margin: 0;\n word-break: normal;\n flex: 1;\n }\n \n .output {\n color: #cccccc;\n line-height: 1.12;\n white-space: pre;\n word-break: normal;\n overflow-x: auto;\n }\n </style>\n</head>\n<body>\n <div class=\"terminal\">\n <div class=\"title-bar\">\n <div class=\"title\">Terminal</div>\n <div class=\"buttons\">\n <div class=\"button minimize\"></div>\n <div class=\"button maximize\"></div>\n <div class=\"button close\"></div>\n </div>\n </div>\n <div class=\"content\">\n <div class=\"command-line\">\n <div class=\"prompt\">").concat(escapeHtml(workingDir), "$</div>\n <div class=\"command\">").concat(escapeHtml(command), "</div>\n </div>\n <div class=\"output\">").concat(coloredOutputHtml, "</div>\n </div>\n </div>\n</body>\n</html>\n ");
122
134
  return [4 /*yield*/, ctx.puppeteer.page()];
123
135
  case 1:
124
136
  page = _a.sent();
@@ -168,32 +180,25 @@ function apply(ctx, config) {
168
180
  return __generator(this, function (_d) {
169
181
  switch (_d.label) {
170
182
  case 0:
171
- (0, debug_log_1.debugLog)(ctx, 'input', { command: command });
172
183
  if (!command) {
173
- (0, debug_log_1.debugLog)(ctx, 'expect-text', { text: session.text('.expect-text') });
174
184
  return [2 /*return*/, session.text('.expect-text')];
175
185
  }
176
186
  command = (0, koishi_1.h)('', koishi_1.h.parse(command)).toString(true);
177
- (0, debug_log_1.debugLog)(ctx, 'parsed-command', { command: command });
178
187
  filterList = (((_c = config.commandList) === null || _c === void 0 ? void 0 : _c.length) ? config.commandList : config.blockedCommands) || [];
179
188
  filterMode = config.commandFilterMode || 'blacklist';
180
189
  if (isCommandBlocked(command, filterMode, filterList)) {
181
- (0, debug_log_1.debugLog)(ctx, 'blocked-command', { command: command, filterMode: filterMode, filterList: filterList });
182
190
  return [2 /*return*/, session.text('.blocked-command')];
183
191
  }
184
192
  sessionId = session.uid || session.channelId;
185
193
  rootDir = path_1.default.resolve(ctx.baseDir, config.root);
186
194
  currentDir = sessionDirs.get(sessionId) || rootDir;
187
195
  cdValidation = validateCdCommand(command, currentDir, rootDir, config.restrictDirectory);
188
- (0, debug_log_1.debugLog)(ctx, 'cd-validation', { command: command, cdValidation: cdValidation });
189
196
  if (!cdValidation.valid) {
190
- (0, debug_log_1.debugLog)(ctx, 'restricted-directory', { text: session.text('.restricted-directory') });
191
197
  return [2 /*return*/, session.text('.restricted-directory')];
192
198
  }
193
199
  timeout = config.timeout;
194
200
  state = { command: command, timeout: timeout, output: '' };
195
201
  if (!!config.renderImage) return [3 /*break*/, 2];
196
- (0, debug_log_1.debugLog)(ctx, 'send-started', { text: session.text('.started', state) });
197
202
  return [4 /*yield*/, session.send(session.text('.started', state))];
198
203
  case 1:
199
204
  _d.sent();
@@ -208,11 +213,9 @@ function apply(ctx, config) {
208
213
  windowsHide: true,
209
214
  });
210
215
  child.stdout.on('data', function (data) {
211
- (0, debug_log_1.debugLog)(ctx, 'stdout', { data: data.toString() });
212
216
  state.output += data.toString();
213
217
  });
214
218
  child.stderr.on('data', function (data) {
215
- (0, debug_log_1.debugLog)(ctx, 'stderr', { data: data.toString() });
216
219
  state.output += data.toString();
217
220
  });
218
221
  child.on('close', function (code, signal) { return __awaiter(_this, void 0, void 0, function () {
@@ -224,11 +227,9 @@ function apply(ctx, config) {
224
227
  state.signal = signal;
225
228
  state.timeUsed = Date.now() - start;
226
229
  state.output = state.output.trim();
227
- (0, debug_log_1.debugLog)(ctx, 'close', { code: code, signal: signal, timeUsed: state.timeUsed, output: state.output });
228
230
  // 更新当前目录(如果是 cd 命令且执行成功)
229
231
  if (cdValidation.newDir && code === 0) {
230
232
  sessionDirs.set(sessionId, cdValidation.newDir);
231
- (0, debug_log_1.debugLog)(ctx, 'cd-updated', { sessionId: sessionId, newDir: cdValidation.newDir });
232
233
  }
233
234
  if (!(config.renderImage && ctx.puppeteer)) return [3 /*break*/, 5];
234
235
  _a.label = 1;
@@ -237,18 +238,15 @@ function apply(ctx, config) {
237
238
  return [4 /*yield*/, renderTerminalImage(ctx, currentDir, command, state.output || '(no output)')];
238
239
  case 2:
239
240
  image = _a.sent();
240
- (0, debug_log_1.debugLog)(ctx, 'render-image-success');
241
241
  resolve(image);
242
242
  return [3 /*break*/, 4];
243
243
  case 3:
244
244
  error_1 = _a.sent();
245
245
  ctx.logger.error('Failed to render terminal image:', error_1);
246
- (0, debug_log_1.debugLog)(ctx, 'render-image-fail', { error: error_1 });
247
246
  resolve(session.text('.finished', state));
248
247
  return [3 /*break*/, 4];
249
248
  case 4: return [3 /*break*/, 6];
250
249
  case 5:
251
- (0, debug_log_1.debugLog)(ctx, 'send-finished', { text: session.text('.finished', state) });
252
250
  resolve(session.text('.finished', state));
253
251
  _a.label = 6;
254
252
  case 6: return [2 /*return*/];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-spawn-modified",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Run shell commands with Koishi",
5
5
  "keywords": [
6
6
  "bot",
@@ -83,5 +83,8 @@
83
83
  "puppeteer"
84
84
  ]
85
85
  }
86
+ },
87
+ "dependencies": {
88
+ "ansi-to-html": "^0.7.2"
86
89
  }
87
90
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { exec } from 'child_process'
2
2
  import { Context, h, Schema, Time } from 'koishi'
3
3
  import path from 'path'
4
4
  import { pathToFileURL } from 'url'
5
- import { debugLog } from './debug-log'
5
+ import AnsiToHtml from 'ansi-to-html'
6
6
 
7
7
  declare module 'koishi' {
8
8
  interface Context {
@@ -91,13 +91,26 @@ async function renderTerminalImage(ctx: Context, workingDir: string, command: st
91
91
  throw new Error('Puppeteer plugin is not available')
92
92
  }
93
93
 
94
- const displayOutput = output || '(no output)'
94
+ const ansiStrip = (text: string) => text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
95
+ const normalizeTabs = (text: string) => text.replace(/\t/g, ' ')
96
+ const displayOutputRaw = normalizeTabs(output || '(no output)')
97
+ const displayOutput = displayOutputRaw.replace(/^\s+/, '')
95
98
  const lines = displayOutput.split(/\r?\n/)
96
- const commandLineLength = `${workingDir}$ ${command}`.length
97
- const maxLineLength = Math.max(commandLineLength, ...lines.map(line => line.length)) || commandLineLength
98
- const charWidth = 7.4 // approximate width of JetBrains Mono at 13px
99
- const horizontalBuffer = 48 // padding + borders + margin buffer
100
- const containerWidth = Math.max(600, Math.min(1400, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
99
+ const commandLineLength = ansiStrip(`${workingDir}$ ${command}`).length
100
+ const visibleLineLengths = lines.map(line => ansiStrip(line).length)
101
+ const maxLineLength = Math.max(commandLineLength, ...visibleLineLengths) || commandLineLength
102
+ const charWidth = 7.1 // refined average width for JetBrains Mono 13px
103
+ const horizontalBuffer = 56 // padding + borders + margin buffer
104
+ const containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
105
+
106
+ const ansi = new AnsiToHtml({
107
+ fg: '#cccccc',
108
+ bg: '#1e1e1e',
109
+ newline: true,
110
+ escapeXML: true,
111
+ stream: false,
112
+ })
113
+ const coloredOutputHtml = ansi.toHtml(displayOutput)
101
114
 
102
115
  const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href
103
116
 
@@ -127,6 +140,8 @@ async function renderTerminalImage(ctx: Context, workingDir: string, command: st
127
140
  padding: 0;
128
141
  display: inline-block;
129
142
  width: ${containerWidth}px;
143
+ max-width: 1600px;
144
+ min-width: 600px;
130
145
  }
131
146
 
132
147
  .terminal {
@@ -169,18 +184,18 @@ async function renderTerminalImage(ctx: Context, workingDir: string, command: st
169
184
  .button.close { background: #ff5f56; }
170
185
 
171
186
  .content {
172
- padding: 10px 14px;
187
+ padding: 8px 12px;
173
188
  white-space: pre;
174
189
  word-break: normal;
175
- line-height: 1.25;
190
+ line-height: 1.18;
176
191
  overflow-x: auto;
177
192
  }
178
193
 
179
194
  .command-line {
180
195
  display: flex;
181
- gap: 4px;
196
+ gap: 3px;
182
197
  align-items: baseline;
183
- margin-bottom: 6px;
198
+ margin-bottom: 2px;
184
199
  }
185
200
 
186
201
  .prompt {
@@ -198,7 +213,7 @@ async function renderTerminalImage(ctx: Context, workingDir: string, command: st
198
213
 
199
214
  .output {
200
215
  color: #cccccc;
201
- line-height: 1.22;
216
+ line-height: 1.12;
202
217
  white-space: pre;
203
218
  word-break: normal;
204
219
  overflow-x: auto;
@@ -220,7 +235,7 @@ async function renderTerminalImage(ctx: Context, workingDir: string, command: st
220
235
  <div class="prompt">${escapeHtml(workingDir)}$</div>
221
236
  <div class="command">${escapeHtml(command)}</div>
222
237
  </div>
223
- <div class="output">${escapeHtml(displayOutput)}</div>
238
+ <div class="output">${coloredOutputHtml}</div>
224
239
  </div>
225
240
  </div>
226
241
  </body>
@@ -255,19 +270,15 @@ export function apply(ctx: Context, config: Config) {
255
270
 
256
271
  ctx.command('exec <command:text>', { authority: config.authority ?? 4 })
257
272
  .action(async ({ session }, command) => {
258
- debugLog(ctx, 'input', { command })
259
273
  if (!command) {
260
- debugLog(ctx, 'expect-text', { text: session.text('.expect-text') })
261
274
  return session.text('.expect-text')
262
275
  }
263
276
 
264
277
  command = h('', h.parse(command)).toString(true)
265
- debugLog(ctx, 'parsed-command', { command })
266
278
  // 检查命令过滤(黑/白名单)
267
279
  const filterList = (config.commandList?.length ? config.commandList : config.blockedCommands) || []
268
280
  const filterMode = config.commandFilterMode || 'blacklist'
269
281
  if (isCommandBlocked(command, filterMode, filterList)) {
270
- debugLog(ctx, 'blocked-command', { command, filterMode, filterList })
271
282
  return session.text('.blocked-command')
272
283
  }
273
284
  const sessionId = session.uid || session.channelId
@@ -275,15 +286,12 @@ export function apply(ctx: Context, config: Config) {
275
286
  const currentDir = sessionDirs.get(sessionId) || rootDir
276
287
  // 验证 cd 命令
277
288
  const cdValidation = validateCdCommand(command, currentDir, rootDir, config.restrictDirectory)
278
- debugLog(ctx, 'cd-validation', { command, cdValidation })
279
289
  if (!cdValidation.valid) {
280
- debugLog(ctx, 'restricted-directory', { text: session.text('.restricted-directory') })
281
290
  return session.text('.restricted-directory')
282
291
  }
283
292
  const { timeout } = config
284
293
  const state: State = { command, timeout, output: '' }
285
294
  if (!config.renderImage) {
286
- debugLog(ctx, 'send-started', { text: session.text('.started', state) })
287
295
  await session.send(session.text('.started', state))
288
296
  }
289
297
  return new Promise((resolve) => {
@@ -296,11 +304,9 @@ export function apply(ctx: Context, config: Config) {
296
304
  windowsHide: true,
297
305
  })
298
306
  child.stdout.on('data', (data) => {
299
- debugLog(ctx, 'stdout', { data: data.toString() })
300
307
  state.output += data.toString()
301
308
  })
302
309
  child.stderr.on('data', (data) => {
303
- debugLog(ctx, 'stderr', { data: data.toString() })
304
310
  state.output += data.toString()
305
311
  })
306
312
  child.on('close', async (code, signal) => {
@@ -308,25 +314,20 @@ export function apply(ctx: Context, config: Config) {
308
314
  state.signal = signal
309
315
  state.timeUsed = Date.now() - start
310
316
  state.output = state.output.trim()
311
- debugLog(ctx, 'close', { code, signal, timeUsed: state.timeUsed, output: state.output })
312
317
  // 更新当前目录(如果是 cd 命令且执行成功)
313
318
  if (cdValidation.newDir && code === 0) {
314
319
  sessionDirs.set(sessionId, cdValidation.newDir)
315
- debugLog(ctx, 'cd-updated', { sessionId, newDir: cdValidation.newDir })
316
320
  }
317
321
  // 渲染为图片或返回文本
318
322
  if (config.renderImage && ctx.puppeteer) {
319
323
  try {
320
324
  const image = await renderTerminalImage(ctx, currentDir, command, state.output || '(no output)')
321
- debugLog(ctx, 'render-image-success')
322
325
  resolve(image)
323
326
  } catch (error) {
324
327
  ctx.logger.error('Failed to render terminal image:', error)
325
- debugLog(ctx, 'render-image-fail', { error })
326
328
  resolve(session.text('.finished', state))
327
329
  }
328
330
  } else {
329
- debugLog(ctx, 'send-finished', { text: session.text('.finished', state) })
330
331
  resolve(session.text('.finished', state))
331
332
  }
332
333
  })