local-cmd-runner 1.0.3 → 1.0.4
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/package.json +3 -3
- package/public/app.js +177 -3
- package/public/index.html +68 -25
- package/public/style.css +230 -4
- package/server.js +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-cmd-runner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Run local shell commands from a web page",
|
|
5
5
|
"homepage": "https://github.com/codewoow/local_cmd#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"express": "^4.19.2",
|
|
25
|
+
"multer": "^2.1.1",
|
|
25
26
|
"socket.io": "^4.7.5"
|
|
26
|
-
}
|
|
27
|
-
"devDependencies": {}
|
|
27
|
+
}
|
|
28
28
|
}
|
package/public/app.js
CHANGED
|
@@ -45,10 +45,51 @@ cmdInput.addEventListener('keypress', (e) => {
|
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
const customModal = document.getElementById('customModal');
|
|
49
|
+
const modalInput = document.getElementById('modalInput');
|
|
50
|
+
const modalCancel = document.getElementById('modalCancel');
|
|
51
|
+
const modalConfirm = document.getElementById('modalConfirm');
|
|
52
|
+
const modalError = document.getElementById('modalError');
|
|
53
|
+
|
|
54
|
+
function showModal() {
|
|
55
|
+
if (!customModal) return;
|
|
56
|
+
customModal.classList.remove('hidden');
|
|
57
|
+
modalInput.value = '';
|
|
58
|
+
modalError.classList.add('hidden');
|
|
59
|
+
modalInput.focus();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hideModal() {
|
|
63
|
+
if (customModal) customModal.classList.add('hidden');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleModalSubmit() {
|
|
67
|
+
const name = modalInput.value.trim();
|
|
68
|
+
if (/^[A-Za-z]+$/.test(name)) {
|
|
69
|
+
runCommand(`myclaw new ${name}`);
|
|
70
|
+
hideModal();
|
|
71
|
+
} else {
|
|
72
|
+
modalError.classList.remove('hidden');
|
|
73
|
+
modalInput.focus();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (customModal) {
|
|
78
|
+
modalCancel.addEventListener('click', hideModal);
|
|
79
|
+
modalConfirm.addEventListener('click', handleModalSubmit);
|
|
80
|
+
modalInput.addEventListener('keypress', (e) => {
|
|
81
|
+
if (e.key === 'Enter') handleModalSubmit();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
presetBtns.forEach(btn => {
|
|
49
86
|
btn.addEventListener('click', () => {
|
|
50
87
|
const cmd = btn.getAttribute('data-cmd');
|
|
51
|
-
|
|
88
|
+
if (cmd === 'myclaw new') {
|
|
89
|
+
showModal();
|
|
90
|
+
} else if (cmd) {
|
|
91
|
+
runCommand(cmd);
|
|
92
|
+
}
|
|
52
93
|
});
|
|
53
94
|
});
|
|
54
95
|
|
|
@@ -86,7 +127,7 @@ socket.on('cmd_output', (data) => {
|
|
|
86
127
|
socket.on('cmd_error', (data) => {
|
|
87
128
|
const container = getOutputContainer(data.runId || data.id);
|
|
88
129
|
const div = document.createElement('div');
|
|
89
|
-
div.textContent = `\n[
|
|
130
|
+
div.textContent = `\n[错误]: ${data.error}\n`;
|
|
90
131
|
div.classList.add('error');
|
|
91
132
|
container.appendChild(div);
|
|
92
133
|
});
|
|
@@ -94,7 +135,140 @@ socket.on('cmd_error', (data) => {
|
|
|
94
135
|
socket.on('cmd_close', (data) => {
|
|
95
136
|
const container = getOutputContainer(data.runId || data.id);
|
|
96
137
|
const div = document.createElement('div');
|
|
97
|
-
div.textContent = `\n[
|
|
138
|
+
div.textContent = `\n[进程已退出,退出码 ${data.code}]\n\n`;
|
|
98
139
|
div.classList.add('system');
|
|
99
140
|
container.appendChild(div);
|
|
100
141
|
});
|
|
142
|
+
|
|
143
|
+
// --- File Manager Logic ---
|
|
144
|
+
const fmTree = document.getElementById('fmTree');
|
|
145
|
+
const fmCurrentPath = document.getElementById('fmCurrentPath');
|
|
146
|
+
const fmUploadBtn = document.getElementById('fmUploadBtn');
|
|
147
|
+
const fmUploadInput = document.getElementById('fmUploadInput');
|
|
148
|
+
const fmRefreshBtn = document.getElementById('fmRefreshBtn');
|
|
149
|
+
|
|
150
|
+
const previewModal = document.getElementById('previewModal');
|
|
151
|
+
const previewTitle = document.getElementById('previewTitle');
|
|
152
|
+
const previewBody = document.getElementById('previewBody');
|
|
153
|
+
const previewClose = document.getElementById('previewClose');
|
|
154
|
+
|
|
155
|
+
let currentRelPath = '';
|
|
156
|
+
|
|
157
|
+
async function loadTree(relPath = '') {
|
|
158
|
+
if (!fmTree) return;
|
|
159
|
+
currentRelPath = relPath;
|
|
160
|
+
fmCurrentPath.textContent = '/root/.openclaw' + (relPath ? '/' + relPath : '');
|
|
161
|
+
fmTree.innerHTML = '<div class="fm-loading">加载中...</div>';
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`/cmd/api/tree?path=${encodeURIComponent(relPath)}`);
|
|
164
|
+
const data = await res.json();
|
|
165
|
+
renderTree(data, relPath);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
fmTree.innerHTML = `<div class="error" style="padding:16px;color:var(--terminal-error)">目录加载失败</div>`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderTree(items, relPath) {
|
|
172
|
+
fmTree.innerHTML = '';
|
|
173
|
+
|
|
174
|
+
if (relPath !== '') {
|
|
175
|
+
const parentPath = relPath.split('/').slice(0, -1).join('/');
|
|
176
|
+
const upItem = document.createElement('div');
|
|
177
|
+
upItem.className = 'fm-item';
|
|
178
|
+
upItem.innerHTML = `<div class="fm-icon">📁</div><div class="fm-name">..</div>`;
|
|
179
|
+
upItem.onclick = () => loadTree(parentPath);
|
|
180
|
+
fmTree.appendChild(upItem);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (items.length === 0) {
|
|
184
|
+
const empty = document.createElement('div');
|
|
185
|
+
empty.className = 'fm-loading';
|
|
186
|
+
empty.textContent = '空文件夹';
|
|
187
|
+
fmTree.appendChild(empty);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
items.forEach(item => {
|
|
192
|
+
const el = document.createElement('div');
|
|
193
|
+
el.className = 'fm-item';
|
|
194
|
+
|
|
195
|
+
const icon = document.createElement('div');
|
|
196
|
+
icon.className = 'fm-icon';
|
|
197
|
+
icon.textContent = item.isDirectory ? '📁' : '📄';
|
|
198
|
+
|
|
199
|
+
const name = document.createElement('div');
|
|
200
|
+
name.className = 'fm-name';
|
|
201
|
+
name.textContent = item.name;
|
|
202
|
+
name.title = item.name;
|
|
203
|
+
|
|
204
|
+
const actions = document.createElement('div');
|
|
205
|
+
actions.className = 'fm-item-actions';
|
|
206
|
+
|
|
207
|
+
if (item.isDirectory) {
|
|
208
|
+
el.onclick = () => loadTree(item.path);
|
|
209
|
+
} else {
|
|
210
|
+
const previewBtn = document.createElement('button');
|
|
211
|
+
previewBtn.innerHTML = '查看';
|
|
212
|
+
previewBtn.onclick = (e) => { e.stopPropagation(); previewFile(item.path); };
|
|
213
|
+
|
|
214
|
+
const downloadBtn = document.createElement('button');
|
|
215
|
+
downloadBtn.innerHTML = '下载';
|
|
216
|
+
downloadBtn.onclick = (e) => { e.stopPropagation(); window.open(`/cmd/api/download?path=${encodeURIComponent(item.path)}`, '_blank'); };
|
|
217
|
+
|
|
218
|
+
actions.appendChild(previewBtn);
|
|
219
|
+
actions.appendChild(downloadBtn);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
el.appendChild(icon);
|
|
223
|
+
el.appendChild(name);
|
|
224
|
+
el.appendChild(actions);
|
|
225
|
+
fmTree.appendChild(el);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (fmRefreshBtn) {
|
|
230
|
+
fmRefreshBtn.onclick = () => loadTree(currentRelPath);
|
|
231
|
+
fmUploadBtn.onclick = () => fmUploadInput.click();
|
|
232
|
+
fmUploadInput.onchange = async (e) => {
|
|
233
|
+
const file = e.target.files[0];
|
|
234
|
+
if (!file) return;
|
|
235
|
+
const formData = new FormData();
|
|
236
|
+
formData.append('file', file);
|
|
237
|
+
formData.append('path', currentRelPath);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
fmUploadBtn.textContent = '...';
|
|
241
|
+
const res = await fetch('/cmd/api/upload', {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
body: formData
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) throw new Error('上传时服务器报错');
|
|
246
|
+
fmUploadInput.value = '';
|
|
247
|
+
loadTree(currentRelPath);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
alert('上传失败: ' + err.message);
|
|
250
|
+
} finally {
|
|
251
|
+
fmUploadBtn.textContent = '↑';
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function previewFile(path) {
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(`/cmd/api/preview?path=${encodeURIComponent(path)}`);
|
|
259
|
+
if (!res.ok) throw new Error('获取预览失败');
|
|
260
|
+
const text = await res.text();
|
|
261
|
+
previewTitle.textContent = path;
|
|
262
|
+
previewBody.textContent = text;
|
|
263
|
+
previewModal.classList.remove('hidden');
|
|
264
|
+
} catch (err) {
|
|
265
|
+
alert('预览失败: ' + err.message);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (previewClose) {
|
|
270
|
+
previewClose.onclick = () => previewModal.classList.add('hidden');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Init Tree
|
|
274
|
+
loadTree();
|
package/public/index.html
CHANGED
|
@@ -4,44 +4,87 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title
|
|
7
|
+
<title>本地命令运行器</title>
|
|
8
8
|
<link rel="stylesheet" href="style.css">
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fira+Code&display=swap"
|
|
10
10
|
rel="stylesheet">
|
|
11
11
|
</head>
|
|
12
12
|
|
|
13
13
|
<body>
|
|
14
|
-
<
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<div class="
|
|
21
|
-
|
|
22
|
-
<div class="
|
|
23
|
-
<
|
|
24
|
-
<
|
|
14
|
+
<header>
|
|
15
|
+
<h1>终端运行器</h1>
|
|
16
|
+
<p>直接从你的浏览器执行本地 Shell 或 CMD 命令。</p>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<div class="layout-wrapper">
|
|
20
|
+
<div class="container">
|
|
21
|
+
|
|
22
|
+
<div class="presets">
|
|
23
|
+
<h3>常用指令</h3>
|
|
24
|
+
<div class="preset-buttons" style="align-items: center;">
|
|
25
|
+
<button class="btn-preset" data-cmd="myclaw start">启动小龙虾</button>
|
|
26
|
+
<button class="btn-preset" data-cmd="myclaw restart">重启小龙虾</button>
|
|
27
|
+
<button class="btn-preset" data-cmd="myclaw new">添加智能体</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="input-group">
|
|
33
|
+
<input type="text" id="cmdInput" placeholder="在此输入 Shell 或 CMD 指令..." autofocus>
|
|
34
|
+
<button id="runBtn" class="btn-primary">运行指令</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="terminal-container">
|
|
39
|
+
<div class="terminal-header">
|
|
40
|
+
<div class="dots">
|
|
41
|
+
<span class="dot red"></span>
|
|
42
|
+
<span class="dot yellow"></span>
|
|
43
|
+
<span class="dot green"></span>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="title" id="terminalTitle">终端面板</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="output" class="terminal-output"></div>
|
|
25
48
|
</div>
|
|
26
49
|
</div>
|
|
50
|
+
<!-- File Manager -->
|
|
51
|
+
<aside class="file-manager">
|
|
52
|
+
<div class="fm-header">
|
|
53
|
+
<h3>/.openclaw</h3>
|
|
54
|
+
<div class="fm-actions">
|
|
55
|
+
<input type="file" id="fmUploadInput" style="display: none;" />
|
|
56
|
+
<button class="btn-preset btn-sm" id="fmUploadBtn" title="上传文件">↑</button>
|
|
57
|
+
<button class="btn-preset btn-sm" id="fmRefreshBtn" title="刷新目录">↻</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="fm-path" id="fmCurrentPath">/root/.openclaw</div>
|
|
61
|
+
<div class="fm-body" id="fmTree">
|
|
62
|
+
<div class="fm-loading">加载中...</div>
|
|
63
|
+
</div>
|
|
64
|
+
</aside>
|
|
65
|
+
</div>
|
|
27
66
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
67
|
+
<!-- Preview Modal -->
|
|
68
|
+
<div id="previewModal" class="modal-overlay hidden">
|
|
69
|
+
<div class="modal-content preview-modal-content">
|
|
70
|
+
<h3 id="previewTitle" style="word-break: break-all;">文件预览</h3>
|
|
71
|
+
<pre class="preview-body" id="previewBody"></pre>
|
|
72
|
+
<div class="modal-actions">
|
|
73
|
+
<button id="previewClose" class="btn-primary">关闭</button>
|
|
32
74
|
</div>
|
|
33
75
|
</div>
|
|
76
|
+
</div>
|
|
34
77
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
78
|
+
<div id="customModal" class="modal-overlay hidden">
|
|
79
|
+
<div class="modal-content">
|
|
80
|
+
<h3>输入名称</h3>
|
|
81
|
+
<p>仅支持纯英文字母,且不支持空格。</p>
|
|
82
|
+
<input type="text" id="modalInput" placeholder="名称..." autocomplete="off">
|
|
83
|
+
<div id="modalError" class="modal-error hidden">格式无效!只能包含纯英文字母。</div>
|
|
84
|
+
<div class="modal-actions">
|
|
85
|
+
<button id="modalCancel" class="btn-preset">取消</button>
|
|
86
|
+
<button id="modalConfirm" class="btn-primary">确定</button>
|
|
43
87
|
</div>
|
|
44
|
-
<div id="output" class="terminal-output"></div>
|
|
45
88
|
</div>
|
|
46
89
|
</div>
|
|
47
90
|
|
package/public/style.css
CHANGED
|
@@ -22,21 +22,30 @@ body {
|
|
|
22
22
|
color: var(--text-primary);
|
|
23
23
|
min-height: 100vh;
|
|
24
24
|
display: flex;
|
|
25
|
-
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
align-items: center;
|
|
26
27
|
padding: 40px 20px;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
.
|
|
30
|
+
.layout-wrapper {
|
|
30
31
|
width: 100%;
|
|
31
|
-
max-width:
|
|
32
|
+
max-width: 1300px;
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: 1fr 350px;
|
|
35
|
+
gap: 24px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.container {
|
|
32
39
|
display: flex;
|
|
33
40
|
flex-direction: column;
|
|
34
41
|
gap: 24px;
|
|
42
|
+
min-width: 0;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
header {
|
|
38
46
|
text-align: center;
|
|
39
|
-
margin-bottom:
|
|
47
|
+
margin-bottom: 32px;
|
|
48
|
+
width: 100%;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
h1 {
|
|
@@ -44,6 +53,7 @@ h1 {
|
|
|
44
53
|
font-weight: 600;
|
|
45
54
|
background: -webkit-linear-gradient(#38bdf8, #818cf8);
|
|
46
55
|
-webkit-background-clip: text;
|
|
56
|
+
background-clip: text;
|
|
47
57
|
-webkit-text-fill-color: transparent;
|
|
48
58
|
margin-bottom: 8px;
|
|
49
59
|
}
|
|
@@ -244,3 +254,219 @@ input[type="text"]:focus {
|
|
|
244
254
|
.terminal-output::-webkit-scrollbar-thumb:hover {
|
|
245
255
|
background: rgba(255, 255, 255, 0.3);
|
|
246
256
|
}
|
|
257
|
+
|
|
258
|
+
/* Modal Styles */
|
|
259
|
+
.modal-overlay {
|
|
260
|
+
position: fixed;
|
|
261
|
+
top: 0;
|
|
262
|
+
left: 0;
|
|
263
|
+
right: 0;
|
|
264
|
+
bottom: 0;
|
|
265
|
+
background: rgba(0, 0, 0, 0.6);
|
|
266
|
+
backdrop-filter: blur(4px);
|
|
267
|
+
display: flex;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
align-items: center;
|
|
270
|
+
z-index: 1000;
|
|
271
|
+
opacity: 1;
|
|
272
|
+
transition: opacity 0.2s ease;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.modal-overlay.hidden {
|
|
276
|
+
opacity: 0;
|
|
277
|
+
pointer-events: none;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.modal-content {
|
|
281
|
+
background: var(--panel-bg);
|
|
282
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
283
|
+
padding: 24px;
|
|
284
|
+
border-radius: 16px;
|
|
285
|
+
width: 90%;
|
|
286
|
+
max-width: 400px;
|
|
287
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
|
288
|
+
transform: translateY(0);
|
|
289
|
+
transition: transform 0.2s ease;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.modal-overlay.hidden .modal-content {
|
|
293
|
+
transform: translateY(-20px);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.modal-content h3 {
|
|
297
|
+
margin-bottom: 8px;
|
|
298
|
+
font-size: 1.2rem;
|
|
299
|
+
font-weight: 500;
|
|
300
|
+
color: #f8fafc;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.modal-content p {
|
|
304
|
+
font-size: 0.9rem;
|
|
305
|
+
color: var(--text-secondary);
|
|
306
|
+
margin-bottom: 16px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.modal-content input {
|
|
310
|
+
width: 100%;
|
|
311
|
+
margin-bottom: 8px;
|
|
312
|
+
padding: 10px 16px;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.modal-error {
|
|
316
|
+
color: #ef4444;
|
|
317
|
+
font-size: 0.85rem;
|
|
318
|
+
margin-bottom: 16px;
|
|
319
|
+
min-height: 1.2em;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.modal-error.hidden {
|
|
323
|
+
display: none;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.modal-actions {
|
|
327
|
+
display: flex;
|
|
328
|
+
justify-content: flex-end;
|
|
329
|
+
gap: 12px;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.modal-actions .btn-preset {
|
|
333
|
+
padding: 10px 16px;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.modal-actions .btn-primary {
|
|
337
|
+
padding: 10px 20px;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* File Manager */
|
|
341
|
+
.file-manager {
|
|
342
|
+
background: var(--panel-bg);
|
|
343
|
+
backdrop-filter: blur(10px);
|
|
344
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
345
|
+
border-radius: 16px;
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-direction: column;
|
|
348
|
+
overflow: hidden;
|
|
349
|
+
height: 100%;
|
|
350
|
+
max-height: calc(100vh - 80px); /* accounting for body padding */
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.fm-header {
|
|
354
|
+
padding: 16px;
|
|
355
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
356
|
+
display: flex;
|
|
357
|
+
justify-content: space-between;
|
|
358
|
+
align-items: center;
|
|
359
|
+
background: rgba(0,0,0,0.2);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.fm-header h3 {
|
|
363
|
+
font-size: 1.1rem;
|
|
364
|
+
font-weight: 500;
|
|
365
|
+
margin: 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.fm-actions {
|
|
369
|
+
display: flex;
|
|
370
|
+
gap: 8px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.btn-sm {
|
|
374
|
+
padding: 6px 12px;
|
|
375
|
+
font-size: 0.8rem;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.fm-path {
|
|
379
|
+
padding: 8px 16px;
|
|
380
|
+
background: rgba(0,0,0,0.4);
|
|
381
|
+
font-family: 'Fira Code', monospace;
|
|
382
|
+
font-size: 0.8rem;
|
|
383
|
+
color: var(--text-secondary);
|
|
384
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
385
|
+
word-break: break-all;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.fm-body {
|
|
389
|
+
flex: 1;
|
|
390
|
+
overflow-y: auto;
|
|
391
|
+
padding: 8px;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.fm-loading {
|
|
395
|
+
padding: 16px;
|
|
396
|
+
text-align: center;
|
|
397
|
+
color: var(--text-secondary);
|
|
398
|
+
font-size: 0.9rem;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.fm-item {
|
|
402
|
+
display: flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
padding: 8px 12px;
|
|
405
|
+
border-radius: 6px;
|
|
406
|
+
cursor: pointer;
|
|
407
|
+
transition: background 0.2s;
|
|
408
|
+
gap: 12px;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.fm-item:hover {
|
|
412
|
+
background: rgba(255,255,255,0.05);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.fm-icon {
|
|
416
|
+
width: 24px;
|
|
417
|
+
text-align: center;
|
|
418
|
+
font-size: 1.1rem;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.fm-name {
|
|
422
|
+
flex: 1;
|
|
423
|
+
font-size: 0.9rem;
|
|
424
|
+
white-space: nowrap;
|
|
425
|
+
overflow: hidden;
|
|
426
|
+
text-overflow: ellipsis;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.fm-item-actions {
|
|
430
|
+
display: none;
|
|
431
|
+
gap: 6px;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.fm-item:hover .fm-item-actions {
|
|
435
|
+
display: flex;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.fm-item-actions button {
|
|
439
|
+
background: rgba(255,255,255,0.1);
|
|
440
|
+
border: none;
|
|
441
|
+
color: #e2e8f0;
|
|
442
|
+
padding: 4px 8px;
|
|
443
|
+
border-radius: 4px;
|
|
444
|
+
cursor: pointer;
|
|
445
|
+
font-size: 0.75rem;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.fm-item-actions button:hover {
|
|
449
|
+
background: var(--accent);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.preview-modal-content {
|
|
453
|
+
max-width: 800px;
|
|
454
|
+
width: 95%;
|
|
455
|
+
max-height: 90vh;
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.preview-body {
|
|
461
|
+
flex: 1;
|
|
462
|
+
overflow-y: auto;
|
|
463
|
+
background: var(--terminal-bg);
|
|
464
|
+
padding: 16px;
|
|
465
|
+
border-radius: 8px;
|
|
466
|
+
color: var(--terminal-text);
|
|
467
|
+
font-family: 'Fira Code', monospace;
|
|
468
|
+
font-size: 0.9rem;
|
|
469
|
+
margin-bottom: 16px;
|
|
470
|
+
white-space: pre-wrap;
|
|
471
|
+
word-break: break-all;
|
|
472
|
+
}
|
package/server.js
CHANGED
|
@@ -4,6 +4,8 @@ const { Server } = require('socket.io');
|
|
|
4
4
|
const { spawn } = require('child_process');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const multer = require('multer');
|
|
7
9
|
|
|
8
10
|
const app = express();
|
|
9
11
|
const httpServer = createServer(app);
|
|
@@ -14,6 +16,82 @@ const PORT = process.env.PORT || 3500;
|
|
|
14
16
|
app.use('/cmd', express.static(path.join(__dirname, 'public')));
|
|
15
17
|
app.get('/', (req, res) => res.redirect('/cmd/'));
|
|
16
18
|
|
|
19
|
+
// --- File Management APIs ---
|
|
20
|
+
const BASE_DIR = path.resolve('/root/.openclaw');
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.existsSync(BASE_DIR)) fs.mkdirSync(BASE_DIR, { recursive: true });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.log(`Could not create ${BASE_DIR}:`, e);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getSecurePath(relPath) {
|
|
28
|
+
// Prevent absolute paths or drives from being evaluated
|
|
29
|
+
const sanitized = (relPath || '').toString().replace(/^([a-zA-Z]:|[\\/]+)/, '');
|
|
30
|
+
const target = path.resolve(BASE_DIR, sanitized);
|
|
31
|
+
|
|
32
|
+
const secureBase = BASE_DIR + path.sep;
|
|
33
|
+
if (target !== BASE_DIR && !target.startsWith(secureBase)) {
|
|
34
|
+
throw new Error('Access denied: Directory constraint violation');
|
|
35
|
+
}
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
app.get('/cmd/api/tree', async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const relPath = req.query.path || '';
|
|
42
|
+
const targetPath = getSecurePath(relPath);
|
|
43
|
+
if (!fs.existsSync(targetPath)) return res.json([]);
|
|
44
|
+
const items = await fs.promises.readdir(targetPath, { withFileTypes: true });
|
|
45
|
+
|
|
46
|
+
const tree = items.map(item => ({
|
|
47
|
+
name: item.name,
|
|
48
|
+
isDirectory: item.isDirectory(),
|
|
49
|
+
path: path.relative(BASE_DIR, path.join(targetPath, item.name)).replace(/\\/g, '/')
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
tree.sort((a, b) => {
|
|
53
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
54
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
55
|
+
return a.name.localeCompare(b.name);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
res.json(tree);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
res.status(500).json({ error: err.message });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
app.get('/cmd/api/preview', async (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const targetPath = getSecurePath(req.query.path);
|
|
67
|
+
const content = await fs.promises.readFile(targetPath, 'utf8');
|
|
68
|
+
res.send(content);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
res.status(500).send(err.message);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.get('/cmd/api/download', (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const targetPath = getSecurePath(req.query.path);
|
|
77
|
+
res.download(targetPath);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
res.status(500).send('Download failed: ' + err.message);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const upload = multer({ dest: os.tmpdir() });
|
|
84
|
+
app.post('/cmd/api/upload', upload.single('file'), async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const targetDir = getSecurePath(req.body.path || '');
|
|
87
|
+
const targetPath = path.join(targetDir, req.file.originalname);
|
|
88
|
+
await fs.promises.rename(req.file.path, targetPath);
|
|
89
|
+
res.json({ success: true });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
res.status(500).json({ error: err.message });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
17
95
|
io.on('connection', (socket) => {
|
|
18
96
|
console.log('Client connected');
|
|
19
97
|
|