svamp-cli 0.2.7 → 0.2.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/dist/{agentCommands-vROerKBL.mjs → agentCommands-BBTwxjv1.mjs} +2 -2
- package/dist/cli.mjs +44 -28
- package/dist/{commands-BYbuedOK.mjs → commands-BJJTEZD4.mjs} +1 -1
- package/dist/{commands-B5yjf3Me.mjs → commands-CAGeQm5f.mjs} +2 -2
- package/dist/{commands-Bh7MIzIQ.mjs → commands-DXaH6BQg.mjs} +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{package-rfyKrDIy.mjs → package-BMEGmZWU.mjs} +2 -2
- package/dist/{run-CKmnXg7d.mjs → run-B3G5eZmn.mjs} +1 -1
- package/dist/{run-1sh7lcBI.mjs → run-CAcScbEG.mjs} +50 -8
- package/dist/serveCommands-Dr9CAgHo.mjs +191 -0
- package/dist/serveManager-BPyT20Q8.mjs +648 -0
- package/dist/{staticServer-CWcmMF5V.mjs → staticServer-_-FoZQpD.mjs} +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { findAvailablePort, setCorsHeaders, serveFile, resolveSafePath, collectBody, generateDirectoryListing, escapeHtml } from './staticServer-_-FoZQpD.mjs';
|
|
5
|
+
import 'net';
|
|
6
|
+
|
|
7
|
+
const AUTH0_NAMESPACES = ["https://api.imjoy.io/", "https://amun.ai/"];
|
|
8
|
+
const COOKIE_NAME = "svamp_serve_token";
|
|
9
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
10
|
+
const DEFAULT_CACHE_MAX_SIZE = 1e3;
|
|
11
|
+
class TokenCache {
|
|
12
|
+
cache = /* @__PURE__ */ new Map();
|
|
13
|
+
ttlMs;
|
|
14
|
+
maxSize;
|
|
15
|
+
constructor(ttlMs = DEFAULT_CACHE_TTL_MS, maxSize = DEFAULT_CACHE_MAX_SIZE) {
|
|
16
|
+
this.ttlMs = ttlMs;
|
|
17
|
+
this.maxSize = maxSize;
|
|
18
|
+
}
|
|
19
|
+
get(token) {
|
|
20
|
+
const info = this.cache.get(token);
|
|
21
|
+
if (!info) return null;
|
|
22
|
+
if (Date.now() > info.expiresAt) {
|
|
23
|
+
this.cache.delete(token);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return info;
|
|
27
|
+
}
|
|
28
|
+
set(token, email) {
|
|
29
|
+
if (this.cache.size >= this.maxSize) {
|
|
30
|
+
const oldest = this.cache.keys().next().value;
|
|
31
|
+
if (oldest) this.cache.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
this.cache.set(token, {
|
|
34
|
+
email,
|
|
35
|
+
expiresAt: Date.now() + this.ttlMs
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.cache.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function extractToken(req) {
|
|
43
|
+
const cookies = parseCookies(req.headers.cookie || "");
|
|
44
|
+
if (cookies[COOKIE_NAME]) return cookies[COOKIE_NAME];
|
|
45
|
+
const auth = req.headers.authorization;
|
|
46
|
+
if (auth?.startsWith("Bearer ") || auth?.startsWith("bearer ")) {
|
|
47
|
+
return auth.slice(7).trim();
|
|
48
|
+
}
|
|
49
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
50
|
+
const qToken = url.searchParams.get("token");
|
|
51
|
+
if (qToken) return qToken;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function parseCookies(header) {
|
|
55
|
+
const cookies = {};
|
|
56
|
+
for (const pair of header.split(";")) {
|
|
57
|
+
const eq = pair.indexOf("=");
|
|
58
|
+
if (eq > 0) {
|
|
59
|
+
const key = pair.slice(0, eq).trim();
|
|
60
|
+
const value = pair.slice(eq + 1).trim();
|
|
61
|
+
cookies[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return cookies;
|
|
65
|
+
}
|
|
66
|
+
function parseJwtEmail(token) {
|
|
67
|
+
try {
|
|
68
|
+
const parts = token.split(".");
|
|
69
|
+
if (parts.length !== 3) return null;
|
|
70
|
+
const payload = JSON.parse(
|
|
71
|
+
Buffer.from(parts[1], "base64url").toString("utf-8")
|
|
72
|
+
);
|
|
73
|
+
if (payload.exp && payload.exp * 1e3 < Date.now()) return null;
|
|
74
|
+
for (const ns of AUTH0_NAMESPACES) {
|
|
75
|
+
const email = payload[ns + "email"];
|
|
76
|
+
if (typeof email === "string" && email) return email.toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
if (typeof payload.email === "string" && payload.email) return payload.email.toLowerCase();
|
|
79
|
+
return null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function verifyTokenViaHypha(token, hyphaServerUrl) {
|
|
85
|
+
try {
|
|
86
|
+
const baseUrl = hyphaServerUrl.replace(/\/$/, "");
|
|
87
|
+
const url = `${baseUrl}/public/services/ws/get_user_info`;
|
|
88
|
+
const resp = await fetch(url, {
|
|
89
|
+
method: "GET",
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${token}`
|
|
92
|
+
},
|
|
93
|
+
signal: AbortSignal.timeout(1e4)
|
|
94
|
+
});
|
|
95
|
+
if (!resp.ok) return null;
|
|
96
|
+
const data = await resp.json();
|
|
97
|
+
const email = data?.email;
|
|
98
|
+
if (typeof email === "string" && email) return email.toLowerCase();
|
|
99
|
+
return null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
class ServeAuth {
|
|
105
|
+
cache;
|
|
106
|
+
hyphaServerUrl;
|
|
107
|
+
constructor(options) {
|
|
108
|
+
this.hyphaServerUrl = options.hyphaServerUrl.replace(/\/$/, "");
|
|
109
|
+
this.cache = new TokenCache(
|
|
110
|
+
options.cacheTtlMs || DEFAULT_CACHE_TTL_MS,
|
|
111
|
+
options.cacheMaxSize || DEFAULT_CACHE_MAX_SIZE
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Authenticate a request and return the user's email, or null if not authenticated.
|
|
116
|
+
*/
|
|
117
|
+
async authenticate(req) {
|
|
118
|
+
const token = extractToken(req);
|
|
119
|
+
if (!token) return null;
|
|
120
|
+
const cached = this.cache.get(token);
|
|
121
|
+
if (cached) return cached.email;
|
|
122
|
+
const localEmail = parseJwtEmail(token);
|
|
123
|
+
if (localEmail) {
|
|
124
|
+
this.cache.set(token, localEmail);
|
|
125
|
+
return localEmail;
|
|
126
|
+
}
|
|
127
|
+
const serverEmail = await verifyTokenViaHypha(token, this.hyphaServerUrl);
|
|
128
|
+
if (serverEmail) {
|
|
129
|
+
this.cache.set(token, serverEmail);
|
|
130
|
+
return serverEmail;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Check if a user email is authorized for a mount's access level.
|
|
136
|
+
*/
|
|
137
|
+
isAuthorized(email, access, ownerEmail) {
|
|
138
|
+
if (access === "public") return true;
|
|
139
|
+
if (!email) return false;
|
|
140
|
+
if (access === "owner") {
|
|
141
|
+
return !!ownerEmail && email.toLowerCase() === ownerEmail.toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
return access.some((e) => e.toLowerCase() === email.toLowerCase());
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Generate the login page HTML. This page:
|
|
147
|
+
* 1. Calls hypha-login.start() to get a login URL
|
|
148
|
+
* 2. Redirects to the login URL
|
|
149
|
+
* 3. Polls hypha-login.check() for the token
|
|
150
|
+
* 4. Sets a cookie and redirects back
|
|
151
|
+
*/
|
|
152
|
+
getLoginPageHtml(redirectUrl) {
|
|
153
|
+
return `<!DOCTYPE html>
|
|
154
|
+
<html><head>
|
|
155
|
+
<meta charset="utf-8">
|
|
156
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
157
|
+
<title>Login \u2014 Svamp File Server</title>
|
|
158
|
+
<style>
|
|
159
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
160
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f6f8fa;color:#24292f}
|
|
161
|
+
.card{background:#fff;border:1px solid #d0d7de;border-radius:12px;padding:32px;max-width:400px;width:100%;text-align:center;box-shadow:0 4px 12px rgba(31,35,40,0.06)}
|
|
162
|
+
h1{font-size:1.25rem;margin-bottom:8px}
|
|
163
|
+
.subtitle{color:#656d76;font-size:0.9rem;margin-bottom:24px}
|
|
164
|
+
button{background:#0969da;color:#fff;border:none;border-radius:8px;padding:12px 24px;font-size:1rem;cursor:pointer;font-weight:500;width:100%}
|
|
165
|
+
button:hover{background:#0860c4}
|
|
166
|
+
button:disabled{background:#94d3a2;cursor:wait}
|
|
167
|
+
.status{margin-top:16px;color:#656d76;font-size:0.85rem;min-height:20px}
|
|
168
|
+
.error{color:#cf222e}
|
|
169
|
+
</style>
|
|
170
|
+
</head><body>
|
|
171
|
+
<div class="card">
|
|
172
|
+
<h1>Svamp File Server</h1>
|
|
173
|
+
<p class="subtitle">Authentication required to access this content.</p>
|
|
174
|
+
<button id="login-btn" onclick="startLogin()">Sign in with Hypha</button>
|
|
175
|
+
<div class="status" id="status"></div>
|
|
176
|
+
</div>
|
|
177
|
+
<script>
|
|
178
|
+
const hyphaServer = ${JSON.stringify(this.hyphaServerUrl)};
|
|
179
|
+
const redirectUrl = ${JSON.stringify(redirectUrl)};
|
|
180
|
+
const cookieName = ${JSON.stringify(COOKIE_NAME)};
|
|
181
|
+
|
|
182
|
+
async function startLogin() {
|
|
183
|
+
const btn = document.getElementById('login-btn');
|
|
184
|
+
const status = document.getElementById('status');
|
|
185
|
+
btn.disabled = true;
|
|
186
|
+
status.textContent = 'Starting login...';
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Call hypha-login.start()
|
|
190
|
+
const startResp = await fetch(hyphaServer + '/public/services/hypha-login/start');
|
|
191
|
+
if (!startResp.ok) throw new Error('Failed to start login');
|
|
192
|
+
const { key, login_url } = await startResp.json();
|
|
193
|
+
|
|
194
|
+
status.textContent = 'Redirecting to login...';
|
|
195
|
+
|
|
196
|
+
// Open login in a popup
|
|
197
|
+
const popup = window.open(login_url, 'hypha-login', 'width=500,height=600');
|
|
198
|
+
|
|
199
|
+
// Poll for token
|
|
200
|
+
status.textContent = 'Waiting for authentication...';
|
|
201
|
+
let attempts = 0;
|
|
202
|
+
const maxAttempts = 120; // 2 minutes
|
|
203
|
+
const poll = setInterval(async () => {
|
|
204
|
+
attempts++;
|
|
205
|
+
if (attempts > maxAttempts) {
|
|
206
|
+
clearInterval(poll);
|
|
207
|
+
status.innerHTML = '<span class="error">Login timed out. Please try again.</span>';
|
|
208
|
+
btn.disabled = false;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const checkResp = await fetch(
|
|
213
|
+
hyphaServer + '/public/services/hypha-login/check?key=' + encodeURIComponent('"' + key + '"') + '&timeout=' + encodeURIComponent('"0"')
|
|
214
|
+
);
|
|
215
|
+
if (!checkResp.ok) return;
|
|
216
|
+
const result = await checkResp.json();
|
|
217
|
+
if (result && result.token) {
|
|
218
|
+
clearInterval(poll);
|
|
219
|
+
if (popup && !popup.closed) popup.close();
|
|
220
|
+
|
|
221
|
+
// Set cookie (session cookie \u2014 no explicit expiry)
|
|
222
|
+
document.cookie = cookieName + '=' + result.token + '; path=/; SameSite=Lax; Secure';
|
|
223
|
+
|
|
224
|
+
status.textContent = 'Login successful! Redirecting...';
|
|
225
|
+
setTimeout(() => { window.location.href = redirectUrl; }, 500);
|
|
226
|
+
}
|
|
227
|
+
} catch { /* ignore polling errors */ }
|
|
228
|
+
}, 1000);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
status.innerHTML = '<span class="error">Login failed: ' + err.message + '</span>';
|
|
231
|
+
btn.disabled = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
<\/script>
|
|
235
|
+
</body></html>`;
|
|
236
|
+
}
|
|
237
|
+
destroy() {
|
|
238
|
+
this.cache.clear();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const MOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
243
|
+
function validateMountName(name) {
|
|
244
|
+
if (!name || name.length > 128) {
|
|
245
|
+
throw new Error("Mount name must be 1\u2013128 characters");
|
|
246
|
+
}
|
|
247
|
+
if (!MOUNT_NAME_RE.test(name)) {
|
|
248
|
+
throw new Error("Mount name must start with alphanumeric and contain only alphanumeric, hyphens, dots, or underscores");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
class ServeManager {
|
|
252
|
+
mounts = /* @__PURE__ */ new Map();
|
|
253
|
+
server = null;
|
|
254
|
+
port = 0;
|
|
255
|
+
tunnel = null;
|
|
256
|
+
serviceUrl = null;
|
|
257
|
+
auth = null;
|
|
258
|
+
persistFile;
|
|
259
|
+
serviceName = "static-serve";
|
|
260
|
+
log;
|
|
261
|
+
hyphaServerUrl;
|
|
262
|
+
constructor(svampHome, logger, hyphaServerUrl) {
|
|
263
|
+
this.persistFile = path.join(svampHome, "serve-mounts.json");
|
|
264
|
+
this.log = logger || ((msg) => console.log(`[SERVE] ${msg}`));
|
|
265
|
+
this.hyphaServerUrl = hyphaServerUrl || process.env.HYPHA_SERVER_URL || "https://hypha.aicell.io";
|
|
266
|
+
this.auth = new ServeAuth({ hyphaServerUrl: this.hyphaServerUrl });
|
|
267
|
+
}
|
|
268
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
269
|
+
/**
|
|
270
|
+
* Add a mount and start the server + tunnel if not already running.
|
|
271
|
+
* Returns the public URL for this mount.
|
|
272
|
+
*/
|
|
273
|
+
async addMount(name, directory, sessionId, access = "owner", ownerEmail) {
|
|
274
|
+
validateMountName(name);
|
|
275
|
+
const resolvedDir = path.resolve(directory);
|
|
276
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
277
|
+
throw new Error(`Path does not exist: ${resolvedDir}`);
|
|
278
|
+
}
|
|
279
|
+
if (this.mounts.has(name)) {
|
|
280
|
+
throw new Error(`Mount '${name}' already exists. Remove it first or choose a different name.`);
|
|
281
|
+
}
|
|
282
|
+
const mount = {
|
|
283
|
+
name,
|
|
284
|
+
directory: resolvedDir,
|
|
285
|
+
sessionId,
|
|
286
|
+
ownerEmail,
|
|
287
|
+
access,
|
|
288
|
+
addedAt: Date.now()
|
|
289
|
+
};
|
|
290
|
+
this.mounts.set(name, mount);
|
|
291
|
+
await this.ensureServerRunning();
|
|
292
|
+
this.persist();
|
|
293
|
+
const url = this.getMountUrl(name);
|
|
294
|
+
this.log(`Mount added: ${name} \u2192 ${resolvedDir} (${url})`);
|
|
295
|
+
return { url, mount };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Remove a mount. If no mounts remain, stop the server + tunnel.
|
|
299
|
+
*/
|
|
300
|
+
async removeMount(name) {
|
|
301
|
+
if (!this.mounts.has(name)) {
|
|
302
|
+
throw new Error(`Mount '${name}' not found`);
|
|
303
|
+
}
|
|
304
|
+
this.mounts.delete(name);
|
|
305
|
+
this.persist();
|
|
306
|
+
this.log(`Mount removed: ${name}`);
|
|
307
|
+
if (this.mounts.size === 0) {
|
|
308
|
+
await this.stopServer();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* List mounts, optionally filtered by sessionId.
|
|
313
|
+
*/
|
|
314
|
+
listMounts(sessionId) {
|
|
315
|
+
const all = Array.from(this.mounts.values());
|
|
316
|
+
if (sessionId) {
|
|
317
|
+
return all.filter((m) => m.sessionId === sessionId);
|
|
318
|
+
}
|
|
319
|
+
return all;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Get server info (URL, port, running state, mount count).
|
|
323
|
+
*/
|
|
324
|
+
getInfo() {
|
|
325
|
+
return {
|
|
326
|
+
url: this.serviceUrl,
|
|
327
|
+
port: this.port,
|
|
328
|
+
running: this.server !== null,
|
|
329
|
+
mountCount: this.mounts.size,
|
|
330
|
+
mounts: Array.from(this.mounts.values())
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Restore mounts from disk and restart the server if there are persisted mounts.
|
|
335
|
+
* Called during daemon startup.
|
|
336
|
+
*/
|
|
337
|
+
async restore() {
|
|
338
|
+
try {
|
|
339
|
+
if (!fs.existsSync(this.persistFile)) return;
|
|
340
|
+
const raw = JSON.parse(fs.readFileSync(this.persistFile, "utf-8"));
|
|
341
|
+
if (!raw.mounts || raw.mounts.length === 0) return;
|
|
342
|
+
let restoredCount = 0;
|
|
343
|
+
for (const m of raw.mounts) {
|
|
344
|
+
if (fs.existsSync(m.directory)) {
|
|
345
|
+
this.mounts.set(m.name, m);
|
|
346
|
+
restoredCount++;
|
|
347
|
+
} else {
|
|
348
|
+
this.log(`Skipping mount '${m.name}': directory no longer exists (${m.directory})`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (restoredCount > 0) {
|
|
352
|
+
this.log(`Restoring ${restoredCount} mount(s)...`);
|
|
353
|
+
await this.ensureServerRunning();
|
|
354
|
+
this.persist();
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
this.log(`Error restoring serve state: ${err.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Shut down the server, tunnel, and clean up.
|
|
362
|
+
*/
|
|
363
|
+
async shutdown() {
|
|
364
|
+
await this.stopServer();
|
|
365
|
+
this.auth?.destroy();
|
|
366
|
+
}
|
|
367
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
368
|
+
getMountUrl(name) {
|
|
369
|
+
const base = this.serviceUrl || `http://127.0.0.1:${this.port}`;
|
|
370
|
+
return `${base}/${name}/`;
|
|
371
|
+
}
|
|
372
|
+
persist() {
|
|
373
|
+
const state = {
|
|
374
|
+
mounts: Array.from(this.mounts.values()),
|
|
375
|
+
serviceName: this.serviceName,
|
|
376
|
+
serviceUrl: this.serviceUrl
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
fs.writeFileSync(this.persistFile, JSON.stringify(state, null, 2));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
this.log(`Error persisting serve state: ${err.message}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async ensureServerRunning() {
|
|
385
|
+
if (this.server) return;
|
|
386
|
+
this.port = await findAvailablePort(18080);
|
|
387
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
388
|
+
await new Promise((resolve, reject) => {
|
|
389
|
+
this.server.listen(this.port, "127.0.0.1", () => resolve());
|
|
390
|
+
this.server.on("error", reject);
|
|
391
|
+
});
|
|
392
|
+
this.log(`Static server listening on 127.0.0.1:${this.port}`);
|
|
393
|
+
await this.ensureExposure();
|
|
394
|
+
}
|
|
395
|
+
async ensureExposure() {
|
|
396
|
+
try {
|
|
397
|
+
const { getSandboxEnv } = await import('./api-BRbsyqJ4.mjs');
|
|
398
|
+
const env = getSandboxEnv();
|
|
399
|
+
if (env.sandboxId) {
|
|
400
|
+
const { createServiceGroup, addBackend } = await import('./api-BRbsyqJ4.mjs');
|
|
401
|
+
try {
|
|
402
|
+
const group = await createServiceGroup(this.serviceName, [this.port]);
|
|
403
|
+
this.serviceUrl = group.ports?.[0]?.url || group.url || null;
|
|
404
|
+
await addBackend(this.serviceName);
|
|
405
|
+
this.log(`Cloud backend added. URL: ${this.serviceUrl}`);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (err.message?.includes("already exists")) {
|
|
408
|
+
const { getServiceGroup } = await import('./api-BRbsyqJ4.mjs');
|
|
409
|
+
const existing = await getServiceGroup(this.serviceName);
|
|
410
|
+
this.serviceUrl = existing.ports?.[0]?.url || existing.url || null;
|
|
411
|
+
this.log(`Reusing existing service group. URL: ${this.serviceUrl}`);
|
|
412
|
+
} else {
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
const { createServiceGroup } = await import('./api-BRbsyqJ4.mjs');
|
|
418
|
+
try {
|
|
419
|
+
const group = await createServiceGroup(this.serviceName, [this.port]);
|
|
420
|
+
this.serviceUrl = group.ports?.[0]?.url || group.url || null;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (err.message?.includes("already exists")) {
|
|
423
|
+
const { getServiceGroup } = await import('./api-BRbsyqJ4.mjs');
|
|
424
|
+
const existing = await getServiceGroup(this.serviceName);
|
|
425
|
+
this.serviceUrl = existing.ports?.[0]?.url || existing.url || null;
|
|
426
|
+
} else {
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const { TunnelClient } = await import('./tunnel-BDKdemh0.mjs');
|
|
431
|
+
this.tunnel = new TunnelClient({
|
|
432
|
+
name: this.serviceName,
|
|
433
|
+
ports: [this.port],
|
|
434
|
+
maxReconnectAttempts: 0,
|
|
435
|
+
// infinite for daemon
|
|
436
|
+
onError: (err) => this.log(`Tunnel error: ${err.message}`),
|
|
437
|
+
onConnect: () => this.log(`Tunnel connected`),
|
|
438
|
+
onDisconnect: () => this.log(`Tunnel disconnected, reconnecting...`)
|
|
439
|
+
});
|
|
440
|
+
await this.tunnel.connect();
|
|
441
|
+
this.log(`Tunnel started. URL: ${this.serviceUrl}`);
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
this.log(`Warning: Could not expose server externally: ${err.message}`);
|
|
445
|
+
this.log(`Server is available locally at http://127.0.0.1:${this.port}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async stopServer() {
|
|
449
|
+
if (this.tunnel) {
|
|
450
|
+
this.tunnel.destroy();
|
|
451
|
+
this.tunnel = null;
|
|
452
|
+
this.log("Tunnel stopped");
|
|
453
|
+
}
|
|
454
|
+
if (this.server) {
|
|
455
|
+
await new Promise((resolve) => {
|
|
456
|
+
this.server.close(() => resolve());
|
|
457
|
+
});
|
|
458
|
+
this.server = null;
|
|
459
|
+
this.log("Static server stopped");
|
|
460
|
+
}
|
|
461
|
+
this.serviceUrl = null;
|
|
462
|
+
this.port = 0;
|
|
463
|
+
}
|
|
464
|
+
// ── HTTP Request Handler ─────────────────────────────────────────────
|
|
465
|
+
async handleRequest(req, res) {
|
|
466
|
+
setCorsHeaders(res);
|
|
467
|
+
if (req.method === "OPTIONS") {
|
|
468
|
+
res.writeHead(204);
|
|
469
|
+
res.end();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
473
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
474
|
+
if (pathname === "/__login__/" || pathname === "/__login__") {
|
|
475
|
+
const redirect = url.searchParams.get("redirect") || "/";
|
|
476
|
+
if (this.auth) {
|
|
477
|
+
const html = this.auth.getLoginPageHtml(redirect);
|
|
478
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
479
|
+
res.end(html);
|
|
480
|
+
} else {
|
|
481
|
+
res.writeHead(302, { Location: redirect });
|
|
482
|
+
res.end();
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (pathname === "/" || pathname === "") {
|
|
487
|
+
this.serveIndex(req, res);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
491
|
+
const mountName = segments[0];
|
|
492
|
+
const mount = this.mounts.get(mountName);
|
|
493
|
+
if (!mount) {
|
|
494
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
495
|
+
res.end(`Mount '${mountName}' not found. Visit / for available mounts.`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (mount.access !== "public" && this.auth) {
|
|
499
|
+
const email = await this.auth.authenticate(req);
|
|
500
|
+
if (!this.auth.isAuthorized(email, mount.access, mount.ownerEmail)) {
|
|
501
|
+
const currentUrl = req.url || "/";
|
|
502
|
+
res.writeHead(302, { Location: `/__login__/?redirect=${encodeURIComponent(currentUrl)}` });
|
|
503
|
+
res.end();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const subPath = "/" + segments.slice(1).join("/");
|
|
508
|
+
const mountDir = mount.directory;
|
|
509
|
+
try {
|
|
510
|
+
const mountStat = fs.statSync(mountDir);
|
|
511
|
+
if (mountStat.isFile()) {
|
|
512
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
513
|
+
serveFile(mountDir, req, res);
|
|
514
|
+
} else {
|
|
515
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
516
|
+
res.end("Method Not Allowed on single-file mount");
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
522
|
+
res.end("Mount directory not accessible");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const filePath = resolveSafePath(mountDir, subPath);
|
|
526
|
+
if (!filePath) {
|
|
527
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
528
|
+
res.end("Forbidden");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.handleMountRequest(req, res, filePath, mountDir, `/${mountName}${subPath}`, mountName);
|
|
532
|
+
}
|
|
533
|
+
async handleMountRequest(req, res, filePath, mountDir, displayPath, mountName) {
|
|
534
|
+
if (req.method === "DELETE") {
|
|
535
|
+
try {
|
|
536
|
+
if (!fs.existsSync(filePath)) {
|
|
537
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
538
|
+
res.end("Not Found");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const stat = fs.statSync(filePath);
|
|
542
|
+
if (stat.isDirectory()) {
|
|
543
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
544
|
+
} else {
|
|
545
|
+
fs.unlinkSync(filePath);
|
|
546
|
+
}
|
|
547
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
548
|
+
res.end(JSON.stringify({ deleted: displayPath }));
|
|
549
|
+
} catch (err) {
|
|
550
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
551
|
+
res.end(`Delete failed: ${err.message}`);
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (req.method === "POST") {
|
|
556
|
+
try {
|
|
557
|
+
const parentDir = path.dirname(filePath);
|
|
558
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
559
|
+
const body = await collectBody(req);
|
|
560
|
+
fs.writeFileSync(filePath, body);
|
|
561
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
562
|
+
res.end(JSON.stringify({ uploaded: displayPath, size: body.length }));
|
|
563
|
+
} catch (err) {
|
|
564
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
565
|
+
res.end(`Upload failed: ${err.message}`);
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
570
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
571
|
+
res.end("Method Not Allowed");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const stat = fs.statSync(filePath);
|
|
576
|
+
if (stat.isDirectory()) {
|
|
577
|
+
const indexPath = path.join(filePath, "index.html");
|
|
578
|
+
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
|
579
|
+
serveFile(indexPath, req, res);
|
|
580
|
+
} else {
|
|
581
|
+
if (!displayPath.endsWith("/")) {
|
|
582
|
+
res.writeHead(301, { Location: displayPath + "/" });
|
|
583
|
+
res.end();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const html = generateDirectoryListing(filePath, displayPath);
|
|
587
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
588
|
+
res.end(html);
|
|
589
|
+
}
|
|
590
|
+
} else if (stat.isFile()) {
|
|
591
|
+
serveFile(filePath, req, res);
|
|
592
|
+
} else {
|
|
593
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
594
|
+
res.end("Not Found");
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
598
|
+
res.end("Not Found");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
serveIndex(_req, res) {
|
|
602
|
+
const mountList = Array.from(this.mounts.values());
|
|
603
|
+
const rows = mountList.sort((a, b) => a.name.localeCompare(b.name)).map((m) => {
|
|
604
|
+
const href = `/${m.name}/`;
|
|
605
|
+
const session = m.sessionId ? escapeHtml(m.sessionId.slice(0, 8)) : "-";
|
|
606
|
+
const added = new Date(m.addedAt).toISOString().slice(0, 16).replace("T", " ");
|
|
607
|
+
const accessLabel = m.access === "public" ? "🌐 public" : m.access === "owner" ? "🔒 owner" : `🔒 ${Array.isArray(m.access) ? m.access.length + " users" : "restricted"}`;
|
|
608
|
+
return `<tr>
|
|
609
|
+
<td class="icon-cell">📁</td>
|
|
610
|
+
<td class="name-cell"><a href="${escapeHtml(href)}">${escapeHtml(m.name)}</a></td>
|
|
611
|
+
<td class="access-cell">${accessLabel}</td>
|
|
612
|
+
<td class="session-cell">${session}</td>
|
|
613
|
+
<td class="date-cell">${added}</td>
|
|
614
|
+
</tr>`;
|
|
615
|
+
}).join("\n");
|
|
616
|
+
const html = `<!DOCTYPE html>
|
|
617
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
618
|
+
<title>Svamp File Server</title>
|
|
619
|
+
<style>
|
|
620
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
621
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#24292f;background:#fff;padding:24px;max-width:960px;margin:0 auto}
|
|
622
|
+
h1{font-size:1.25rem;font-weight:600;margin-bottom:4px}
|
|
623
|
+
.subtitle{color:#656d76;font-size:0.9rem;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid #d0d7de}
|
|
624
|
+
table{width:100%;border-collapse:collapse}
|
|
625
|
+
tr{border-bottom:1px solid #eaeef2}
|
|
626
|
+
tr:hover{background:#f6f8fa}
|
|
627
|
+
th{text-align:left;padding:8px 12px;font-size:0.85rem;color:#656d76;border-bottom:2px solid #d0d7de}
|
|
628
|
+
td{padding:8px 12px;vertical-align:middle}
|
|
629
|
+
.icon-cell{width:32px;text-align:center;font-size:1.1rem}
|
|
630
|
+
.name-cell a{text-decoration:none;color:#0969da;font-weight:500}
|
|
631
|
+
.name-cell a:hover{text-decoration:underline}
|
|
632
|
+
.access-cell{color:#656d76;font-size:0.85rem}
|
|
633
|
+
.session-cell{color:#656d76;font-size:0.85rem;font-family:monospace}
|
|
634
|
+
.date-cell{color:#656d76;font-size:0.85rem}
|
|
635
|
+
.empty{padding:32px;text-align:center;color:#656d76}
|
|
636
|
+
</style>
|
|
637
|
+
</head><body>
|
|
638
|
+
<h1>Svamp File Server</h1>
|
|
639
|
+
<p class="subtitle">${mountList.length} mount(s) registered</p>
|
|
640
|
+
${mountList.length === 0 ? '<div class="empty">No mounts registered. Use <code>svamp serve <name> [directory]</code> to add one.</div>' : `<table><thead><tr><th></th><th>Mount</th><th>Access</th><th>Session</th><th>Added</th></tr></thead>
|
|
641
|
+
<tbody>${rows}</tbody></table>`}
|
|
642
|
+
</body></html>`;
|
|
643
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
644
|
+
res.end(html);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export { ServeManager };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svamp-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
|
|
5
5
|
"author": "Amun AI AB",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "rm -rf dist && tsc --noEmit && pkgroll",
|
|
22
22
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"test": "npx tsx test/test-authorize.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-ralph-loop.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs",
|
|
23
|
+
"test": "npx tsx test/test-authorize.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-ralph-loop.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs",
|
|
24
24
|
"test:hypha": "node --no-warnings test/test-hypha-service.mjs",
|
|
25
25
|
"dev": "tsx src/cli.ts",
|
|
26
26
|
"dev:daemon": "tsx src/cli.ts daemon start-sync",
|