@sealant/mend 0.6.0 → 0.8.0

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/dashboard.js CHANGED
@@ -261,6 +261,14 @@ const App = ({ ctx, onQuit }) => {
261
261
  let outcome;
262
262
  try {
263
263
  outcome = await ctx.attachTty(session.id, session.harness);
264
+ if (outcome === "unavailable") {
265
+ // A live session whose terminal ended (idle: a workspace held open,
266
+ // no PTY behind it). Enter still means "get me in" — open a fresh
267
+ // shell in the same workspace and attach to that.
268
+ const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
269
+ process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
270
+ outcome = await ctx.attachTty(session.id, "shell", shell.id);
271
+ }
264
272
  }
265
273
  catch (error) {
266
274
  say(error instanceof Error ? error.message : String(error));
package/dist/main.js CHANGED
@@ -533,17 +533,39 @@ const flattenService = (view) => {
533
533
  const fetchServiceViews = (config, all = false) => api(config, "GET", `/services${all ? "?all=1" : ""}`);
534
534
  const fetchServices = async (config, all = false) => (await fetchServiceViews(config, all)).map(flattenService);
535
535
  const mutateService = async (config, method, endpointPath, body) => flattenService(await api(config, method, endpointPath, body));
536
+ /** Is the configured server this machine? Only then is its bind authority OUR address. */
537
+ const serverIsLocal = (config) => {
538
+ const host = parseMendUrl(config.url).hostname;
539
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
540
+ };
536
541
  const serviceUrl = (service) => service.browserUrl ??
537
542
  (service.authority === null
538
543
  ? "unbound"
539
544
  : `${service.authority}${service.protocol === "udp" ? " (udp)" : ""}`);
540
- const serviceExposure = (service) => {
541
- if (service.exposureScope === null)
542
- return "";
543
- if (service.exposureScope === "private") {
544
- return "No Mend sign-in protects this port. Anyone who can reach this private address can connect.";
545
+ /**
546
+ * Where THIS terminal reaches the Service. On a local server the bind
547
+ * authority is our own address; on a remote one it is an address on the
548
+ * server's network — the honest client path is the authenticated tunnel.
549
+ */
550
+ const printServiceAccess = (config, service) => {
551
+ const gate = "no Mend sign-in on this port — network reach is the only gate";
552
+ if (serverIsLocal(config)) {
553
+ if (service.authority !== null)
554
+ say(` ${cobalt(serviceUrl(service))} ${dim(gate)}`);
555
+ return;
556
+ }
557
+ if (service.protocol === "udp") {
558
+ // No tunnel for UDP: the server-side listener is the only path.
559
+ if (service.authority !== null) {
560
+ say(` ${cobalt(serviceUrl(service))} ${dim(`on the server's network · ${gate}`)}`);
561
+ }
562
+ return;
563
+ }
564
+ const local = service.hostPort ?? service.workspacePort;
565
+ say(` ${cobalt(`mend service connect ${service.label}`)} ${dim(`→ 127.0.0.1:${local} on this machine, authenticated as you`)}`);
566
+ if (service.authority !== null) {
567
+ say(` ${dim(`server-side listener ${service.authority} · ${gate}`)}`);
545
568
  }
546
- return `Loopback only · Mend auth: ${service.mendAuthentication ?? "unknown"}`;
547
569
  };
548
570
  const printWorkspaceTtlFailure = (service) => {
549
571
  if (service.workspaceTtlRenewalError === null)
@@ -551,15 +573,17 @@ const printWorkspaceTtlFailure = (service) => {
551
573
  say(amber(` workspace TTL renewal failed · ${service.workspaceTtlRenewalError}`));
552
574
  say(dim(` last renewed ${service.workspaceTtlRenewedAt ?? "unknown"} · known expiry ${service.workspaceExpiresAt ?? "unknown"} · failed ${service.workspaceTtlRenewalFailedAt ?? "unknown"}`));
553
575
  };
554
- const printServiceEndpoint = (service) => {
555
- const exposure = serviceExposure(service);
556
- say(` ${cobalt(serviceUrl(service))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
576
+ const printServiceEndpoint = (config, service) => {
577
+ printServiceAccess(config, service);
557
578
  printWorkspaceTtlFailure(service);
558
579
  };
559
- const printService = (service) => {
580
+ const printService = (config, service) => {
560
581
  const status = service.status === "reachable" ? green(service.status) : amber(service.status);
561
- const exposure = serviceExposure(service);
562
- say(`${(service.label ?? service.id.slice(0, 8)).padEnd(12)} ${dim(`:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""} →`)} ${serviceUrl(service)} ${status} ${dim(service.id.slice(0, 8))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
582
+ const port = `:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""}`;
583
+ // Pad around the colored status by its bare length — ANSI codes break padEnd.
584
+ const statusPad = " ".repeat(Math.max(1, 12 - service.status.length));
585
+ say(`${(service.label ?? service.id.slice(0, 8)).padEnd(14)} ${status}${statusPad}${dim(port.padEnd(7))} ${dim(service.id.slice(0, 8))}`);
586
+ printServiceAccess(config, service);
563
587
  printWorkspaceTtlFailure(service);
564
588
  };
565
589
  /**
@@ -597,7 +621,7 @@ const serviceAdd = async (config, args) => {
597
621
  browserScheme,
598
622
  });
599
623
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
600
- printServiceEndpoint(service);
624
+ printServiceEndpoint(config, service);
601
625
  if (protocol === "udp") {
602
626
  say(dim(` udp — a reply is the only reachability signal; silence just relays`));
603
627
  }
@@ -612,7 +636,7 @@ const serviceList = async (config) => {
612
636
  return;
613
637
  }
614
638
  for (const service of services)
615
- printService(service);
639
+ printService(config, service);
616
640
  };
617
641
  const serviceStop = async (config, args) => {
618
642
  const needle = args.find((a) => !a.startsWith("--"));
@@ -675,7 +699,7 @@ const serviceRun = async (config, args) => {
675
699
  name: recipe.name,
676
700
  }));
677
701
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
678
- printServiceEndpoint(service);
702
+ printServiceEndpoint(config, service);
679
703
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
680
704
  return;
681
705
  }
@@ -709,7 +733,7 @@ const serviceRun = async (config, args) => {
709
733
  browserScheme,
710
734
  }));
711
735
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
712
- printServiceEndpoint(service);
736
+ printServiceEndpoint(config, service);
713
737
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
714
738
  };
715
739
  /** Read sequence-addressed PTY output without attaching an input-capable terminal. */
@@ -778,7 +802,7 @@ const serviceRestart = async (config, args) => {
778
802
  const service = await findLiveService(config, needle);
779
803
  const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, mutateService(config, "POST", `/services/${service.id}/restart`));
780
804
  say(`${green("✓")} restarted · ${restarted.status}`);
781
- printServiceEndpoint(restarted);
805
+ printServiceEndpoint(config, restarted);
782
806
  };
783
807
  /**
784
808
  * Scaffold mend.toml from the project's own manifests. Static suggestion,
@@ -856,6 +880,82 @@ const serviceInit = async (args) => {
856
880
  fs.writeFileSync(target, toml);
857
881
  say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
858
882
  };
883
+ /**
884
+ * `mend service connect [name…] [--port <n>]`: the location-independent data
885
+ * plane for Services. The server's own listener binds the SERVER's
886
+ * interfaces (`MEND_SERVICE_HOSTS`) — exactly right when the server is this
887
+ * machine, unreachable when it is a Pod or a VPS. This binds each Service's
888
+ * port on THIS machine's loopback instead and pumps every accepted
889
+ * connection over one authenticated WebSocket to the server, which dials the
890
+ * same workspace forward the listener uses. No ports are opened anywhere but
891
+ * here, and every connection carries the caller's Mend auth.
892
+ */
893
+ const serviceConnect = async (config, args) => {
894
+ const portFlag = args.indexOf("--port");
895
+ const portOverride = portFlag === -1 ? null : Number(args[portFlag + 1]);
896
+ if (portOverride !== null && !Number.isInteger(portOverride)) {
897
+ return fail("--port takes a port number");
898
+ }
899
+ const names = args.filter((a, i) => !a.startsWith("--") && i !== portFlag + 1);
900
+ const live = (await fetchServices(config)).filter((s) => s.protocol === "tcp");
901
+ const picked = names.length === 0
902
+ ? live
903
+ : live.filter((s) => names.some((n) => s.label === n || s.id.startsWith(n)));
904
+ if (picked.length === 0) {
905
+ return fail(names.length === 0
906
+ ? "no live TCP services — mend service run starts one"
907
+ : `no live TCP service matches "${names.join('", "')}"`);
908
+ }
909
+ if (portOverride !== null && picked.length !== 1) {
910
+ return fail("--port applies to exactly one service — name it");
911
+ }
912
+ const tunnelUrl = (serviceId) => {
913
+ const url = parseMendUrl(`${config.url}/api/service-tunnel`);
914
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
915
+ url.searchParams.set("service", serviceId);
916
+ if (config.token !== null)
917
+ url.searchParams.set("token", config.token);
918
+ return url;
919
+ };
920
+ for (const service of picked) {
921
+ const port = portOverride ?? service.hostPort ?? service.workspacePort;
922
+ const server = net.createServer((socket) => {
923
+ // Hold local bytes until the tunnel is open; loopback buffers are tiny.
924
+ socket.pause();
925
+ const ws = new WebSocket(tunnelUrl(service.id));
926
+ ws.binaryType = "arraybuffer";
927
+ ws.addEventListener("open", () => socket.resume(), { once: true });
928
+ ws.addEventListener("message", (event) => {
929
+ if (typeof event.data === "string")
930
+ return; // no text frames come down
931
+ socket.write(Buffer.from(event.data));
932
+ });
933
+ ws.addEventListener("close", () => socket.end(), { once: true });
934
+ ws.addEventListener("error", () => socket.destroy(), { once: true });
935
+ // Copy per chunk: the WS client wants an ArrayBuffer-backed view, and
936
+ // Buffer pools share their backing store.
937
+ socket.on("data", (chunk) => ws.send(new Uint8Array(chunk)));
938
+ socket.on("end", () => {
939
+ if (ws.readyState === WebSocket.OPEN)
940
+ ws.send(JSON.stringify({ t: "eof" }));
941
+ });
942
+ socket.on("close", () => ws.close());
943
+ socket.on("error", () => ws.close());
944
+ });
945
+ await new Promise((resolve, reject) => {
946
+ server.once("error", (error) => {
947
+ reject(new Error(error.code === "EADDRINUSE"
948
+ ? `127.0.0.1:${port} is already in use here — pick one with: mend service connect ${service.label} --port <n>`
949
+ : error.message));
950
+ });
951
+ server.listen(port, "127.0.0.1", () => resolve());
952
+ }).catch((error) => fail(error.message));
953
+ say(`${green("●")} ${service.label} → 127.0.0.1:${port} ${dim(`(tunnel to ${config.url})`)}`);
954
+ }
955
+ say(dim(" connections are authenticated as you · Ctrl-C stops"));
956
+ // The listeners keep the process alive until the user stops it.
957
+ await new Promise(() => { });
958
+ };
859
959
  const serviceCommand = async (config, args) => {
860
960
  const [verb, ...rest] = args;
861
961
  switch (verb) {
@@ -865,6 +965,8 @@ const serviceCommand = async (config, args) => {
865
965
  return serviceAdd(config, rest);
866
966
  case "init":
867
967
  return serviceInit(rest);
968
+ case "connect":
969
+ return serviceConnect(config, rest);
868
970
  case "list":
869
971
  case undefined:
870
972
  return serviceList(config);
@@ -1862,7 +1964,7 @@ const dashboard = async (config) => {
1862
1964
  config,
1863
1965
  cwd: process.cwd(),
1864
1966
  api: (method, route, body) => request(config, method, route, body),
1865
- attachTty: (sessionId, harness) => attachTty(config, sessionId, harness, 0n),
1967
+ attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
1866
1968
  });
1867
1969
  };
1868
1970
  // ─── entry ──────────────────────────────────────────────────────────────────
@@ -1912,6 +2014,9 @@ everything else
1912
2014
  mend service init [--yes] scaffold mend.toml from package.json + compose ports
1913
2015
  mend service add [session] <port> [--name <n>] [--udp]
1914
2016
  adopt a listening workspace port — reachable on this machine
2017
+ mend service connect [name…] [--port <p>]
2018
+ bring live Services to THIS machine's loopback — each
2019
+ connection tunnels through the server, authenticated as you
1915
2020
  mend service list every live service and its observed state
1916
2021
  mend service logs <name-or-id> follow a supervised service's output (replay, then live)
1917
2022
  mend service restart <name-or-id> re-run its recorded command — same URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",