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