@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 +8 -0
- package/dist/main.js +123 -18
- package/package.json +1 -1
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
|
562
|
-
|
|
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.
|
|
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",
|