koishi-plugin-spawn-modified 1.1.6 → 1.1.8
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/lib/index.js +241 -0
- package/package.json +1 -1
package/lib/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { h, Schema, Time } from 'koishi';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
const encodings = ['utf8', 'utf16le', 'latin1', 'ucs2'];
|
|
6
|
+
export const Config = Schema.object({
|
|
7
|
+
root: Schema.string().description('工作路径。').default(''),
|
|
8
|
+
shell: Schema.string().description('运行命令的程序。'),
|
|
9
|
+
encoding: Schema.union(encodings).description('输出内容编码。').default('utf8'),
|
|
10
|
+
timeout: Schema.number().description('最长运行时间。').default(Time.minute),
|
|
11
|
+
renderImage: Schema.boolean().description('是否将命令执行结果渲染为图片(需要安装 puppeteer 插件)。').default(false),
|
|
12
|
+
blockedCommands: Schema.array(String).description('违禁命令列表(命令的开头部分)。').default([]),
|
|
13
|
+
restrictDirectory: Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
|
|
14
|
+
});
|
|
15
|
+
export const name = 'spawn';
|
|
16
|
+
export const inject = {
|
|
17
|
+
optional: ['puppeteer'],
|
|
18
|
+
};
|
|
19
|
+
// 当前工作目录状态管理
|
|
20
|
+
const sessionDirs = new Map();
|
|
21
|
+
// 验证命令是否被禁止
|
|
22
|
+
function isCommandBlocked(command, blockedCommands) {
|
|
23
|
+
const trimmedCommand = command.trim().toLowerCase();
|
|
24
|
+
return blockedCommands.some(blocked => trimmedCommand.startsWith(blocked.toLowerCase()));
|
|
25
|
+
}
|
|
26
|
+
// 解析 cd 命令并验证路径
|
|
27
|
+
function validateCdCommand(command, currentDir, rootDir, restrictDirectory) {
|
|
28
|
+
const cdMatch = command.trim().match(/^cd\s+(.+)$/i);
|
|
29
|
+
if (!cdMatch)
|
|
30
|
+
return { valid: true };
|
|
31
|
+
if (!restrictDirectory)
|
|
32
|
+
return { valid: true };
|
|
33
|
+
const targetPath = cdMatch[1].trim().replace(/['"]/g, '');
|
|
34
|
+
const absolutePath = path.resolve(currentDir, targetPath);
|
|
35
|
+
const normalizedRoot = path.resolve(rootDir);
|
|
36
|
+
// 检查目标路径是否在根目录内
|
|
37
|
+
if (!absolutePath.startsWith(normalizedRoot)) {
|
|
38
|
+
return { valid: false, error: 'restricted-directory' };
|
|
39
|
+
}
|
|
40
|
+
return { valid: true, newDir: absolutePath };
|
|
41
|
+
}
|
|
42
|
+
// 渲染终端输出为图片
|
|
43
|
+
async function renderTerminalImage(ctx, workingDir, command, output) {
|
|
44
|
+
if (!ctx.puppeteer) {
|
|
45
|
+
throw new Error('Puppeteer plugin is not available');
|
|
46
|
+
}
|
|
47
|
+
const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href;
|
|
48
|
+
const html = `
|
|
49
|
+
<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="UTF-8">
|
|
53
|
+
<style>
|
|
54
|
+
@font-face {
|
|
55
|
+
font-family: 'JetBrains Mono';
|
|
56
|
+
src: url('${fontPath}') format('truetype');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
* {
|
|
60
|
+
margin: 0;
|
|
61
|
+
padding: 0;
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
body {
|
|
66
|
+
background: #1e1e1e;
|
|
67
|
+
color: #cccccc;
|
|
68
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
69
|
+
font-size: 14px;
|
|
70
|
+
padding: 0;
|
|
71
|
+
display: inline-block;
|
|
72
|
+
min-width: 600px;
|
|
73
|
+
max-width: 1200px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.terminal {
|
|
77
|
+
background: #1e1e1e;
|
|
78
|
+
border: 1px solid #3c3c3c;
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.title-bar {
|
|
84
|
+
background: #2d2d2d;
|
|
85
|
+
height: 35px;
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: space-between;
|
|
89
|
+
padding: 0 12px;
|
|
90
|
+
border-bottom: 1px solid #3c3c3c;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.title {
|
|
94
|
+
color: #cccccc;
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
font-weight: 500;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.buttons {
|
|
100
|
+
display: flex;
|
|
101
|
+
gap: 8px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.button {
|
|
105
|
+
width: 12px;
|
|
106
|
+
height: 12px;
|
|
107
|
+
border-radius: 50%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.button.minimize { background: #ffbd2e; }
|
|
111
|
+
.button.maximize { background: #28c940; }
|
|
112
|
+
.button.close { background: #ff5f56; }
|
|
113
|
+
|
|
114
|
+
.content {
|
|
115
|
+
padding: 16px;
|
|
116
|
+
white-space: pre-wrap;
|
|
117
|
+
word-break: break-all;
|
|
118
|
+
line-height: 1.5;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.prompt {
|
|
122
|
+
color: #4ec9b0;
|
|
123
|
+
margin-bottom: 8px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.command {
|
|
127
|
+
color: #dcdcaa;
|
|
128
|
+
margin-bottom: 12px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.output {
|
|
132
|
+
color: #cccccc;
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
135
|
+
</head>
|
|
136
|
+
<body>
|
|
137
|
+
<div class="terminal">
|
|
138
|
+
<div class="title-bar">
|
|
139
|
+
<div class="title">Terminal</div>
|
|
140
|
+
<div class="buttons">
|
|
141
|
+
<div class="button minimize"></div>
|
|
142
|
+
<div class="button maximize"></div>
|
|
143
|
+
<div class="button close"></div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="content">
|
|
147
|
+
<div class="prompt">${escapeHtml(workingDir)}$</div>
|
|
148
|
+
<div class="command">${escapeHtml(command)}</div>
|
|
149
|
+
<div class="output">${escapeHtml(output)}</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
`;
|
|
155
|
+
const page = await ctx.puppeteer.page();
|
|
156
|
+
try {
|
|
157
|
+
await page.setContent(html);
|
|
158
|
+
await page.waitForNetworkIdle({ timeout: 5000 });
|
|
159
|
+
const element = await page.$('.terminal');
|
|
160
|
+
const clip = await element.boundingBox();
|
|
161
|
+
const screenshot = await page.screenshot({ clip });
|
|
162
|
+
return h.image(screenshot, 'image/png');
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await page.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function escapeHtml(text) {
|
|
169
|
+
return text
|
|
170
|
+
.replace(/&/g, '&')
|
|
171
|
+
.replace(/</g, '<')
|
|
172
|
+
.replace(/>/g, '>')
|
|
173
|
+
.replace(/"/g, '"')
|
|
174
|
+
.replace(/'/g, ''');
|
|
175
|
+
}
|
|
176
|
+
export function apply(ctx, config) {
|
|
177
|
+
ctx.i18n.define('zh-CN', require('./locales/zh-CN'));
|
|
178
|
+
ctx.command('exec <command:text>', { authority: 4 })
|
|
179
|
+
.action(async ({ session }, command) => {
|
|
180
|
+
if (!command)
|
|
181
|
+
return session.text('.expect-text');
|
|
182
|
+
command = h('', h.parse(command)).toString(true);
|
|
183
|
+
// 检查违禁命令
|
|
184
|
+
if (isCommandBlocked(command, config.blockedCommands)) {
|
|
185
|
+
return session.text('.blocked-command');
|
|
186
|
+
}
|
|
187
|
+
const sessionId = session.uid || session.channelId;
|
|
188
|
+
const rootDir = path.resolve(ctx.baseDir, config.root);
|
|
189
|
+
const currentDir = sessionDirs.get(sessionId) || rootDir;
|
|
190
|
+
// 验证 cd 命令
|
|
191
|
+
const cdValidation = validateCdCommand(command, currentDir, rootDir, config.restrictDirectory);
|
|
192
|
+
if (!cdValidation.valid) {
|
|
193
|
+
return session.text('.restricted-directory');
|
|
194
|
+
}
|
|
195
|
+
const { timeout } = config;
|
|
196
|
+
const state = { command, timeout, output: '' };
|
|
197
|
+
if (!config.renderImage) {
|
|
198
|
+
await session.send(session.text('.started', state));
|
|
199
|
+
}
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const start = Date.now();
|
|
202
|
+
const child = exec(command, {
|
|
203
|
+
timeout,
|
|
204
|
+
cwd: currentDir,
|
|
205
|
+
encoding: config.encoding,
|
|
206
|
+
shell: config.shell,
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
});
|
|
209
|
+
child.stdout.on('data', (data) => {
|
|
210
|
+
state.output += data.toString();
|
|
211
|
+
});
|
|
212
|
+
child.stderr.on('data', (data) => {
|
|
213
|
+
state.output += data.toString();
|
|
214
|
+
});
|
|
215
|
+
child.on('close', async (code, signal) => {
|
|
216
|
+
state.code = code;
|
|
217
|
+
state.signal = signal;
|
|
218
|
+
state.timeUsed = Date.now() - start;
|
|
219
|
+
state.output = state.output.trim();
|
|
220
|
+
// 更新当前目录(如果是 cd 命令且执行成功)
|
|
221
|
+
if (cdValidation.newDir && code === 0) {
|
|
222
|
+
sessionDirs.set(sessionId, cdValidation.newDir);
|
|
223
|
+
}
|
|
224
|
+
// 渲染为图片或返回文本
|
|
225
|
+
if (config.renderImage && ctx.puppeteer) {
|
|
226
|
+
try {
|
|
227
|
+
const image = await renderTerminalImage(ctx, currentDir, command, state.output || '(no output)');
|
|
228
|
+
resolve(image);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
ctx.logger.error('Failed to render terminal image:', error);
|
|
232
|
+
resolve(session.text('.finished', state));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
resolve(session.text('.finished', state));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|