@slicervm/sdk 0.1.1 → 0.1.3

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/index.cjs CHANGED
@@ -4,14 +4,19 @@ var http = require('http');
4
4
  var https = require('https');
5
5
  var url = require('url');
6
6
  var os = require('os');
7
- var path = require('path');
7
+ var path2 = require('path');
8
+ var net = require('net');
9
+ var fs = require('fs');
10
+ var ws = require('ws');
8
11
 
9
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
13
 
11
14
  var http__default = /*#__PURE__*/_interopDefault(http);
12
15
  var https__default = /*#__PURE__*/_interopDefault(https);
13
16
  var os__default = /*#__PURE__*/_interopDefault(os);
14
- var path__default = /*#__PURE__*/_interopDefault(path);
17
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
18
+ var net__default = /*#__PURE__*/_interopDefault(net);
19
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
15
20
 
16
21
  // src/types.ts
17
22
  var ExecStdioText = "text";
@@ -27,11 +32,11 @@ var SlicerAPIError = class extends Error {
27
32
  method;
28
33
  path;
29
34
  body;
30
- constructor(method, path2, status, body) {
31
- super(`slicer ${method} ${path2} failed: ${status} ${body}`);
35
+ constructor(method, path3, status, body) {
36
+ super(`slicer ${method} ${path3} failed: ${status} ${body}`);
32
37
  this.name = "SlicerAPIError";
33
38
  this.method = method;
34
- this.path = path2;
39
+ this.path = path3;
35
40
  this.status = status;
36
41
  this.body = body;
37
42
  }
@@ -44,7 +49,7 @@ function resolveTransport(baseURL) {
44
49
  if (!trimmed) throw new Error("Slicer baseURL is required");
45
50
  let candidate = trimmed;
46
51
  if (candidate.startsWith("unix://")) candidate = candidate.slice("unix://".length);
47
- if (candidate.startsWith("~/")) candidate = path__default.default.join(os__default.default.homedir(), candidate.slice(2));
52
+ if (candidate.startsWith("~/")) candidate = path2__default.default.join(os__default.default.homedir(), candidate.slice(2));
48
53
  const socketLike = candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.endsWith(".sock");
49
54
  if (socketLike) return { kind: "socket", socketPath: candidate };
50
55
  return { kind: "net", url: new url.URL(trimmed) };
@@ -156,9 +161,9 @@ var TransportClient = class {
156
161
  });
157
162
  }
158
163
  /** Streaming request producing a Node Readable of the response body. */
159
- requestStreamRaw(method, reqPath, body, contentType = "application/octet-stream") {
164
+ requestStreamRaw(method, reqPath, body, contentType = "application/octet-stream", extraHeaders = {}) {
160
165
  return new Promise((resolve, reject) => {
161
- const headers = {};
166
+ const headers = { ...extraHeaders };
162
167
  if (body instanceof Buffer) {
163
168
  headers["Content-Type"] = contentType;
164
169
  headers["Content-Length"] = String(body.length);
@@ -303,6 +308,258 @@ function secretFromWire(w) {
303
308
  ...w.modified_at !== void 0 && { modifiedAt: w.modified_at }
304
309
  };
305
310
  }
311
+ function looksLikeUnixSocketPath(s) {
312
+ if (!s) return false;
313
+ return s.startsWith("/") || s.startsWith("./") || s.startsWith("../") || s.includes("/");
314
+ }
315
+ function parseAddressMapping(spec) {
316
+ const lastColon = spec.lastIndexOf(":");
317
+ if (lastColon !== -1) {
318
+ const localPart = spec.slice(0, lastColon);
319
+ const remotePart = spec.slice(lastColon + 1);
320
+ if (looksLikeUnixSocketPath(localPart) && looksLikeUnixSocketPath(remotePart)) {
321
+ const localAbs = path2__default.default.isAbsolute(localPart) ? localPart : path2__default.default.resolve(localPart);
322
+ return { rawSpec: spec, listenUnixPath: localAbs, remoteUnixPath: remotePart };
323
+ }
324
+ }
325
+ const tcpToUnix = spec.indexOf(":/");
326
+ if (tcpToUnix !== -1) {
327
+ const listenPart = spec.slice(0, tcpToUnix);
328
+ const socketPath = spec.slice(tcpToUnix + 1);
329
+ const innerColon = listenPart.lastIndexOf(":");
330
+ if (innerColon === -1) {
331
+ return {
332
+ rawSpec: spec,
333
+ listenAddr: "0.0.0.0",
334
+ listenPort: parsePort(listenPart, spec),
335
+ remoteUnixPath: socketPath
336
+ };
337
+ }
338
+ return {
339
+ rawSpec: spec,
340
+ listenAddr: listenPart.slice(0, innerColon),
341
+ listenPort: parsePort(listenPart.slice(innerColon + 1), spec),
342
+ remoteUnixPath: socketPath
343
+ };
344
+ }
345
+ const parts = spec.split(":");
346
+ switch (parts.length) {
347
+ case 2:
348
+ return {
349
+ rawSpec: spec,
350
+ listenAddr: parts[0],
351
+ listenPort: parsePort(parts[1], spec),
352
+ remoteHost: parts[0],
353
+ remotePort: parsePort(parts[1], spec)
354
+ };
355
+ case 3:
356
+ return {
357
+ rawSpec: spec,
358
+ listenAddr: parts[1],
359
+ listenPort: parsePort(parts[0], spec),
360
+ remoteHost: parts[1],
361
+ remotePort: parsePort(parts[2], spec)
362
+ };
363
+ case 4:
364
+ return {
365
+ rawSpec: spec,
366
+ listenAddr: parts[0],
367
+ listenPort: parsePort(parts[1], spec),
368
+ remoteHost: parts[2],
369
+ remotePort: parsePort(parts[3], spec)
370
+ };
371
+ default:
372
+ throw new Error(`invalid forward spec ${JSON.stringify(spec)}: expected 2-4 colon-separated parts`);
373
+ }
374
+ }
375
+ function parsePort(s, spec) {
376
+ const n = Number(s);
377
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
378
+ throw new Error(`invalid port ${JSON.stringify(s)} in forward spec ${JSON.stringify(spec)}`);
379
+ }
380
+ return n;
381
+ }
382
+ function isListenUnix(m) {
383
+ return m.listenUnixPath !== void 0;
384
+ }
385
+ function remoteTargetHeader(m) {
386
+ if (m.remoteUnixPath) return `unix:${m.remoteUnixPath}`;
387
+ return `${m.remoteHost}:${m.remotePort}`;
388
+ }
389
+ function listenAddressDescription(m) {
390
+ if (isListenUnix(m)) return m.listenUnixPath;
391
+ return `${m.listenAddr}:${m.listenPort}`;
392
+ }
393
+ var Forwarder = class _Forwarder {
394
+ constructor(init, mappings) {
395
+ this.init = init;
396
+ this.mappings = mappings;
397
+ }
398
+ init;
399
+ mappings;
400
+ listeners = [];
401
+ servers = [];
402
+ liveSockets = /* @__PURE__ */ new Set();
403
+ closed = false;
404
+ static async start(init) {
405
+ if (init.specs.length === 0) {
406
+ throw new Error("Forwarder requires at least one forward spec");
407
+ }
408
+ const mappings = init.specs.map(parseAddressMapping);
409
+ const fwd = new _Forwarder(init, mappings);
410
+ try {
411
+ await fwd.bindAll();
412
+ } catch (err) {
413
+ await fwd.close();
414
+ throw err;
415
+ }
416
+ return fwd;
417
+ }
418
+ async bindAll() {
419
+ for (const m of this.mappings) {
420
+ const server = net.createServer((socket) => this.handleAccept(m, socket));
421
+ const { local, port } = await listen(server, m);
422
+ this.servers.push(server);
423
+ this.listeners.push({
424
+ spec: m.rawSpec,
425
+ local,
426
+ remote: remoteTargetHeader(m),
427
+ ...port !== void 0 && { port }
428
+ });
429
+ this.log(`listen ${local} \u2192 ${remoteTargetHeader(m)}`);
430
+ }
431
+ }
432
+ handleAccept(mapping, socket) {
433
+ if (this.closed) {
434
+ socket.destroy();
435
+ return;
436
+ }
437
+ socket.on("error", () => {
438
+ });
439
+ const ws$1 = openWebSocket(this.init, mapping);
440
+ let closed = false;
441
+ const cleanup = () => {
442
+ if (closed) return;
443
+ closed = true;
444
+ try {
445
+ socket.destroy();
446
+ } catch {
447
+ }
448
+ try {
449
+ ws$1.close();
450
+ } catch {
451
+ }
452
+ this.liveSockets.delete(handle);
453
+ };
454
+ const handle = { close: cleanup };
455
+ this.liveSockets.add(handle);
456
+ ws$1.binaryType = "nodebuffer";
457
+ ws$1.on("open", () => {
458
+ const wsStream = ws.createWebSocketStream(ws$1);
459
+ wsStream.on("error", cleanup);
460
+ socket.pipe(wsStream);
461
+ wsStream.pipe(socket);
462
+ });
463
+ ws$1.on("close", cleanup);
464
+ ws$1.on("error", (err) => {
465
+ this.log(`tunnel error: ${err.message}`);
466
+ cleanup();
467
+ });
468
+ socket.on("close", cleanup);
469
+ }
470
+ /** Tear down all listeners and any in-flight tunnel sockets. */
471
+ async close() {
472
+ this.closed = true;
473
+ for (const handle of [...this.liveSockets]) handle.close();
474
+ this.liveSockets.clear();
475
+ await Promise.all(
476
+ this.servers.map(
477
+ (s) => new Promise((resolve) => {
478
+ s.close(() => resolve());
479
+ })
480
+ )
481
+ );
482
+ for (const m of this.mappings) {
483
+ if (isListenUnix(m) && m.listenUnixPath) {
484
+ try {
485
+ fs__default.default.rmSync(m.listenUnixPath, { force: true });
486
+ } catch {
487
+ }
488
+ }
489
+ }
490
+ }
491
+ log(msg) {
492
+ this.init.options?.log?.(msg);
493
+ }
494
+ };
495
+ function listen(server, mapping) {
496
+ return new Promise((resolve, reject) => {
497
+ server.once("error", reject);
498
+ if (isListenUnix(mapping)) {
499
+ const p = mapping.listenUnixPath;
500
+ try {
501
+ fs__default.default.rmSync(p, { force: true });
502
+ } catch {
503
+ }
504
+ server.listen(p, () => {
505
+ try {
506
+ fs__default.default.chmodSync(p, 432);
507
+ } catch {
508
+ }
509
+ resolve({ local: p });
510
+ });
511
+ return;
512
+ }
513
+ server.listen({ host: mapping.listenAddr, port: mapping.listenPort }, () => {
514
+ const addr = server.address();
515
+ if (addr && typeof addr === "object") {
516
+ const local = `${mapping.listenAddr}:${addr.port}`;
517
+ resolve({ local, port: addr.port });
518
+ } else {
519
+ resolve({ local: listenAddressDescription(mapping) });
520
+ }
521
+ });
522
+ });
523
+ }
524
+ function openWebSocket(init, mapping) {
525
+ const url = wsURLForVM(init.transport, init.hostname);
526
+ const headers = {
527
+ "X-Inlets-Client-ID": init.options?.clientId ?? os__default.default.hostname(),
528
+ "X-Inlets-Mode": "local",
529
+ "X-Inlets-Upstream": remoteTargetHeader(mapping),
530
+ "User-Agent": init.userAgent
531
+ };
532
+ if (init.token) headers["Authorization"] = `Bearer ${init.token}`;
533
+ const opts = {
534
+ headers,
535
+ handshakeTimeout: init.options?.dialTimeoutMs ?? 1e4
536
+ };
537
+ if (init.transport.kind === "socket") {
538
+ opts.agent = unixAgent(init.transport.socketPath);
539
+ }
540
+ return new ws.WebSocket(url, opts);
541
+ }
542
+ function wsURLForVM(transport, hostname) {
543
+ if (transport.kind === "socket") {
544
+ return `ws://localhost/vm/${encodeURIComponent(hostname)}/forward`;
545
+ }
546
+ const u = transport.url;
547
+ const scheme = u.protocol === "https:" ? "wss" : "ws";
548
+ const port = u.port ? `:${u.port}` : "";
549
+ return `${scheme}://${u.hostname}${port}/vm/${encodeURIComponent(hostname)}/forward`;
550
+ }
551
+ function unixAgent(socketPath) {
552
+ const agent = new http__default.default.Agent({ keepAlive: false });
553
+ agent.createConnection = ((_opts, cb) => {
554
+ const conn = net__default.default.createConnection({ path: socketPath });
555
+ if (cb) {
556
+ conn.once("connect", () => cb(null, conn));
557
+ conn.once("error", (err) => cb(err));
558
+ }
559
+ return conn;
560
+ });
561
+ return agent;
562
+ }
306
563
 
307
564
  // src/vm.ts
308
565
  var VMFileSystem = class {
@@ -312,16 +569,16 @@ var VMFileSystem = class {
312
569
  }
313
570
  transport;
314
571
  hostname;
315
- async readDir(path2) {
316
- const q = new URLSearchParams({ path: path2 });
572
+ async readDir(path3) {
573
+ const q = new URLSearchParams({ path: path3 });
317
574
  const wire = await this.transport.request(
318
575
  "GET",
319
576
  `/vm/${encodeURIComponent(this.hostname)}/fs/readdir?${q.toString()}`
320
577
  );
321
578
  return (wire ?? []).map(fsEntryFromWire);
322
579
  }
323
- async stat(path2) {
324
- const q = new URLSearchParams({ path: path2 });
580
+ async stat(path3) {
581
+ const q = new URLSearchParams({ path: path3 });
325
582
  try {
326
583
  const wire = await this.transport.request(
327
584
  "GET",
@@ -333,8 +590,8 @@ var VMFileSystem = class {
333
590
  throw err;
334
591
  }
335
592
  }
336
- async exists(path2) {
337
- return await this.stat(path2) !== null;
593
+ async exists(path3) {
594
+ return await this.stat(path3) !== null;
338
595
  }
339
596
  async mkdir(req) {
340
597
  await this.transport.request(
@@ -347,22 +604,22 @@ var VMFileSystem = class {
347
604
  }
348
605
  );
349
606
  }
350
- async remove(path2, recursive = false) {
351
- const q = new URLSearchParams({ path: path2, recursive: String(recursive) });
607
+ async remove(path3, recursive = false) {
608
+ const q = new URLSearchParams({ path: path3, recursive: String(recursive) });
352
609
  await this.transport.request(
353
610
  "DELETE",
354
611
  `/vm/${encodeURIComponent(this.hostname)}/fs/remove?${q.toString()}`
355
612
  );
356
613
  }
357
- async readFile(path2) {
358
- const q = new URLSearchParams({ path: path2, mode: "binary" });
614
+ async readFile(path3) {
615
+ const q = new URLSearchParams({ path: path3, mode: "binary" });
359
616
  return this.transport.requestRaw(
360
617
  "GET",
361
618
  `/vm/${encodeURIComponent(this.hostname)}/cp?${q.toString()}`
362
619
  );
363
620
  }
364
- async writeFile(path2, content, opts = {}) {
365
- const q = new URLSearchParams({ path: path2, mode: "binary" });
621
+ async writeFile(path3, content, opts = {}) {
622
+ const q = new URLSearchParams({ path: path3, mode: "binary" });
366
623
  if (opts.uid !== void 0) q.set("uid", String(opts.uid));
367
624
  if (opts.gid !== void 0) q.set("gid", String(opts.gid));
368
625
  if (opts.permissions) q.set("permissions", opts.permissions);
@@ -374,8 +631,8 @@ var VMFileSystem = class {
374
631
  );
375
632
  }
376
633
  /** Upload a tar archive, expanded into the VM at `path`. */
377
- async tarTo(path2, tar) {
378
- const q = new URLSearchParams({ path: path2, mode: "tar" });
634
+ async tarTo(path3, tar) {
635
+ const q = new URLSearchParams({ path: path3, mode: "tar" });
379
636
  if (tar instanceof Buffer) {
380
637
  await this.transport.requestRaw(
381
638
  "POST",
@@ -393,9 +650,88 @@ var VMFileSystem = class {
393
650
  );
394
651
  for await (const _ of res) void _;
395
652
  }
653
+ /**
654
+ * Open a Server-Sent Events stream of filesystem events from the VM.
655
+ * Yields one `FSWatchEvent` per agent-side event. The stream stays open
656
+ * until the supplied request's `timeout` / `maxEvents` is hit, the daemon
657
+ * tears it down, or the caller breaks out of the loop.
658
+ *
659
+ * Heartbeat SSE comments and named `event:` lines are silently dropped.
660
+ *
661
+ * Example:
662
+ * ```ts
663
+ * for await (const e of vm.fs.watch({ paths: ['/tmp'], recursive: true })) {
664
+ * console.log(e.type, e.path);
665
+ * }
666
+ * ```
667
+ */
668
+ async *watch(req) {
669
+ if (!req.paths || req.paths.length === 0) {
670
+ throw new Error("vm.fs.watch: paths is required");
671
+ }
672
+ const q = new URLSearchParams();
673
+ for (const p of req.paths) if (p) q.append("paths", p);
674
+ for (const p of req.patterns ?? []) if (p) q.append("patterns", p);
675
+ for (const e of req.events ?? []) if (e) q.append("events", e);
676
+ if (req.uid !== void 0 && req.uid !== 0) q.set("uid", String(req.uid));
677
+ if (req.recursive) q.set("recursive", "true");
678
+ if (req.oneShot) q.set("one_shot", "true");
679
+ if (req.debounce) q.set("debounce", req.debounce);
680
+ if (req.timeout) q.set("timeout", req.timeout);
681
+ if (req.maxEvents !== void 0 && req.maxEvents > 0) {
682
+ q.set("max_events", String(req.maxEvents));
683
+ }
684
+ const extraHeaders = { Accept: "text/event-stream" };
685
+ if (req.lastEventId) extraHeaders["Last-Event-ID"] = req.lastEventId;
686
+ const res = await this.transport.requestStreamRaw(
687
+ "GET",
688
+ `/vm/${encodeURIComponent(this.hostname)}/fs/watch?${q.toString()}`,
689
+ void 0,
690
+ void 0,
691
+ extraHeaders
692
+ );
693
+ res.setEncoding("utf8");
694
+ let buffer = "";
695
+ let dataLines = [];
696
+ let pendingId = 0;
697
+ for await (const chunk of res) {
698
+ buffer += chunk;
699
+ let nl;
700
+ while ((nl = buffer.indexOf("\n")) >= 0) {
701
+ const raw = buffer.slice(0, nl);
702
+ buffer = buffer.slice(nl + 1);
703
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
704
+ if (line === "") {
705
+ if (dataLines.length > 0) {
706
+ const payload = dataLines.join("\n");
707
+ dataLines = [];
708
+ try {
709
+ const parsed = JSON.parse(payload);
710
+ const evt = {
711
+ id: typeof parsed.id === "number" && parsed.id !== 0 ? parsed.id : pendingId,
712
+ type: parsed.type ?? "",
713
+ path: parsed.path ?? "",
714
+ timestamp: parsed.timestamp ?? "",
715
+ size: parsed.size ?? 0,
716
+ isDir: parsed.isDir ?? false,
717
+ ...parsed.message !== void 0 && { message: parsed.message }
718
+ };
719
+ yield evt;
720
+ } catch {
721
+ }
722
+ }
723
+ } else if (line.startsWith(":")) ; else if (line.startsWith("data:")) {
724
+ dataLines.push(line.slice(5).replace(/^ /, ""));
725
+ } else if (line.startsWith("id:")) {
726
+ const v = parseInt(line.slice(3).trim(), 10);
727
+ if (!Number.isNaN(v)) pendingId = v;
728
+ }
729
+ }
730
+ }
731
+ }
396
732
  /** Download `path` from the VM as a tar archive. */
397
- async tarFrom(path2) {
398
- const q = new URLSearchParams({ path: path2, mode: "tar" });
733
+ async tarFrom(path3) {
734
+ const q = new URLSearchParams({ path: path3, mode: "tar" });
399
735
  return this.transport.requestRaw(
400
736
  "GET",
401
737
  `/vm/${encodeURIComponent(this.hostname)}/cp?${q.toString()}`
@@ -500,6 +836,30 @@ var VM = class {
500
836
  async restore() {
501
837
  await this.transport.request("POST", `/vm/${encodeURIComponent(this.hostname)}/restore`);
502
838
  }
839
+ // --- port forwarding ---------------------------------------------------
840
+ /**
841
+ * Open one or more port forwards from the host to this VM. Each spec follows
842
+ * the same syntax as `slicer vm forward -L`:
843
+ *
844
+ * `127.0.0.1:9000` — listen and forward on the same TCP port
845
+ * `8081:127.0.0.1:8080` — listen on `0.0.0.0:8081`, forward to `127.0.0.1:8080`
846
+ * `0.0.0.0:8080:127.0.0.1:8080` — fully explicit
847
+ * `9000:/var/run/docker.sock` — TCP listen, Unix socket forward
848
+ * `/tmp/docker.sock:/var/run/docker.sock` — Unix-to-Unix
849
+ *
850
+ * Returns a {@link Forwarder} handle. Call `forwarder.close()` to tear down
851
+ * all listeners and any in-flight tunnel sockets.
852
+ */
853
+ async forward(specs, options) {
854
+ return Forwarder.start({
855
+ hostname: this.hostname,
856
+ transport: this.transport.transport,
857
+ ...this.transport.token !== void 0 && { token: this.transport.token },
858
+ userAgent: this.transport.userAgent,
859
+ specs: typeof specs === "string" ? [specs] : specs,
860
+ ...options !== void 0 && { options }
861
+ });
862
+ }
503
863
  // --- exec -------------------------------------------------------------
504
864
  /**
505
865
  * Streaming exec — yields NDJSON frames (`started`, `stdout`, `stderr`, `exit`).
@@ -508,8 +868,8 @@ var VM = class {
508
868
  * `dataBytes`/`stdoutBytes`/`stderrBytes` Buffers alongside for convenience.
509
869
  */
510
870
  async *exec(req) {
511
- const { path: path2, body } = buildExecPath(this.hostname, req, false);
512
- for await (const frame of this.transport.requestNDJSON("POST", path2, body)) {
871
+ const { path: path3, body } = buildExecPath(this.hostname, req, false);
872
+ for await (const frame of this.transport.requestNDJSON("POST", path3, body)) {
513
873
  if (frame.encoding === "base64") {
514
874
  if (frame.data) frame.dataBytes = Buffer.from(frame.data, "base64");
515
875
  if (frame.stdout) frame.stdoutBytes = Buffer.from(frame.stdout, "base64");
@@ -522,8 +882,8 @@ var VM = class {
522
882
  if (req.stdin !== void 0) {
523
883
  throw new Error("stdin is not supported by execBuffered; use exec() instead");
524
884
  }
525
- const { path: path2, body } = buildExecPath(this.hostname, req, true);
526
- const raw = await this.transport.requestRaw("POST", path2, body);
885
+ const { path: path3, body } = buildExecPath(this.hostname, req, true);
886
+ const raw = await this.transport.requestRaw("POST", path3, body);
527
887
  const text = raw.toString("utf8");
528
888
  const parsed = text ? JSON.parse(text) : {};
529
889
  const common = {
@@ -749,6 +1109,7 @@ var SlicerClient = class _SlicerClient {
749
1109
 
750
1110
  exports.ExecStdioBase64 = ExecStdioBase64;
751
1111
  exports.ExecStdioText = ExecStdioText;
1112
+ exports.Forwarder = Forwarder;
752
1113
  exports.GiB = GiB;
753
1114
  exports.HostGroupsAPI = HostGroupsAPI;
754
1115
  exports.MiB = MiB;
@@ -760,6 +1121,7 @@ exports.SlicerClient = SlicerClient;
760
1121
  exports.VM = VM;
761
1122
  exports.VMFileSystem = VMFileSystem;
762
1123
  exports.VMsAPI = VMsAPI;
1124
+ exports.parseAddressMapping = parseAddressMapping;
763
1125
  exports.resolveTransport = resolveTransport;
764
1126
  //# sourceMappingURL=index.cjs.map
765
1127
  //# sourceMappingURL=index.cjs.map