svamp-cli 0.2.45 → 0.2.47

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.
@@ -1,8 +1,8 @@
1
1
  import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { randomUUID } from 'node:crypto';
2
2
  import os from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { mkdirSync, writeFileSync, existsSync, unlinkSync, readFileSync, watch } from 'node:fs';
5
- import { c as connectToHypha, a as registerSessionService } from './run-D59qJKn_.mjs';
3
+ import { resolve, join } from 'node:path';
4
+ import { existsSync, readFileSync, watch } from 'node:fs';
5
+ import { c as connectToHypha, a as registerSessionService, h as generateHookSettings } from './run-6umeTX-K.mjs';
6
6
  import { createServer } from 'node:http';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { createInterface } from 'node:readline';
@@ -66,56 +66,6 @@ async function startHookServer(onSessionHook, log) {
66
66
  });
67
67
  }
68
68
 
69
- const SVAMP_HOME$1 = process.env.SVAMP_HOME || join(os.homedir(), ".svamp");
70
- function generateHookSettings(port) {
71
- const hooksDir = join(SVAMP_HOME$1, "tmp", "hooks");
72
- mkdirSync(hooksDir, { recursive: true });
73
- const forwarderPath = join(hooksDir, `forwarder-${process.pid}.cjs`);
74
- const forwarderCode = `#!/usr/bin/env node
75
- const http = require('http');
76
- const port = parseInt(process.argv[2], 10);
77
- if (!port || isNaN(port)) process.exit(1);
78
- const chunks = [];
79
- process.stdin.on('data', c => chunks.push(c));
80
- process.stdin.on('end', () => {
81
- const body = Buffer.concat(chunks);
82
- const req = http.request({
83
- host: '127.0.0.1', port, method: 'POST',
84
- path: '/hook/session-start',
85
- headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }
86
- }, res => res.resume());
87
- req.on('error', () => {});
88
- req.end(body);
89
- });
90
- process.stdin.resume();
91
- `;
92
- writeFileSync(forwarderPath, forwarderCode, { mode: 493 });
93
- const settingsPath = join(hooksDir, `session-hook-${process.pid}.json`);
94
- const hookCommand = `node "${forwarderPath}" ${port}`;
95
- const settings = {
96
- hooks: {
97
- SessionStart: [
98
- {
99
- matcher: "*",
100
- hooks: [{ type: "command", command: hookCommand }]
101
- }
102
- ]
103
- }
104
- };
105
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
106
- const cleanup = () => {
107
- try {
108
- if (existsSync(settingsPath)) unlinkSync(settingsPath);
109
- } catch {
110
- }
111
- try {
112
- if (existsSync(forwarderPath)) unlinkSync(forwarderPath);
113
- } catch {
114
- }
115
- };
116
- return { settingsPath, cleanup };
117
- }
118
-
119
69
  const INTERNAL_EVENT_TYPES = /* @__PURE__ */ new Set([
120
70
  "file-history-snapshot",
121
71
  "change",
@@ -45,6 +45,8 @@ async function handleServeCommand() {
45
45
  await serveInfo(machineId);
46
46
  } else if (sub === "add") {
47
47
  await serveAdd(filteredArgs.slice(1), machineId);
48
+ } else if (sub === "apply") {
49
+ await serveApply(filteredArgs.slice(1), machineId);
48
50
  } else if (sub && !sub.startsWith("-")) {
49
51
  await serveAdd(filteredArgs, machineId);
50
52
  } else {
@@ -52,7 +54,7 @@ async function handleServeCommand() {
52
54
  }
53
55
  }
54
56
  async function serveAdd(args, machineId) {
55
- const { connectAndGetMachine } = await import('./commands-Cd_I1MXo.mjs');
57
+ const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
56
58
  const pos = positionalArgs(args);
57
59
  const name = pos[0];
58
60
  if (!name) {
@@ -83,8 +85,93 @@ async function serveAdd(args, machineId) {
83
85
  });
84
86
  }
85
87
  }
88
+ async function serveApply(args, machineId) {
89
+ const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
90
+ const fs = await import('fs');
91
+ const yaml = await import('yaml');
92
+ const file = positionalArgs(args)[0];
93
+ if (!file) {
94
+ console.error("Usage: svamp serve apply <yaml-or-json-file> [--machine <id>]");
95
+ console.error("");
96
+ console.error("YAML format:");
97
+ console.error(" name: my-app");
98
+ console.error(" # Static mount:");
99
+ console.error(" directory: ./public");
100
+ console.error(" # OR managed-process mount:");
101
+ console.error(" process:");
102
+ console.error(" command: npm");
103
+ console.error(" args: [start]");
104
+ console.error(" port: 3000");
105
+ console.error(" workdir: .");
106
+ console.error(" warmup_path: / # default '/'");
107
+ console.error(" warmup_timeout_ms: 30000");
108
+ console.error(" idle_timeout_sec: 600 # 0 = always running");
109
+ console.error(" wake_on_request: true # spawn lazily on first request");
110
+ console.error(" access: public # public | owner (default) | [emails]");
111
+ process.exit(1);
112
+ }
113
+ if (!fs.existsSync(file)) {
114
+ console.error(`Error: file not found: ${file}`);
115
+ process.exit(1);
116
+ }
117
+ const raw = fs.readFileSync(file, "utf-8");
118
+ let parsed;
119
+ try {
120
+ parsed = file.endsWith(".json") ? JSON.parse(raw) : yaml.parse(raw);
121
+ } catch (err) {
122
+ console.error(`Error: failed to parse ${file}: ${err.message}`);
123
+ process.exit(1);
124
+ }
125
+ if (!parsed || typeof parsed !== "object") {
126
+ console.error("Error: top-level config must be an object");
127
+ process.exit(1);
128
+ }
129
+ if (!parsed.name) {
130
+ console.error("Error: name is required");
131
+ process.exit(1);
132
+ }
133
+ if (!parsed.directory && !parsed.process) {
134
+ console.error("Error: must specify either directory (static) or process (managed)");
135
+ process.exit(1);
136
+ }
137
+ const proc = parsed.process ? {
138
+ command: parsed.process.command,
139
+ args: parsed.process.args,
140
+ port: parsed.process.port,
141
+ workdir: parsed.process.workdir ? path.resolve(parsed.process.workdir) : void 0,
142
+ env: parsed.process.env,
143
+ warmupPath: parsed.process.warmupPath ?? parsed.process.warmup_path,
144
+ warmupTimeoutMs: parsed.process.warmupTimeoutMs ?? parsed.process.warmup_timeout_ms,
145
+ idleTimeoutSec: parsed.process.idleTimeoutSec ?? parsed.process.idle_timeout_sec,
146
+ wakeOnRequest: parsed.process.wakeOnRequest ?? parsed.process.wake_on_request
147
+ } : void 0;
148
+ const params = {
149
+ name: parsed.name,
150
+ directory: parsed.directory ? path.resolve(parsed.directory) : void 0,
151
+ process: proc,
152
+ sessionId: parsed.sessionId ?? parsed.session_id,
153
+ access: parsed.access ?? "owner",
154
+ ownerEmail: parsed.ownerEmail ?? parsed.owner_email
155
+ };
156
+ const { machine, server } = await connectAndGetMachine(machineId);
157
+ try {
158
+ const result = await machine.serveApply(params);
159
+ const kind = proc ? "managed" : "static";
160
+ const what = proc ? `${proc.command}${proc.args?.length ? " " + proc.args.join(" ") : ""} (port ${proc.port})` : params.directory;
161
+ console.log(`Mount applied (${kind}): ${params.name} \u2192 ${what}`);
162
+ if (proc?.wakeOnRequest) console.log("Wake-on-request: enabled (process starts on first incoming request)");
163
+ if (proc?.idleTimeoutSec) console.log(`Idle timeout: ${proc.idleTimeoutSec}s`);
164
+ console.log(`URL: ${result.url}`);
165
+ } catch (err) {
166
+ console.error(`Error: ${err.message || err}`);
167
+ process.exit(1);
168
+ } finally {
169
+ await server.disconnect().catch(() => {
170
+ });
171
+ }
172
+ }
86
173
  async function serveRemove(args, machineId) {
87
- const { connectAndGetMachine } = await import('./commands-Cd_I1MXo.mjs');
174
+ const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
88
175
  const pos = positionalArgs(args);
89
176
  const name = pos[0];
90
177
  if (!name) {
@@ -104,7 +191,7 @@ async function serveRemove(args, machineId) {
104
191
  }
105
192
  }
106
193
  async function serveList(args, machineId) {
107
- const { connectAndGetMachine } = await import('./commands-Cd_I1MXo.mjs');
194
+ const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
108
195
  const all = hasFlag(args, "--all", "-a");
109
196
  const json = hasFlag(args, "--json");
110
197
  const sessionId = getFlag(args, "--session");
@@ -137,7 +224,7 @@ async function serveList(args, machineId) {
137
224
  }
138
225
  }
139
226
  async function serveInfo(machineId) {
140
- const { connectAndGetMachine } = await import('./commands-Cd_I1MXo.mjs');
227
+ const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
141
228
  const { machine, server } = await connectAndGetMachine(machineId);
142
229
  try {
143
230
  const info = await machine.serveInfo();
@@ -164,6 +251,9 @@ Multiple sessions can register different mount points without port conflicts.
164
251
  Usage:
165
252
  svamp serve <name> [directory] Add a mount and print its URL (dir defaults to .)
166
253
  svamp serve add <name> [directory] Same as above
254
+ svamp serve apply <yaml> Apply a declarative mount config (idempotent).
255
+ Supports static and managed-process mounts
256
+ with wake-on-request and idle timeout.
167
257
  svamp serve remove <name> Remove a mount
168
258
  svamp serve list [--all] [--json] List mounts (default: current session only)
169
259
  svamp serve info Show server status and URL
@@ -183,8 +273,21 @@ Examples:
183
273
  svamp serve my-report ./output # Owner-only (default)
184
274
  svamp serve dashboard ./dist --public # Anyone can access
185
275
  svamp serve data ./csv --access a@x.com,b@y.com # Specific users
276
+ svamp serve apply my-app.yaml # Declarative apply
186
277
  svamp serve list --all # Show all mounts
187
278
  svamp serve remove my-report # Stop serving
279
+
280
+ Declarative apply (svamp serve apply <yaml>):
281
+ name: my-app
282
+ process:
283
+ command: npm
284
+ args: [start]
285
+ port: 3000
286
+ workdir: .
287
+ warmup_path: /
288
+ idle_timeout_sec: 600
289
+ wake_on_request: true
290
+ access: public
188
291
  `);
189
292
  }
190
293
 
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'child_process';
1
2
  import * as fs from 'fs';
2
3
  import * as http from 'http';
3
4
  import * as net from 'net';
@@ -281,6 +282,10 @@ class ServeManager {
281
282
  caddy = null;
282
283
  proxyServer = null;
283
284
  auth = null;
285
+ /** Live child processes for managed mounts. Keyed by mount name. */
286
+ managedProcs = /* @__PURE__ */ new Map();
287
+ /** Single timer that scans managed mounts every 30s for idle eviction. */
288
+ idleTimer = null;
284
289
  persistFile;
285
290
  log;
286
291
  hyphaServerUrl;
@@ -292,35 +297,72 @@ class ServeManager {
292
297
  }
293
298
  // ── Public API ───────────────────────────────────────────────────────
294
299
  /**
295
- * Add a mount and start Caddy + frpc tunnel if not already running.
296
- * Returns the public URL for this mount.
300
+ * Add a static mount (backward-compatible thin wrapper around applyMount).
301
+ * Throws if a mount with the same name already exists, preserving the
302
+ * pre-existing semantics that callers may depend on.
297
303
  */
298
304
  async addMount(name, directory, sessionId, access = "owner", ownerEmail) {
299
- validateMountName(name);
300
- const resolvedDir = path.resolve(directory);
301
- if (!fs.existsSync(resolvedDir)) {
302
- throw new Error(`Path does not exist: ${resolvedDir}`);
303
- }
304
305
  if (this.mounts.has(name)) {
305
306
  throw new Error(`Mount '${name}' already exists. Remove it first or choose a different name.`);
306
307
  }
308
+ return this.applyMount({ name, directory, sessionId, access, ownerEmail });
309
+ }
310
+ /**
311
+ * Apply a mount declaratively. Unifies static and managed-process mounts:
312
+ * pass `directory` for static, `process` for managed (or both — `process`
313
+ * takes precedence for routing). Replaces an existing mount with the same
314
+ * name (idempotent), so repeated `svamp serve apply` is safe.
315
+ *
316
+ * Backward-compatible: existing `addMount(name, directory, ...)` callers
317
+ * still work and produce the same result as `applyMount({name, directory, ...})`.
318
+ */
319
+ async applyMount(spec) {
320
+ validateMountName(spec.name);
321
+ if (!spec.directory && !spec.process) {
322
+ throw new Error(`Mount '${spec.name}': must specify either directory (static) or process (managed)`);
323
+ }
324
+ if (spec.process) {
325
+ if (!spec.process.command) {
326
+ throw new Error(`Mount '${spec.name}': process.command is required`);
327
+ }
328
+ if (!spec.process.port || spec.process.port <= 0) {
329
+ throw new Error(`Mount '${spec.name}': process.port must be a positive integer`);
330
+ }
331
+ }
332
+ const resolvedDir = spec.directory ? path.resolve(spec.directory) : void 0;
333
+ if (resolvedDir && !fs.existsSync(resolvedDir)) {
334
+ throw new Error(`Path does not exist: ${resolvedDir}`);
335
+ }
336
+ if (this.mounts.has(spec.name)) {
337
+ await this.removeMount(spec.name);
338
+ }
307
339
  const mount = {
308
- name,
340
+ name: spec.name,
309
341
  directory: resolvedDir,
310
- sessionId,
311
- ownerEmail,
312
- access,
342
+ process: spec.process,
343
+ sessionId: spec.sessionId,
344
+ ownerEmail: spec.ownerEmail,
345
+ access: spec.access ?? "owner",
313
346
  addedAt: Date.now()
314
347
  };
315
- this.mounts.set(name, mount);
348
+ this.mounts.set(spec.name, mount);
316
349
  await this.ensureRunning();
317
- if (this.caddy?.isRunning) {
318
- await this.caddy.addMount(name, resolvedDir);
350
+ if (resolvedDir && this.caddy?.isRunning) {
351
+ await this.caddy.addMount(spec.name, resolvedDir);
319
352
  }
320
- await this.startMountTunnel(name);
353
+ await this.startMountTunnel(spec.name);
354
+ if (spec.process && !spec.process.wakeOnRequest) {
355
+ try {
356
+ await this.ensureManagedRunning(spec.name);
357
+ } catch (err) {
358
+ this.log(`Mount '${spec.name}': initial process start failed: ${err.message}`);
359
+ }
360
+ }
361
+ this.ensureIdleTimer();
321
362
  this.persist();
322
- const url = this.getMountUrl(name);
323
- this.log(`Mount added: ${name} \u2192 ${resolvedDir} (${url ?? "tunnel pending"})`);
363
+ const url = this.getMountUrl(spec.name);
364
+ const what = spec.process ? `${spec.process.command}${spec.process.args?.length ? " " + spec.process.args.join(" ") : ""} (port ${spec.process.port})` : resolvedDir;
365
+ this.log(`Mount applied: ${spec.name} \u2192 ${what} (${url ?? "tunnel pending"})`);
324
366
  return { url: url || "", mount };
325
367
  }
326
368
  /**
@@ -331,6 +373,8 @@ class ServeManager {
331
373
  throw new Error(`Mount '${name}' not found`);
332
374
  }
333
375
  this.mounts.delete(name);
376
+ await this.stopManagedProcess(name).catch(() => {
377
+ });
334
378
  const tunnel = this.mountTunnels.get(name);
335
379
  if (tunnel) {
336
380
  try {
@@ -375,6 +419,7 @@ class ServeManager {
375
419
  return {
376
420
  url: firstUrl,
377
421
  port: running ? this.caddy?.port ?? this.port : 0,
422
+ authProxyPort: running ? this.port : 0,
378
423
  running,
379
424
  mountCount: this.mounts.size,
380
425
  mounts: Array.from(this.mounts.values()).map((m) => ({
@@ -396,12 +441,16 @@ class ServeManager {
396
441
  if (!raw.mounts || raw.mounts.length === 0) return;
397
442
  let restoredCount = 0;
398
443
  for (const m of raw.mounts) {
399
- if (fs.existsSync(m.directory)) {
400
- this.mounts.set(m.name, m);
401
- restoredCount++;
402
- } else {
444
+ if (m.directory && !fs.existsSync(m.directory)) {
403
445
  this.log(`Skipping mount '${m.name}': directory no longer exists (${m.directory})`);
446
+ continue;
404
447
  }
448
+ if (!m.directory && !m.process) {
449
+ this.log(`Skipping mount '${m.name}': neither directory nor process configured`);
450
+ continue;
451
+ }
452
+ this.mounts.set(m.name, m);
453
+ restoredCount++;
405
454
  }
406
455
  if (restoredCount > 0) {
407
456
  this.log(`Restoring ${restoredCount} mount(s)...`);
@@ -412,7 +461,15 @@ class ServeManager {
412
461
  } catch (err) {
413
462
  this.log(`Failed to start tunnel for restored mount '${m.name}': ${err.message}`);
414
463
  }
464
+ if (m.process && !m.process.wakeOnRequest) {
465
+ try {
466
+ await this.ensureManagedRunning(m.name);
467
+ } catch (err) {
468
+ this.log(`Restored managed process '${m.name}' failed to start: ${err.message}`);
469
+ }
470
+ }
415
471
  }
472
+ this.ensureIdleTimer();
416
473
  this.persist();
417
474
  }
418
475
  } catch (err) {
@@ -420,9 +477,18 @@ class ServeManager {
420
477
  }
421
478
  }
422
479
  /**
423
- * Shut down auth proxy + Caddy + all per-mount frpc tunnels.
480
+ * Shut down auth proxy + Caddy + all per-mount frpc tunnels + all
481
+ * managed processes.
424
482
  */
425
483
  async shutdown() {
484
+ for (const name of Array.from(this.managedProcs.keys())) {
485
+ await this.stopManagedProcess(name).catch(() => {
486
+ });
487
+ }
488
+ if (this.idleTimer) {
489
+ clearInterval(this.idleTimer);
490
+ this.idleTimer = null;
491
+ }
426
492
  for (const [name, tunnel] of this.mountTunnels.entries()) {
427
493
  try {
428
494
  tunnel.destroy();
@@ -443,6 +509,141 @@ class ServeManager {
443
509
  }
444
510
  this.auth?.destroy();
445
511
  }
512
+ // ── Managed-process lifecycle ─────────────────────────────────────────
513
+ /**
514
+ * Ensure the managed process for a mount is running and warm. If a warmup
515
+ * is already in flight, awaits the same promise (no duplicate spawns).
516
+ * Throws if warmup probe never returns 200/3xx within warmupTimeoutMs.
517
+ */
518
+ async ensureManagedRunning(name) {
519
+ const mount = this.mounts.get(name);
520
+ if (!mount?.process) return;
521
+ const handle = this.managedProcs.get(name);
522
+ if (handle && handle.warmupPromise) {
523
+ return handle.warmupPromise;
524
+ }
525
+ if (handle && handle.child.exitCode === null && !handle.child.killed) {
526
+ handle.lastRequestAt = Date.now();
527
+ return;
528
+ }
529
+ const cfg = mount.process;
530
+ const child = spawn(cfg.command, cfg.args ?? [], {
531
+ cwd: cfg.workdir ?? process.cwd(),
532
+ env: { ...process.env, ...cfg.env ?? {} },
533
+ stdio: ["ignore", "pipe", "pipe"],
534
+ detached: false
535
+ });
536
+ child.stdout?.on("data", (d) => {
537
+ const line = d.toString().trimEnd();
538
+ if (line) this.log(`[${name}] ${line}`);
539
+ });
540
+ child.stderr?.on("data", (d) => {
541
+ const line = d.toString().trimEnd();
542
+ if (line) this.log(`[${name}] ${line}`);
543
+ });
544
+ child.on("exit", (code, signal) => {
545
+ this.log(`Managed process '${name}' exited (code=${code}, signal=${signal})`);
546
+ const h = this.managedProcs.get(name);
547
+ if (h && h.child === child) this.managedProcs.delete(name);
548
+ });
549
+ const warmupPath = cfg.warmupPath ?? "/";
550
+ const warmupTimeoutMs = cfg.warmupTimeoutMs ?? 3e4;
551
+ const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs).then(() => {
552
+ const h = this.managedProcs.get(name);
553
+ if (h) {
554
+ h.warmupPromise = null;
555
+ h.lastRequestAt = Date.now();
556
+ }
557
+ this.log(`Managed process '${name}' ready on 127.0.0.1:${cfg.port}`);
558
+ }).catch((err) => {
559
+ this.log(`Managed process '${name}' warmup failed: ${err.message}`);
560
+ try {
561
+ child.kill("SIGTERM");
562
+ } catch {
563
+ }
564
+ this.managedProcs.delete(name);
565
+ throw err;
566
+ });
567
+ const newHandle = {
568
+ child,
569
+ pid: child.pid ?? 0,
570
+ lastRequestAt: Date.now(),
571
+ warmupPromise
572
+ };
573
+ this.managedProcs.set(name, newHandle);
574
+ this.log(`Managed process '${name}' starting: ${cfg.command} ${(cfg.args ?? []).join(" ")} (port ${cfg.port})`);
575
+ return warmupPromise;
576
+ }
577
+ /** Poll a URL until it returns <500 or the deadline passes. */
578
+ async warmupProbe(url, timeoutMs) {
579
+ const deadline = Date.now() + timeoutMs;
580
+ let lastErr;
581
+ while (Date.now() < deadline) {
582
+ try {
583
+ const ctrl = new AbortController();
584
+ const t = setTimeout(() => ctrl.abort(), 2e3);
585
+ let resp;
586
+ try {
587
+ resp = await fetch(url, {
588
+ method: "GET",
589
+ signal: ctrl.signal,
590
+ redirect: "manual"
591
+ });
592
+ } finally {
593
+ clearTimeout(t);
594
+ }
595
+ if (resp.status < 500) return;
596
+ lastErr = new Error(`status ${resp.status}`);
597
+ } catch (err) {
598
+ lastErr = err;
599
+ }
600
+ await new Promise((r) => setTimeout(r, 500));
601
+ }
602
+ throw new Error(`warmup probe ${url} did not respond within ${timeoutMs}ms (${lastErr?.message || "no response"})`);
603
+ }
604
+ /** Stop a managed process (SIGTERM + SIGKILL after 5s). */
605
+ async stopManagedProcess(name) {
606
+ const h = this.managedProcs.get(name);
607
+ if (!h) return;
608
+ this.managedProcs.delete(name);
609
+ try {
610
+ h.child.kill("SIGTERM");
611
+ } catch {
612
+ }
613
+ const child = h.child;
614
+ await new Promise((resolve) => {
615
+ const t = setTimeout(() => {
616
+ try {
617
+ child.kill("SIGKILL");
618
+ } catch {
619
+ }
620
+ resolve();
621
+ }, 5e3);
622
+ child.once("exit", () => {
623
+ clearTimeout(t);
624
+ resolve();
625
+ });
626
+ });
627
+ this.log(`Managed process '${name}' stopped`);
628
+ }
629
+ /** Idle eviction loop — stops processes that have been idle longer than configured. */
630
+ ensureIdleTimer() {
631
+ if (this.idleTimer) return;
632
+ this.idleTimer = setInterval(() => {
633
+ const now = Date.now();
634
+ for (const [name, mount] of this.mounts) {
635
+ const cfg = mount.process;
636
+ if (!cfg || !cfg.idleTimeoutSec || cfg.idleTimeoutSec <= 0) continue;
637
+ const h = this.managedProcs.get(name);
638
+ if (!h || h.warmupPromise) continue;
639
+ if (now - h.lastRequestAt >= cfg.idleTimeoutSec * 1e3) {
640
+ this.log(`Idle eviction: stopping '${name}' (idle ${Math.round((now - h.lastRequestAt) / 1e3)}s \u2265 ${cfg.idleTimeoutSec}s)`);
641
+ this.stopManagedProcess(name).catch(() => {
642
+ });
643
+ }
644
+ }
645
+ }, 3e4);
646
+ }
446
647
  // ── Internal ─────────────────────────────────────────────────────────
447
648
  /** Get the public URL for a mount (mount-specific subdomain). */
448
649
  getMountUrl(name) {
@@ -484,7 +685,9 @@ class ServeManager {
484
685
  log: (msg) => this.log(`[Caddy] ${msg}`)
485
686
  });
486
687
  for (const mount of this.mounts.values()) {
487
- await this.caddy.addMount(mount.name, mount.directory);
688
+ if (mount.directory) {
689
+ await this.caddy.addMount(mount.name, mount.directory);
690
+ }
488
691
  }
489
692
  await this.caddy.start();
490
693
  this.log(`Caddy file server started on 127.0.0.1:${this.caddyPort}`);
@@ -510,6 +713,18 @@ class ServeManager {
510
713
  basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
511
714
  }
512
715
  const mount = mountName ? this.mounts.get(mountName) : void 0;
716
+ if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
717
+ res.writeHead(200, {
718
+ "Content-Type": "application/json",
719
+ "Cache-Control": "no-store"
720
+ });
721
+ res.end(JSON.stringify({
722
+ ok: true,
723
+ mount: mountName || null,
724
+ ts: Date.now()
725
+ }));
726
+ return;
727
+ }
513
728
  if (basePath === "/__login__" || url.pathname === "/__login__") {
514
729
  const returnUrl = url.searchParams.get("return") || "/";
515
730
  const safeReturn = returnUrl.startsWith("/__login__") ? "/" : returnUrl;
@@ -535,7 +750,7 @@ class ServeManager {
535
750
  return;
536
751
  }
537
752
  }
538
- if (req.method === "PUT" && mount) {
753
+ if (req.method === "PUT" && mount && mount.directory) {
539
754
  const filePath = path.join(mount.directory, basePath);
540
755
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
541
756
  const ws = fs.createWriteStream(filePath);
@@ -550,7 +765,7 @@ class ServeManager {
550
765
  });
551
766
  return;
552
767
  }
553
- if (req.method === "DELETE" && mount) {
768
+ if (req.method === "DELETE" && mount && mount.directory) {
554
769
  const filePath = path.join(mount.directory, basePath);
555
770
  try {
556
771
  fs.unlinkSync(filePath);
@@ -562,6 +777,39 @@ class ServeManager {
562
777
  }
563
778
  return;
564
779
  }
780
+ if (mount && mount.process) {
781
+ const cfg = mount.process;
782
+ if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
783
+ try {
784
+ await this.ensureManagedRunning(mount.name);
785
+ } catch (err) {
786
+ res.writeHead(503, { "Content-Type": "text/plain" });
787
+ res.end(`Backend not ready: ${err?.message || err}`);
788
+ return;
789
+ }
790
+ }
791
+ const handle = this.managedProcs.get(mount.name);
792
+ if (handle) handle.lastRequestAt = Date.now();
793
+ const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
794
+ const proxyReq2 = http.request({
795
+ hostname: "127.0.0.1",
796
+ port: cfg.port,
797
+ path: targetPath,
798
+ method: req.method,
799
+ headers: req.headers
800
+ }, (proxyRes) => {
801
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
802
+ proxyRes.pipe(res);
803
+ });
804
+ proxyReq2.on("error", (err) => {
805
+ if (!res.headersSent) {
806
+ res.writeHead(502);
807
+ res.end(`Backend error: ${err.message}`);
808
+ }
809
+ });
810
+ req.pipe(proxyReq2);
811
+ return;
812
+ }
565
813
  let proxyPath = req.url || "/";
566
814
  if (mountResolvedByHost && mountName) {
567
815
  const search = url.search || "";
@@ -634,10 +882,17 @@ class ServeManager {
634
882
  const subdomainSafe = mountName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
635
883
  const tunnelName = `static-${subdomainSafe}`;
636
884
  try {
637
- const { FrpcTunnel } = await import('./frpc-DzRFx60H.mjs');
638
- const tunnel = new FrpcTunnel({
885
+ const { FrpcTunnel } = await import('./frpc-j60b46eU.mjs');
886
+ let tunnel;
887
+ tunnel = new FrpcTunnel({
639
888
  name: tunnelName,
640
889
  ports: [this.port],
890
+ // End-to-end probe: the daemon's health loop watches probe.ok
891
+ // to detect ghosted tunnel registrations (frpc says "connected"
892
+ // but no traffic actually flows). The sentinel route is served
893
+ // by the auth proxy without auth.
894
+ probePath: "/__svamp_health",
895
+ probeIntervalMs: 3e4,
641
896
  onError: (err) => this.log(`frpc error [${mountName}]: ${err.message}`),
642
897
  onConnect: () => {
643
898
  const url2 = tunnel.getUrls().get(this.port);
@@ -650,7 +905,8 @@ class ServeManager {
650
905
  this.log(`frpc tunnel connected for '${mountName}'. URL: ${url2}/`);
651
906
  }
652
907
  },
653
- onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`)
908
+ onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`),
909
+ onProbeFail: (err) => this.log(`probe fail [${mountName}]: ${err.message}`)
654
910
  });
655
911
  await tunnel.connect();
656
912
  this.mountTunnels.set(mountName, tunnel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",