svamp-cli 0.1.76 → 0.1.78
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/dist/{agentCommands-NVZzP_Vo.mjs → agentCommands-uNFhhdN1.mjs} +16 -51
- package/dist/cli.mjs +82 -30
- package/dist/{commands-lJ8V7MJE.mjs → commands-B6FEeZeP.mjs} +32 -36
- package/dist/{commands-CADr1mQg.mjs → commands-BYbuedOK.mjs} +4 -4
- package/dist/{commands-7Iw1nFwf.mjs → commands-Cf3mXxPZ.mjs} +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{package-Dpz1MLO4.mjs → package-DTOqWYBv.mjs} +2 -2
- package/dist/{run-B29grSMh.mjs → run-DqvxMsWh.mjs} +1 -1
- package/dist/{run-BnFGIK0c.mjs → run-DsXDjwLW.mjs} +199 -50
- package/dist/staticServer-CWcmMF5V.mjs +477 -0
- package/dist/{tunnel-C2kqST5d.mjs → tunnel-BDKdemh0.mjs} +51 -9
- package/package.json +2 -2
- package/dist/staticServer-B-S9sl6E.mjs +0 -198
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as net from 'net';
|
|
5
|
+
|
|
6
|
+
const MIME_TYPES = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".htm": "text/html; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".js": "application/javascript; charset=utf-8",
|
|
11
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".xml": "application/xml; charset=utf-8",
|
|
14
|
+
".csv": "text/csv; charset=utf-8",
|
|
15
|
+
".txt": "text/plain; charset=utf-8",
|
|
16
|
+
".md": "text/markdown; charset=utf-8",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".gif": "image/gif",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
".ico": "image/x-icon",
|
|
23
|
+
".webp": "image/webp",
|
|
24
|
+
".avif": "image/avif",
|
|
25
|
+
".woff": "font/woff",
|
|
26
|
+
".woff2": "font/woff2",
|
|
27
|
+
".ttf": "font/ttf",
|
|
28
|
+
".otf": "font/otf",
|
|
29
|
+
".eot": "application/vnd.ms-fontobject",
|
|
30
|
+
".pdf": "application/pdf",
|
|
31
|
+
".zip": "application/zip",
|
|
32
|
+
".gz": "application/gzip",
|
|
33
|
+
".tar": "application/x-tar",
|
|
34
|
+
".wasm": "application/wasm",
|
|
35
|
+
".mp4": "video/mp4",
|
|
36
|
+
".webm": "video/webm",
|
|
37
|
+
".mp3": "audio/mpeg",
|
|
38
|
+
".ogg": "audio/ogg",
|
|
39
|
+
".wav": "audio/wav",
|
|
40
|
+
".yaml": "text/yaml; charset=utf-8",
|
|
41
|
+
".yml": "text/yaml; charset=utf-8",
|
|
42
|
+
".toml": "text/plain; charset=utf-8"
|
|
43
|
+
};
|
|
44
|
+
function getMimeType(filePath) {
|
|
45
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
46
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
47
|
+
}
|
|
48
|
+
function setCorsHeaders(res) {
|
|
49
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
50
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS");
|
|
51
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
52
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
53
|
+
}
|
|
54
|
+
async function findAvailablePort(startPort) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const server = net.createServer();
|
|
57
|
+
server.listen(startPort, "127.0.0.1", () => {
|
|
58
|
+
const addr = server.address();
|
|
59
|
+
server.close(() => resolve(addr.port));
|
|
60
|
+
});
|
|
61
|
+
server.on("error", () => {
|
|
62
|
+
if (startPort < 65535) {
|
|
63
|
+
findAvailablePort(startPort + 1).then(resolve, reject);
|
|
64
|
+
} else {
|
|
65
|
+
reject(new Error("No available ports"));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function escapeHtml(s) {
|
|
71
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
72
|
+
}
|
|
73
|
+
function formatSize(bytes) {
|
|
74
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
75
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
76
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
77
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
78
|
+
}
|
|
79
|
+
function getFileIcon(name, isDir) {
|
|
80
|
+
if (isDir) return "📁";
|
|
81
|
+
const ext = path.extname(name).toLowerCase();
|
|
82
|
+
const imageExts = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico"];
|
|
83
|
+
const codeExts = [".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".go", ".rs", ".c", ".cpp", ".h", ".java", ".rb", ".sh", ".css", ".html", ".htm", ".xml", ".yaml", ".yml", ".toml", ".json"];
|
|
84
|
+
const docExts = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".md", ".txt", ".csv"];
|
|
85
|
+
const archiveExts = [".zip", ".gz", ".tar", ".rar", ".7z"];
|
|
86
|
+
const videoExts = [".mp4", ".webm", ".avi", ".mov"];
|
|
87
|
+
const audioExts = [".mp3", ".ogg", ".wav", ".flac"];
|
|
88
|
+
if (imageExts.includes(ext)) return "🖼";
|
|
89
|
+
if (codeExts.includes(ext)) return "📄";
|
|
90
|
+
if (docExts.includes(ext)) return "📋";
|
|
91
|
+
if (archiveExts.includes(ext)) return "📦";
|
|
92
|
+
if (videoExts.includes(ext)) return "🎥";
|
|
93
|
+
if (audioExts.includes(ext)) return "🎵";
|
|
94
|
+
return "📄";
|
|
95
|
+
}
|
|
96
|
+
function generateDirectoryListing(dirPath, urlPath) {
|
|
97
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
98
|
+
const items = entries.sort((a, b) => {
|
|
99
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
100
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
101
|
+
return a.name.localeCompare(b.name);
|
|
102
|
+
}).map((entry) => {
|
|
103
|
+
const isDir = entry.isDirectory();
|
|
104
|
+
const name = isDir ? `${entry.name}/` : entry.name;
|
|
105
|
+
const href = path.posix.join(urlPath, entry.name) + (isDir ? "/" : "");
|
|
106
|
+
let size = "";
|
|
107
|
+
let mtime = "";
|
|
108
|
+
if (!isDir) {
|
|
109
|
+
try {
|
|
110
|
+
const stat = fs.statSync(path.join(dirPath, entry.name));
|
|
111
|
+
size = formatSize(stat.size);
|
|
112
|
+
mtime = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const icon = getFileIcon(entry.name, isDir);
|
|
117
|
+
return { name, href, size, mtime, icon, isDir, rawName: entry.name };
|
|
118
|
+
});
|
|
119
|
+
const rows = items.map((item) => {
|
|
120
|
+
const downloadBtn = !item.isDir ? `<a class="act-btn download-btn" href="${escapeHtml(item.href)}" download title="Download">⬇</a>` : "";
|
|
121
|
+
const menuBtn = `<button class="act-btn menu-btn" data-path="${escapeHtml(item.href)}" data-name="${escapeHtml(item.rawName)}" data-isdir="${item.isDir}" title="More actions">⋯</button>`;
|
|
122
|
+
return `<tr>
|
|
123
|
+
<td class="icon-cell">${item.icon}</td>
|
|
124
|
+
<td class="name-cell"><a href="${escapeHtml(item.href)}">${escapeHtml(item.name)}</a></td>
|
|
125
|
+
<td class="size-cell">${item.size}</td>
|
|
126
|
+
<td class="date-cell">${item.mtime}</td>
|
|
127
|
+
<td class="action-cell">${downloadBtn}${menuBtn}</td>
|
|
128
|
+
</tr>`;
|
|
129
|
+
}).join("\n");
|
|
130
|
+
const parentRow = urlPath !== "/" ? `<tr><td class="icon-cell">📁</td><td class="name-cell"><a href="${escapeHtml(path.posix.dirname(urlPath))}/">..</a></td><td></td><td></td><td></td></tr>
|
|
131
|
+
` : "";
|
|
132
|
+
return `<!DOCTYPE html>
|
|
133
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
134
|
+
<title>Index of ${escapeHtml(urlPath)}</title>
|
|
135
|
+
<style>
|
|
136
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
137
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#24292f;background:#fff;padding:24px;max-width:960px;margin:0 auto}
|
|
138
|
+
h1{font-size:1.25rem;font-weight:600;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid #d0d7de}
|
|
139
|
+
table{width:100%;border-collapse:collapse}
|
|
140
|
+
tr{border-bottom:1px solid #eaeef2}
|
|
141
|
+
tr:hover{background:#f6f8fa}
|
|
142
|
+
td{padding:8px 12px;vertical-align:middle}
|
|
143
|
+
.icon-cell{width:32px;text-align:center;font-size:1.1rem}
|
|
144
|
+
.name-cell a{text-decoration:none;color:#0969da;font-weight:500}
|
|
145
|
+
.name-cell a:hover{text-decoration:underline}
|
|
146
|
+
.size-cell{width:80px;text-align:right;color:#656d76;font-size:0.85rem}
|
|
147
|
+
.date-cell{width:140px;color:#656d76;font-size:0.85rem}
|
|
148
|
+
.action-cell{width:70px;text-align:right;white-space:nowrap}
|
|
149
|
+
.act-btn{background:none;border:none;cursor:pointer;padding:4px 6px;border-radius:4px;color:#656d76;font-size:1rem;text-decoration:none;display:inline-block;line-height:1}
|
|
150
|
+
.act-btn:hover{background:#eaeef2;color:#24292f}
|
|
151
|
+
.download-btn{font-size:0.85rem}
|
|
152
|
+
|
|
153
|
+
/* Drop zone */
|
|
154
|
+
#drop-overlay{display:none;position:fixed;inset:0;background:rgba(9,105,218,0.08);border:3px dashed #0969da;z-index:1000;align-items:center;justify-content:center;pointer-events:none}
|
|
155
|
+
#drop-overlay.active{display:flex}
|
|
156
|
+
#drop-overlay .label{background:#0969da;color:#fff;padding:12px 24px;border-radius:8px;font-size:1.1rem;font-weight:600}
|
|
157
|
+
|
|
158
|
+
/* Upload progress */
|
|
159
|
+
#upload-bar{display:none;position:fixed;bottom:0;left:0;right:0;background:#f6f8fa;border-top:1px solid #d0d7de;padding:12px 24px;z-index:999;font-size:0.9rem}
|
|
160
|
+
#upload-bar.active{display:block}
|
|
161
|
+
#upload-bar .bar{height:4px;background:#d0d7de;border-radius:2px;margin-top:8px;overflow:hidden}
|
|
162
|
+
#upload-bar .bar .fill{height:100%;background:#0969da;transition:width 0.2s}
|
|
163
|
+
|
|
164
|
+
/* Context menu */
|
|
165
|
+
#ctx-menu{display:none;position:fixed;background:#fff;border:1px solid #d0d7de;border-radius:8px;box-shadow:0 8px 24px rgba(31,35,40,0.12);padding:4px 0;z-index:2000;min-width:160px}
|
|
166
|
+
#ctx-menu.active{display:block}
|
|
167
|
+
#ctx-menu button{display:flex;align-items:center;gap:8px;width:100%;background:none;border:none;padding:8px 16px;text-align:left;cursor:pointer;font-size:0.9rem;color:#24292f}
|
|
168
|
+
#ctx-menu button:hover{background:#f6f8fa}
|
|
169
|
+
#ctx-menu button.danger{color:#cf222e}
|
|
170
|
+
#ctx-menu button.danger:hover{background:#fff1f0}
|
|
171
|
+
#ctx-menu .divider{height:1px;background:#d0d7de;margin:4px 0}
|
|
172
|
+
|
|
173
|
+
/* Toast */
|
|
174
|
+
#toast{display:none;position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#24292f;color:#fff;padding:8px 20px;border-radius:6px;font-size:0.85rem;z-index:3000}
|
|
175
|
+
#toast.active{display:block}
|
|
176
|
+
</style>
|
|
177
|
+
</head><body>
|
|
178
|
+
<h1>Index of ${escapeHtml(urlPath)}</h1>
|
|
179
|
+
<table><thead><tr><th></th><th>Name</th><th style="text-align:right">Size</th><th>Modified</th><th></th></tr></thead>
|
|
180
|
+
<tbody>${parentRow}${rows}</tbody></table>
|
|
181
|
+
|
|
182
|
+
<div id="drop-overlay"><div class="label">Drop files to upload</div></div>
|
|
183
|
+
<div id="upload-bar"><span id="upload-status">Uploading...</span><div class="bar"><div class="fill" id="upload-fill" style="width:0%"></div></div></div>
|
|
184
|
+
<div id="ctx-menu">
|
|
185
|
+
<button id="ctx-download">⬇ Download</button>
|
|
186
|
+
<button id="ctx-copy">📋 Copy path</button>
|
|
187
|
+
<div class="divider"></div>
|
|
188
|
+
<button id="ctx-delete" class="danger">🗑 Delete</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div id="toast"></div>
|
|
191
|
+
|
|
192
|
+
<script>
|
|
193
|
+
(function(){
|
|
194
|
+
const currentPath = ${JSON.stringify(urlPath)};
|
|
195
|
+
const dropOverlay = document.getElementById('drop-overlay');
|
|
196
|
+
const uploadBar = document.getElementById('upload-bar');
|
|
197
|
+
const uploadFill = document.getElementById('upload-fill');
|
|
198
|
+
const uploadStatus = document.getElementById('upload-status');
|
|
199
|
+
const ctxMenu = document.getElementById('ctx-menu');
|
|
200
|
+
const toast = document.getElementById('toast');
|
|
201
|
+
let ctxTarget = null;
|
|
202
|
+
let dragCounter = 0;
|
|
203
|
+
|
|
204
|
+
// Toast
|
|
205
|
+
function showToast(msg, ms) {
|
|
206
|
+
toast.textContent = msg;
|
|
207
|
+
toast.classList.add('active');
|
|
208
|
+
setTimeout(() => toast.classList.remove('active'), ms || 2000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Context menu
|
|
212
|
+
document.querySelectorAll('.menu-btn').forEach(btn => {
|
|
213
|
+
btn.addEventListener('click', (e) => {
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
ctxTarget = { path: btn.dataset.path, name: btn.dataset.name, isDir: btn.dataset.isdir === 'true' };
|
|
216
|
+
const rect = btn.getBoundingClientRect();
|
|
217
|
+
ctxMenu.style.top = rect.bottom + 4 + 'px';
|
|
218
|
+
ctxMenu.style.left = Math.min(rect.left, window.innerWidth - 180) + 'px';
|
|
219
|
+
ctxMenu.classList.add('active');
|
|
220
|
+
document.getElementById('ctx-download').style.display = ctxTarget.isDir ? 'none' : '';
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
document.addEventListener('click', () => ctxMenu.classList.remove('active'));
|
|
225
|
+
|
|
226
|
+
document.getElementById('ctx-copy').addEventListener('click', () => {
|
|
227
|
+
if (!ctxTarget) return;
|
|
228
|
+
const fullPath = new URL(ctxTarget.path, location.href).pathname;
|
|
229
|
+
navigator.clipboard.writeText(fullPath).then(() => showToast('Path copied'));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
document.getElementById('ctx-download').addEventListener('click', () => {
|
|
233
|
+
if (!ctxTarget || ctxTarget.isDir) return;
|
|
234
|
+
const a = document.createElement('a');
|
|
235
|
+
a.href = ctxTarget.path;
|
|
236
|
+
a.download = ctxTarget.name;
|
|
237
|
+
document.body.appendChild(a);
|
|
238
|
+
a.click();
|
|
239
|
+
a.remove();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
document.getElementById('ctx-delete').addEventListener('click', async () => {
|
|
243
|
+
if (!ctxTarget) return;
|
|
244
|
+
const label = ctxTarget.isDir ? 'directory' : 'file';
|
|
245
|
+
if (!confirm('Delete ' + label + ' "' + ctxTarget.name + '"?')) return;
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(ctxTarget.path, { method: 'DELETE' });
|
|
248
|
+
if (res.ok) {
|
|
249
|
+
showToast('Deleted ' + ctxTarget.name);
|
|
250
|
+
setTimeout(() => location.reload(), 500);
|
|
251
|
+
} else {
|
|
252
|
+
const text = await res.text();
|
|
253
|
+
alert('Delete failed: ' + text);
|
|
254
|
+
}
|
|
255
|
+
} catch(err) {
|
|
256
|
+
alert('Delete failed: ' + err.message);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Drag and drop
|
|
261
|
+
document.addEventListener('dragenter', (e) => {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
dragCounter++;
|
|
264
|
+
dropOverlay.classList.add('active');
|
|
265
|
+
});
|
|
266
|
+
document.addEventListener('dragleave', (e) => {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
dragCounter--;
|
|
269
|
+
if (dragCounter <= 0) { dragCounter = 0; dropOverlay.classList.remove('active'); }
|
|
270
|
+
});
|
|
271
|
+
document.addEventListener('dragover', (e) => e.preventDefault());
|
|
272
|
+
document.addEventListener('drop', async (e) => {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
dragCounter = 0;
|
|
275
|
+
dropOverlay.classList.remove('active');
|
|
276
|
+
|
|
277
|
+
const items = e.dataTransfer.items;
|
|
278
|
+
if (!items || items.length === 0) return;
|
|
279
|
+
|
|
280
|
+
// Collect all files (including from folder drops via webkitGetAsEntry)
|
|
281
|
+
const files = [];
|
|
282
|
+
const promises = [];
|
|
283
|
+
for (let i = 0; i < items.length; i++) {
|
|
284
|
+
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
|
285
|
+
if (entry) {
|
|
286
|
+
promises.push(traverseEntry(entry, '', files));
|
|
287
|
+
} else if (items[i].kind === 'file') {
|
|
288
|
+
const f = items[i].getAsFile();
|
|
289
|
+
if (f) files.push({ file: f, relativePath: f.name });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
await Promise.all(promises);
|
|
293
|
+
if (files.length === 0) return;
|
|
294
|
+
|
|
295
|
+
// Upload sequentially with progress
|
|
296
|
+
uploadBar.classList.add('active');
|
|
297
|
+
for (let i = 0; i < files.length; i++) {
|
|
298
|
+
const { file, relativePath } = files[i];
|
|
299
|
+
uploadStatus.textContent = 'Uploading ' + (i+1) + '/' + files.length + ': ' + relativePath;
|
|
300
|
+
uploadFill.style.width = Math.round((i / files.length) * 100) + '%';
|
|
301
|
+
const uploadPath = currentPath.replace(/\\/$/, '') + '/' + relativePath;
|
|
302
|
+
await uploadFile(uploadPath, file);
|
|
303
|
+
}
|
|
304
|
+
uploadFill.style.width = '100%';
|
|
305
|
+
uploadStatus.textContent = 'Done \u2014 ' + files.length + ' file(s) uploaded';
|
|
306
|
+
setTimeout(() => { uploadBar.classList.remove('active'); location.reload(); }, 1000);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
function traverseEntry(entry, basePath, results) {
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
if (entry.isFile) {
|
|
312
|
+
entry.file((f) => {
|
|
313
|
+
results.push({ file: f, relativePath: basePath ? basePath + '/' + f.name : f.name });
|
|
314
|
+
resolve();
|
|
315
|
+
}, () => resolve());
|
|
316
|
+
} else if (entry.isDirectory) {
|
|
317
|
+
const reader = entry.createReader();
|
|
318
|
+
reader.readEntries(async (entries) => {
|
|
319
|
+
const subBase = basePath ? basePath + '/' + entry.name : entry.name;
|
|
320
|
+
await Promise.all(entries.map(e => traverseEntry(e, subBase, results)));
|
|
321
|
+
resolve();
|
|
322
|
+
}, () => resolve());
|
|
323
|
+
} else {
|
|
324
|
+
resolve();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function uploadFile(urlPath, file) {
|
|
330
|
+
const res = await fetch(urlPath, { method: 'POST', body: file,
|
|
331
|
+
headers: { 'Content-Type': 'application/octet-stream' }
|
|
332
|
+
});
|
|
333
|
+
if (!res.ok) throw new Error('Upload failed: ' + (await res.text()));
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
<\/script>
|
|
337
|
+
</body></html>`;
|
|
338
|
+
}
|
|
339
|
+
function collectBody(req) {
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const chunks = [];
|
|
342
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
343
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
344
|
+
req.on("error", reject);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function resolveSafePath(rootDir, urlPath) {
|
|
348
|
+
const decodedPath = decodeURIComponent(urlPath);
|
|
349
|
+
const normalizedPath = path.normalize(decodedPath);
|
|
350
|
+
if (normalizedPath.includes("..")) return null;
|
|
351
|
+
const filePath = path.join(rootDir, normalizedPath);
|
|
352
|
+
if (!filePath.startsWith(rootDir)) return null;
|
|
353
|
+
return filePath;
|
|
354
|
+
}
|
|
355
|
+
async function startStaticServer(options) {
|
|
356
|
+
const { directory, listing = true } = options;
|
|
357
|
+
const rootDir = path.resolve(directory);
|
|
358
|
+
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
|
|
359
|
+
throw new Error(`Not a directory: ${rootDir}`);
|
|
360
|
+
}
|
|
361
|
+
const server = http.createServer(async (req, res) => {
|
|
362
|
+
setCorsHeaders(res);
|
|
363
|
+
if (req.method === "OPTIONS") {
|
|
364
|
+
res.writeHead(204);
|
|
365
|
+
res.end();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const url = new URL(req.url || "/", `http://localhost`);
|
|
369
|
+
const filePath = resolveSafePath(rootDir, url.pathname);
|
|
370
|
+
if (!filePath) {
|
|
371
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
372
|
+
res.end("Forbidden");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (req.method === "DELETE") {
|
|
376
|
+
try {
|
|
377
|
+
if (!fs.existsSync(filePath)) {
|
|
378
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
379
|
+
res.end("Not Found");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const stat = fs.statSync(filePath);
|
|
383
|
+
if (stat.isDirectory()) {
|
|
384
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
385
|
+
} else {
|
|
386
|
+
fs.unlinkSync(filePath);
|
|
387
|
+
}
|
|
388
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
389
|
+
res.end(JSON.stringify({ deleted: url.pathname }));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
392
|
+
res.end(`Delete failed: ${err.message}`);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (req.method === "POST") {
|
|
397
|
+
try {
|
|
398
|
+
const parentDir = path.dirname(filePath);
|
|
399
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
400
|
+
const body = await collectBody(req);
|
|
401
|
+
fs.writeFileSync(filePath, body);
|
|
402
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
403
|
+
res.end(JSON.stringify({ uploaded: url.pathname, size: body.length }));
|
|
404
|
+
} catch (err) {
|
|
405
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
406
|
+
res.end(`Upload failed: ${err.message}`);
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
411
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
412
|
+
res.end("Method Not Allowed");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const stat = fs.statSync(filePath);
|
|
417
|
+
const decodedPath = decodeURIComponent(url.pathname);
|
|
418
|
+
if (stat.isDirectory()) {
|
|
419
|
+
const indexPath = path.join(filePath, "index.html");
|
|
420
|
+
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
|
421
|
+
serveFile(indexPath, req, res);
|
|
422
|
+
} else if (listing) {
|
|
423
|
+
if (!decodedPath.endsWith("/")) {
|
|
424
|
+
res.writeHead(301, { Location: decodedPath + "/" });
|
|
425
|
+
res.end();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const html = generateDirectoryListing(filePath, decodedPath);
|
|
429
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
430
|
+
res.end(html);
|
|
431
|
+
} else {
|
|
432
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
433
|
+
res.end("Not Found");
|
|
434
|
+
}
|
|
435
|
+
} else if (stat.isFile()) {
|
|
436
|
+
serveFile(filePath, req, res);
|
|
437
|
+
} else {
|
|
438
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
439
|
+
res.end("Not Found");
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
443
|
+
res.end("Not Found");
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
const port = await findAvailablePort(options.port || 18080);
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
server.listen(port, "127.0.0.1", () => {
|
|
449
|
+
resolve({
|
|
450
|
+
server,
|
|
451
|
+
port,
|
|
452
|
+
close: () => server.close()
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
server.on("error", reject);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function serveFile(filePath, req, res) {
|
|
459
|
+
const stat = fs.statSync(filePath);
|
|
460
|
+
const contentType = getMimeType(filePath);
|
|
461
|
+
res.writeHead(200, {
|
|
462
|
+
"Content-Type": contentType,
|
|
463
|
+
"Content-Length": stat.size,
|
|
464
|
+
"Cache-Control": "no-cache"
|
|
465
|
+
});
|
|
466
|
+
if (req.method === "HEAD") {
|
|
467
|
+
res.end();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const stream = fs.createReadStream(filePath);
|
|
471
|
+
stream.pipe(res);
|
|
472
|
+
stream.on("error", () => {
|
|
473
|
+
res.end();
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export { startStaticServer };
|
|
@@ -2,15 +2,20 @@ import * as os from 'os';
|
|
|
2
2
|
import { requireSandboxApiEnv } from './api-BRbsyqJ4.mjs';
|
|
3
3
|
import { WebSocket } from 'ws';
|
|
4
4
|
|
|
5
|
+
const PING_INTERVAL_MS = 3e4;
|
|
6
|
+
const PONG_TIMEOUT_MS = 1e4;
|
|
7
|
+
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 20;
|
|
5
8
|
class TunnelClient {
|
|
6
9
|
ws = null;
|
|
7
10
|
options;
|
|
8
11
|
env;
|
|
9
12
|
sandboxId;
|
|
10
13
|
reconnectAttempts = 0;
|
|
11
|
-
|
|
14
|
+
maxReconnects;
|
|
12
15
|
destroyed = false;
|
|
13
|
-
|
|
16
|
+
pingTimer = null;
|
|
17
|
+
pongTimeoutTimer = null;
|
|
18
|
+
lastPongAt = 0;
|
|
14
19
|
requestCount = 0;
|
|
15
20
|
localWebSockets = /* @__PURE__ */ new Map();
|
|
16
21
|
// request_id → local WS connection
|
|
@@ -22,6 +27,7 @@ class TunnelClient {
|
|
|
22
27
|
requestTimeout: 12e4,
|
|
23
28
|
...options
|
|
24
29
|
};
|
|
30
|
+
this.maxReconnects = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
|
|
25
31
|
this.env = options.env || requireSandboxApiEnv();
|
|
26
32
|
this.sandboxId = options.sandboxId || this.env.sandboxId || `local-${os.hostname()}-${process.pid}`;
|
|
27
33
|
}
|
|
@@ -52,6 +58,7 @@ class TunnelClient {
|
|
|
52
58
|
}
|
|
53
59
|
this.ws.on("open", () => {
|
|
54
60
|
this.reconnectAttempts = 0;
|
|
61
|
+
this.lastPongAt = Date.now();
|
|
55
62
|
this.startPingInterval();
|
|
56
63
|
this.options.onConnect?.();
|
|
57
64
|
resolve();
|
|
@@ -99,6 +106,20 @@ class TunnelClient {
|
|
|
99
106
|
get activeWebSockets() {
|
|
100
107
|
return this.localWebSockets.size;
|
|
101
108
|
}
|
|
109
|
+
/** Whether the tunnel WebSocket is currently open. */
|
|
110
|
+
get isConnected() {
|
|
111
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
112
|
+
}
|
|
113
|
+
/** Snapshot of tunnel health for external monitoring. */
|
|
114
|
+
get status() {
|
|
115
|
+
return {
|
|
116
|
+
connected: this.ws?.readyState === WebSocket.OPEN,
|
|
117
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
118
|
+
lastPongAt: this.lastPongAt,
|
|
119
|
+
totalRequests: this.requestCount,
|
|
120
|
+
activeWebSockets: this.localWebSockets.size
|
|
121
|
+
};
|
|
122
|
+
}
|
|
102
123
|
// ── Message handling ────────────────────────────────────────────────
|
|
103
124
|
handleMessage(raw) {
|
|
104
125
|
let msg;
|
|
@@ -112,6 +133,10 @@ class TunnelClient {
|
|
|
112
133
|
case "ping":
|
|
113
134
|
this.send({ type: "pong" });
|
|
114
135
|
break;
|
|
136
|
+
case "pong":
|
|
137
|
+
this.lastPongAt = Date.now();
|
|
138
|
+
this.clearPongTimeout();
|
|
139
|
+
break;
|
|
115
140
|
case "request":
|
|
116
141
|
this.options.onRequest?.(msg);
|
|
117
142
|
if (msg.has_body) {
|
|
@@ -274,20 +299,37 @@ class TunnelClient {
|
|
|
274
299
|
}
|
|
275
300
|
startPingInterval() {
|
|
276
301
|
this.stopPingInterval();
|
|
277
|
-
this.
|
|
302
|
+
this.pingTimer = setInterval(() => {
|
|
278
303
|
this.send({ type: "ping" });
|
|
279
|
-
|
|
304
|
+
this.schedulePongTimeout();
|
|
305
|
+
}, PING_INTERVAL_MS);
|
|
280
306
|
}
|
|
281
307
|
stopPingInterval() {
|
|
282
|
-
if (this.
|
|
283
|
-
clearInterval(this.
|
|
284
|
-
this.
|
|
308
|
+
if (this.pingTimer) {
|
|
309
|
+
clearInterval(this.pingTimer);
|
|
310
|
+
this.pingTimer = null;
|
|
311
|
+
}
|
|
312
|
+
this.clearPongTimeout();
|
|
313
|
+
}
|
|
314
|
+
schedulePongTimeout() {
|
|
315
|
+
this.clearPongTimeout();
|
|
316
|
+
this.pongTimeoutTimer = setTimeout(() => {
|
|
317
|
+
this.options.onError?.(new Error("Tunnel: pong timeout \u2014 connection stale"));
|
|
318
|
+
if (this.ws) {
|
|
319
|
+
this.ws.terminate();
|
|
320
|
+
}
|
|
321
|
+
}, PONG_TIMEOUT_MS);
|
|
322
|
+
}
|
|
323
|
+
clearPongTimeout() {
|
|
324
|
+
if (this.pongTimeoutTimer) {
|
|
325
|
+
clearTimeout(this.pongTimeoutTimer);
|
|
326
|
+
this.pongTimeoutTimer = null;
|
|
285
327
|
}
|
|
286
328
|
}
|
|
287
329
|
scheduleReconnect() {
|
|
288
|
-
if (this.reconnectAttempts >= this.
|
|
330
|
+
if (this.maxReconnects > 0 && this.reconnectAttempts >= this.maxReconnects) {
|
|
289
331
|
this.options.onError?.(new Error(
|
|
290
|
-
`Tunnel disconnected: max reconnect attempts (${this.
|
|
332
|
+
`Tunnel disconnected: max reconnect attempts (${this.maxReconnects}) reached`
|
|
291
333
|
));
|
|
292
334
|
return;
|
|
293
335
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svamp-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.78",
|
|
4
4
|
"description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
|
|
5
5
|
"author": "Amun AI AB",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@agentclientprotocol/sdk": "^0.14.1",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
32
32
|
"hypha-rpc": "0.21.34",
|
|
33
|
-
"node-pty": "
|
|
33
|
+
"node-pty": "1.2.0-beta.11",
|
|
34
34
|
"ws": "^8.18.0",
|
|
35
35
|
"yaml": "^2.8.2",
|
|
36
36
|
"zod": "^3.24.4"
|