@sealant/mend 0.5.0 → 0.7.1

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/README.md CHANGED
@@ -17,6 +17,58 @@ diffs, reveal whitespace, select line ranges, add line or whole-change comments,
17
17
  evidence, and draft a follow-up for the same session. Press `y` to deliver a pending follow-up and
18
18
  relaunch that session, or `o` to continue the review in the web app.
19
19
 
20
+ ## Getting started
21
+
22
+ Once per machine, in this order:
23
+
24
+ ```sh
25
+ mend login # sign in to the server; the token is saved 0600
26
+ mend connect codex # send this machine's codex (or claude, github) credential
27
+ mend adopt # adopt the repository you are standing in
28
+ mend codex "fix the flaky test" # new session worktree, harness running in it
29
+ mend pair # hand a phone the same server
30
+ mend doctor # every fact above, on one screen
31
+ ```
32
+
33
+ `mend help` prints the same sequence under `start`, then everything else.
34
+
35
+ ### mend pair
36
+
37
+ ```
38
+ mend pair [--url <base url>]
39
+ ```
40
+
41
+ Asks the server for a pairing code and prints it three ways: a QR of the `mend://pair` deep link,
42
+ the code grouped as `ABCD-EFGH`, and the base URL the device should reach — the machine's tailnet
43
+ address when it has one, otherwise a LAN address (`--url` overrides the choice). Scan it in the Mend
44
+ app, or type the URL and the code in by hand. The code is single use and expires in 10 minutes; the
45
+ device's own token is minted when it claims the code, and can be revoked from the Mend app later.
46
+ The device names itself when it claims the code.
47
+
48
+ ### mend doctor
49
+
50
+ ```
51
+ mend doctor
52
+ ```
53
+
54
+ Reads, and changes nothing: server reachable, token accepted, the Sealant connection, each connected
55
+ account, adopted projects, the `claude` / `codex` / `gh` CLIs on PATH and whether their credentials
56
+ exist on this machine, and the tailnet address. One line per fact — `✓` observed, `○` not set up
57
+ yet, `✗` a blocker — and every line that needs an action ends with the one command that takes it:
58
+
59
+ ```
60
+ ✓ server http://localhost:3105 · mend 0.5.0
61
+ ✓ signed in token accepted
62
+ ✓ sealant connected · http://127.0.0.1:4000
63
+ ✓ claude connected · you@example.com
64
+ ○ codex not connected → mend connect codex
65
+ ✓ projects 2 adopted
66
+ ○ gh cli on PATH · no credential here → gh auth login
67
+ ○ tailnet not detected
68
+ ```
69
+
70
+ It exits 1 when a `✗` is printed, so a setup script can gate on it. No request waits longer than 3s.
71
+
20
72
  ## Commands
21
73
 
22
74
  ```
@@ -29,6 +81,8 @@ mend resume [session-id] [--with h] rejoin a settled session (state restored;
29
81
  mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
30
82
  mend sessions [--all] [--project p] [--json]
31
83
  mend status active sessions (alias of mend sessions)
84
+ mend pair [--url <base url>] pair a phone or a second machine: QR + code + URL
85
+ mend doctor read-only checklist of this machine's setup
32
86
  ```
33
87
 
34
88
  ## Signing in
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/doctor.js ADDED
@@ -0,0 +1,233 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * `mend doctor`: one read-only pass over everything a first run depends on, printed
5
+ * as mono status lines (DESIGN.md §4 — a mark plus a word, never a badge). Every
6
+ * line states what was observed; a line that needs an action ends with the single
7
+ * command that takes it. Nothing here writes, launches, or repairs anything.
8
+ *
9
+ * No request waits longer than 3s: a doctor that hangs is worse than a doctor that
10
+ * reports "not checked".
11
+ */
12
+ const TIMEOUT_MS = 3_000;
13
+ const MARKS = { ok: "✓", todo: "○", failed: "✗" };
14
+ const LABEL_WIDTH = 11;
15
+ /** One status line. The mark is painted by the caller so the formatter stays testable. */
16
+ export const formatCheck = (check, paint = (_state, mark) => mark) => `${paint(check.state, MARKS[check.state])} ${check.label.padEnd(LABEL_WIDTH)} ${check.detail}${check.fix === null ? "" : ` → ${check.fix}`}`;
17
+ /** A read that never throws and never blocks: the outcome is a value the checklist can print. */
18
+ const getJson = async (config, route) => {
19
+ try {
20
+ const response = await fetch(`${config.url}/api${route}`, {
21
+ headers: config.token === null ? {} : { authorization: `Bearer ${config.token}` },
22
+ signal: AbortSignal.timeout(TIMEOUT_MS),
23
+ });
24
+ if (!response.ok)
25
+ return { value: null, status: response.status };
26
+ return { value: (await response.json()), status: response.status };
27
+ }
28
+ catch {
29
+ return { value: null, status: null };
30
+ }
31
+ };
32
+ /** Where each provider's own CLI writes the credential Mend forwards (mirrors `mend connect`). */
33
+ const LOGIN_COMMANDS = {
34
+ claude: "claude setup-token",
35
+ codex: "codex login",
36
+ github: "gh auth login",
37
+ };
38
+ const HARNESS_CLIS = [
39
+ { command: "claude", provider: "claude" },
40
+ { command: "codex", provider: "codex" },
41
+ { command: "gh", provider: "github" },
42
+ ];
43
+ const notChecked = (label) => ({
44
+ label,
45
+ state: "todo",
46
+ detail: "not checked",
47
+ fix: null,
48
+ });
49
+ const identityOf = (account) => {
50
+ const meta = account.metadata;
51
+ for (const key of ["login", "email", "accountEmail", "accountId"]) {
52
+ const value = meta[key];
53
+ if (typeof value === "string" && value !== "")
54
+ return value;
55
+ }
56
+ return null;
57
+ };
58
+ /** Every fact the checklist prints, in the order a first run needs them. */
59
+ export const runChecks = async (config, probes) => {
60
+ const checks = [];
61
+ const health = await getJson(config, "/health");
62
+ checks.push(health.value === null
63
+ ? {
64
+ label: "server",
65
+ state: "failed",
66
+ detail: `cannot reach ${config.url}`,
67
+ fix: "start the Mend server",
68
+ }
69
+ : {
70
+ label: "server",
71
+ state: "ok",
72
+ detail: `${config.url} · mend ${health.value.version}`,
73
+ fix: null,
74
+ });
75
+ // Projects double as the cheapest authenticated read there is: it proves the token
76
+ // without asking the platform anything.
77
+ const projects = health.value !== null && config.token !== null
78
+ ? await getJson(config, "/projects")
79
+ : null;
80
+ if (health.value === null)
81
+ checks.push(notChecked("signed in"));
82
+ else if (config.token === null) {
83
+ checks.push({
84
+ label: "signed in",
85
+ state: "failed",
86
+ detail: "no token saved",
87
+ fix: "mend login",
88
+ });
89
+ }
90
+ else if (projects !== null && projects.value !== null) {
91
+ checks.push({ label: "signed in", state: "ok", detail: "token accepted", fix: null });
92
+ }
93
+ else {
94
+ const status = projects === null ? null : projects.status;
95
+ checks.push(status === 401 || status === 403
96
+ ? { label: "signed in", state: "failed", detail: "token rejected", fix: "mend login" }
97
+ : {
98
+ label: "signed in",
99
+ state: "failed",
100
+ detail: `GET /projects → ${status ?? "no answer"}`,
101
+ fix: null,
102
+ });
103
+ }
104
+ const signedIn = projects !== null && projects.value !== null;
105
+ const connection = signedIn ? await getJson(config, "/sealant/connection") : null;
106
+ if (connection === null)
107
+ checks.push(notChecked("sealant"));
108
+ else if (connection.value === null) {
109
+ // 404 is a server older than the endpoint: nothing was observed, so nothing is claimed.
110
+ checks.push(connection.status === 404
111
+ ? notChecked("sealant")
112
+ : {
113
+ label: "sealant",
114
+ state: "failed",
115
+ detail: `GET /sealant/connection → ${connection.status ?? "no answer"}`,
116
+ fix: null,
117
+ });
118
+ }
119
+ else if (connection.value.status === "connected") {
120
+ checks.push({
121
+ label: "sealant",
122
+ state: "ok",
123
+ detail: `connected · ${connection.value.baseUrl}`,
124
+ fix: null,
125
+ });
126
+ }
127
+ else {
128
+ checks.push({
129
+ label: "sealant",
130
+ state: "failed",
131
+ detail: `${connection.value.status} · ${connection.value.detail ?? connection.value.baseUrl}`,
132
+ fix: null,
133
+ });
134
+ }
135
+ const identity = signedIn ? await getJson(config, "/me/sealant") : null;
136
+ const platform = identity === null ? null : identity.value;
137
+ for (const { provider } of HARNESS_CLIS) {
138
+ if (platform === null) {
139
+ checks.push(notChecked(provider));
140
+ continue;
141
+ }
142
+ const account = platform.accounts.find((row) => row.provider === provider && row.name === "default") ??
143
+ platform.accounts.find((row) => row.provider === provider);
144
+ if (account === undefined) {
145
+ checks.push({
146
+ label: provider,
147
+ state: "todo",
148
+ detail: "not connected",
149
+ fix: `mend connect ${provider}`,
150
+ });
151
+ continue;
152
+ }
153
+ const who = identityOf(account);
154
+ checks.push(account.status === "active"
155
+ ? {
156
+ label: provider,
157
+ state: "ok",
158
+ detail: who === null ? "connected" : `connected · ${who}`,
159
+ fix: null,
160
+ }
161
+ : {
162
+ label: provider,
163
+ state: "todo",
164
+ detail: account.status,
165
+ fix: `mend connect ${provider}`,
166
+ });
167
+ }
168
+ const adopted = projects === null ? null : projects.value;
169
+ if (adopted === null)
170
+ checks.push(notChecked("projects"));
171
+ else if (adopted.length === 0) {
172
+ checks.push({ label: "projects", state: "todo", detail: "none adopted", fix: "mend adopt" });
173
+ }
174
+ else {
175
+ checks.push({ label: "projects", state: "ok", detail: `${adopted.length} adopted`, fix: null });
176
+ }
177
+ for (const { command, provider } of HARNESS_CLIS) {
178
+ const label = `${command} cli`;
179
+ if (!probes.onPath(command)) {
180
+ checks.push({ label, state: "todo", detail: "not on PATH", fix: null });
181
+ continue;
182
+ }
183
+ const credential = probes.localCredential(provider);
184
+ checks.push(credential === null
185
+ ? {
186
+ label,
187
+ state: "todo",
188
+ detail: "on PATH · no credential here",
189
+ fix: LOGIN_COMMANDS[provider],
190
+ }
191
+ : { label, state: "ok", detail: "on PATH · credential present", fix: null });
192
+ }
193
+ const machine = signedIn ? await getJson(config, "/machine") : null;
194
+ const tailnet = machine === null || machine.value === null ? null : machine.value.tailnet;
195
+ if (tailnet === null)
196
+ checks.push(notChecked("tailnet"));
197
+ else if (tailnet.status === "reachable" && tailnet.address !== null) {
198
+ checks.push({ label: "tailnet", state: "ok", detail: tailnet.address, fix: null });
199
+ }
200
+ else {
201
+ checks.push({ label: "tailnet", state: "todo", detail: "not detected", fix: null });
202
+ }
203
+ return checks;
204
+ };
205
+ /** An executable of that name on PATH — the question `command -v` asks, without a subprocess. */
206
+ export const onPath = (command) => {
207
+ for (const directory of (process.env["PATH"] ?? "").split(path.delimiter)) {
208
+ if (directory === "")
209
+ continue;
210
+ try {
211
+ fs.accessSync(path.join(directory, command), fs.constants.X_OK);
212
+ return true;
213
+ }
214
+ catch {
215
+ // keep looking
216
+ }
217
+ }
218
+ return false;
219
+ };
220
+ /** Green observed · amber not started · red a blocker (DESIGN.md §4); plain text on a pipe. */
221
+ const paintMark = (state, mark) => {
222
+ if (process.stdout.isTTY !== true)
223
+ return mark;
224
+ const code = state === "ok" ? "32" : state === "todo" ? "33" : "31";
225
+ return `[${code}m${mark}`;
226
+ };
227
+ export const doctorCommand = async (config, localCredential) => {
228
+ const checks = await runChecks(config, { localCredential, onPath });
229
+ for (const check of checks)
230
+ process.stdout.write(`${formatCheck(check, paintMark)}\n`);
231
+ if (checks.some((check) => check.state === "failed"))
232
+ process.exitCode = 1;
233
+ };
package/dist/main.js CHANGED
@@ -5,8 +5,10 @@ import { createRequire } from "node:module";
5
5
  import * as net from "node:net";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { doctorCommand } from "./doctor.js";
8
9
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
10
  import { formatLoadReport } from "./env.js";
11
+ import { pairCommand, qrCommand } from "./pair.js";
10
12
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
11
13
  import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
12
14
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
@@ -134,6 +136,8 @@ const api = async (config, method, route, body) => {
134
136
  return fail(error instanceof Error ? error.message : String(error));
135
137
  }
136
138
  };
139
+ /** The same call bound to one config — the dependency ./pair.ts takes. */
140
+ const boundApi = (config) => (method, route, body) => api(config, method, route, body);
137
141
  /** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
138
142
  const withSpinner = async (label, work) => {
139
143
  if (process.stdout.isTTY !== true)
@@ -529,17 +533,39 @@ const flattenService = (view) => {
529
533
  const fetchServiceViews = (config, all = false) => api(config, "GET", `/services${all ? "?all=1" : ""}`);
530
534
  const fetchServices = async (config, all = false) => (await fetchServiceViews(config, all)).map(flattenService);
531
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
+ };
532
541
  const serviceUrl = (service) => service.browserUrl ??
533
542
  (service.authority === null
534
543
  ? "unbound"
535
544
  : `${service.authority}${service.protocol === "udp" ? " (udp)" : ""}`);
536
- const serviceExposure = (service) => {
537
- if (service.exposureScope === null)
538
- return "";
539
- if (service.exposureScope === "private") {
540
- 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}`)}`);
541
568
  }
542
- return `Loopback only · Mend auth: ${service.mendAuthentication ?? "unknown"}`;
543
569
  };
544
570
  const printWorkspaceTtlFailure = (service) => {
545
571
  if (service.workspaceTtlRenewalError === null)
@@ -547,15 +573,17 @@ const printWorkspaceTtlFailure = (service) => {
547
573
  say(amber(` workspace TTL renewal failed · ${service.workspaceTtlRenewalError}`));
548
574
  say(dim(` last renewed ${service.workspaceTtlRenewedAt ?? "unknown"} · known expiry ${service.workspaceExpiresAt ?? "unknown"} · failed ${service.workspaceTtlRenewalFailedAt ?? "unknown"}`));
549
575
  };
550
- const printServiceEndpoint = (service) => {
551
- const exposure = serviceExposure(service);
552
- say(` ${cobalt(serviceUrl(service))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
576
+ const printServiceEndpoint = (config, service) => {
577
+ printServiceAccess(config, service);
553
578
  printWorkspaceTtlFailure(service);
554
579
  };
555
- const printService = (service) => {
580
+ const printService = (config, service) => {
556
581
  const status = service.status === "reachable" ? green(service.status) : amber(service.status);
557
- const exposure = serviceExposure(service);
558
- 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);
559
587
  printWorkspaceTtlFailure(service);
560
588
  };
561
589
  /**
@@ -593,7 +621,7 @@ const serviceAdd = async (config, args) => {
593
621
  browserScheme,
594
622
  });
595
623
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
596
- printServiceEndpoint(service);
624
+ printServiceEndpoint(config, service);
597
625
  if (protocol === "udp") {
598
626
  say(dim(` udp — a reply is the only reachability signal; silence just relays`));
599
627
  }
@@ -608,7 +636,7 @@ const serviceList = async (config) => {
608
636
  return;
609
637
  }
610
638
  for (const service of services)
611
- printService(service);
639
+ printService(config, service);
612
640
  };
613
641
  const serviceStop = async (config, args) => {
614
642
  const needle = args.find((a) => !a.startsWith("--"));
@@ -671,7 +699,7 @@ const serviceRun = async (config, args) => {
671
699
  name: recipe.name,
672
700
  }));
673
701
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
674
- printServiceEndpoint(service);
702
+ printServiceEndpoint(config, service);
675
703
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
676
704
  return;
677
705
  }
@@ -705,7 +733,7 @@ const serviceRun = async (config, args) => {
705
733
  browserScheme,
706
734
  }));
707
735
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
708
- printServiceEndpoint(service);
736
+ printServiceEndpoint(config, service);
709
737
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
710
738
  };
711
739
  /** Read sequence-addressed PTY output without attaching an input-capable terminal. */
@@ -774,7 +802,7 @@ const serviceRestart = async (config, args) => {
774
802
  const service = await findLiveService(config, needle);
775
803
  const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, mutateService(config, "POST", `/services/${service.id}/restart`));
776
804
  say(`${green("✓")} restarted · ${restarted.status}`);
777
- printServiceEndpoint(restarted);
805
+ printServiceEndpoint(config, restarted);
778
806
  };
779
807
  /**
780
808
  * Scaffold mend.toml from the project's own manifests. Static suggestion,
@@ -852,6 +880,82 @@ const serviceInit = async (args) => {
852
880
  fs.writeFileSync(target, toml);
853
881
  say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
854
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
+ };
855
959
  const serviceCommand = async (config, args) => {
856
960
  const [verb, ...rest] = args;
857
961
  switch (verb) {
@@ -861,6 +965,8 @@ const serviceCommand = async (config, args) => {
861
965
  return serviceAdd(config, rest);
862
966
  case "init":
863
967
  return serviceInit(rest);
968
+ case "connect":
969
+ return serviceConnect(config, rest);
864
970
  case "list":
865
971
  case undefined:
866
972
  return serviceList(config);
@@ -877,6 +983,92 @@ const serviceCommand = async (config, args) => {
877
983
  return serviceRun(config, [verb, ...rest]);
878
984
  }
879
985
  };
986
+ const isProvider = (value) => value === "claude" || value === "codex" || value === "github";
987
+ const readIfExists = (file) => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
988
+ /** The credential as THIS machine holds it — the same files the agent CLIs wrote at login. */
989
+ const localCredential = (provider) => {
990
+ const home = os.homedir();
991
+ switch (provider) {
992
+ case "codex":
993
+ return readIfExists(path.join(process.env["CODEX_HOME"] ?? path.join(home, ".codex"), "auth.json"));
994
+ case "claude":
995
+ return readIfExists(path.join(process.env["CLAUDE_CONFIG_DIR"] ?? path.join(home, ".claude"), ".credentials.json"));
996
+ case "github": {
997
+ const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
998
+ const token = result.status === 0 ? result.stdout.trim() : "";
999
+ return token === "" ? null : token;
1000
+ }
1001
+ }
1002
+ };
1003
+ const accountLine = (account) => {
1004
+ const meta = account.metadata;
1005
+ const pick = (key) => (typeof meta[key] === "string" ? String(meta[key]) : null);
1006
+ const identity = pick("login") ?? pick("email") ?? pick("accountEmail") ?? pick("accountId");
1007
+ const suffix = pick("tokenSuffix");
1008
+ const facts = [
1009
+ account.status === "active" ? "connected" : account.status,
1010
+ identity,
1011
+ suffix === null ? null : `…${suffix}`,
1012
+ `since ${account.connectedAt.slice(0, 10)}`,
1013
+ ].filter((fact) => fact !== null);
1014
+ return `${account.provider.padEnd(8)} ${facts.join(" · ")}`;
1015
+ };
1016
+ /**
1017
+ * `mend accounts`: the signed-in user's own connected accounts on the platform — each person's
1018
+ * subscriptions, under their own Sealant user (docs/SEALANT-IDENTITY.md).
1019
+ */
1020
+ const accountsCommand = async (config) => {
1021
+ const identity = await api(config, "GET", "/me/sealant");
1022
+ process.stdout.write(`platform user ${identity.sealantUserId}\n`);
1023
+ const providers = ["claude", "codex", "github"];
1024
+ for (const provider of providers) {
1025
+ const account = identity.accounts.find((row) => row.provider === provider && row.name === "default") ??
1026
+ identity.accounts.find((row) => row.provider === provider);
1027
+ process.stdout.write(` ${account === undefined ? `${provider.padEnd(8)} not connected` : accountLine(account)}\n`);
1028
+ }
1029
+ };
1030
+ /**
1031
+ * `mend connect claude|codex|github [--from-stdin] [--remove]`: send THIS machine's credential
1032
+ * for the provider to the platform under your own user. The file the provider's CLI wrote at
1033
+ * login is read verbatim (codex: ~/.codex/auth.json; claude: ~/.claude/.credentials.json;
1034
+ * github: `gh auth token`); `--from-stdin` takes a pasted token or file instead. Mend forwards
1035
+ * it once and stores nothing.
1036
+ */
1037
+ const connectCommand = async (config, args) => {
1038
+ const [providerArg, ...flags] = args;
1039
+ if (!isProvider(providerArg)) {
1040
+ return fail("usage: mend connect claude|codex|github [--from-stdin] [--remove]");
1041
+ }
1042
+ const provider = providerArg;
1043
+ if (flags.includes("--remove")) {
1044
+ const identity = await api(config, "GET", "/me/sealant");
1045
+ const account = identity.accounts.find((row) => row.provider === provider);
1046
+ if (account === undefined)
1047
+ return fail(`${provider}: nothing connected`);
1048
+ await api(config, "DELETE", `/me/sealant/accounts/${account.id}`);
1049
+ process.stdout.write(`${provider}: disconnected\n`);
1050
+ return;
1051
+ }
1052
+ let secret;
1053
+ if (flags.includes("--from-stdin")) {
1054
+ secret = fs.readFileSync(0, "utf8").trim();
1055
+ if (secret === "")
1056
+ return fail("nothing on stdin");
1057
+ }
1058
+ else {
1059
+ secret = localCredential(provider);
1060
+ if (secret === null) {
1061
+ const where = provider === "github"
1062
+ ? "`gh auth login` first, or pipe a token: gh auth token | mend connect github --from-stdin"
1063
+ : provider === "codex"
1064
+ ? "`codex login` first, or: mend connect codex --from-stdin < auth.json"
1065
+ : "`claude setup-token` then: mend connect claude --from-stdin";
1066
+ return fail(`${provider}: no credential on this machine — ${where}`);
1067
+ }
1068
+ }
1069
+ const account = await withSpinner(`connecting ${provider}`, api(config, "POST", "/me/sealant/accounts", { provider, secret }));
1070
+ process.stdout.write(`${accountLine(account)}\n`);
1071
+ };
880
1072
  // ─── login: obtain and save the bearer token ────────────────────────────────
881
1073
  const takeFlagValue = (args, flag) => {
882
1074
  const at = args.indexOf(flag);
@@ -1333,6 +1525,9 @@ _mend() {
1333
1525
  'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
1334
1526
  'service:reachable ports — add, list, stop'
1335
1527
  'keys:the machine Mend deploy key — init, show, share'
1528
+ 'accounts:your connected accounts on the platform'
1529
+ 'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
1530
+ 'connect:send this machine'"'"'s claude/codex/github credential to the platform'
1336
1531
  'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
1337
1532
  'rejoin:attach if live, otherwise resume'
1338
1533
  'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
@@ -1355,7 +1550,7 @@ _mend "$@"
1355
1550
  const BASH_COMPLETIONS = `_mend() {
1356
1551
  local cur=\${COMP_WORDS[COMP_CWORD]}
1357
1552
  if [ "$COMP_CWORD" -eq 1 ]; then
1358
- COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
1553
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys pair doctor continue resume rejoin projects sessions status ui help" -- "$cur") )
1359
1554
  return
1360
1555
  fi
1361
1556
  case \${COMP_WORDS[1]} in
@@ -1769,17 +1964,34 @@ const dashboard = async (config) => {
1769
1964
  config,
1770
1965
  cwd: process.cwd(),
1771
1966
  api: (method, route, body) => request(config, method, route, body),
1772
- attachTty: (sessionId, harness) => attachTty(config, sessionId, harness, 0n),
1967
+ attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
1773
1968
  });
1774
1969
  };
1775
1970
  // ─── entry ──────────────────────────────────────────────────────────────────
1776
1971
  const HELP = `mend — the agent workbench
1777
1972
 
1778
- mend the dashboard: every project and session, live
1973
+ start
1974
+ mend login [--url <server>] sign in with email + password; saves the token (0600)
1975
+ mend connect <provider> [--from-stdin] [--remove]
1976
+ send THIS machine's claude/codex/github credential to the
1977
+ platform under your own user (reads the file the provider's
1978
+ CLI wrote at login; --from-stdin pastes one instead)
1779
1979
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
1780
1980
  adopt a repository into the store (default: cwd; any git
1781
1981
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
1782
- mend login [--url <server>] sign in with email + password; saves the token (0600)
1982
+ mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
1983
+ [--base <ref>] [--ask] [--fast]
1984
+ new session worktree + launch the harness in it; a quoted
1985
+ prompt becomes its first message (and names the session),
1986
+ --ask restores the harness's permission prompts, --fast
1987
+ requests priority processing (codex service tier)
1988
+ mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
1989
+ the URL to reach this server (one device, once, 10 minutes)
1990
+ mend doctor read-only checklist of this machine's setup — one line per
1991
+ fact, each unfinished one ending in the command that fixes it
1992
+
1993
+ everything else
1994
+ mend the dashboard: every project and session, live
1783
1995
  mend logout forget the saved token
1784
1996
  mend keys init generate the machine's Mend deploy key (ed25519)
1785
1997
  mend keys show print the public key — add it as a deploy key on your git host
@@ -1787,16 +1999,11 @@ const HELP = `mend — the agent workbench
1787
1999
  secret-shaped names → secrets; --secret sends all (or the
1788
2000
  named ones, e.g. DATABASE_URL) to secrets
1789
2001
  mend env show what the project store holds — names only, never values
2002
+ mend accounts your connected accounts on the platform (claude, codex, github)
1790
2003
  mend dotfiles your dotfiles on the server: repo + synced home files
1791
2004
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1792
2005
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
1793
2006
  hardware keys sign here; Ctrl-C stops sharing)
1794
- mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
1795
- [--base <ref>] [--ask] [--fast]
1796
- new session worktree + launch the harness in it; a quoted
1797
- prompt becomes its first message (and names the session),
1798
- --ask restores the harness's permission prompts, --fast
1799
- requests priority processing (codex service tier)
1800
2007
  mend run -- <command...> same, with an arbitrary command
1801
2008
  mend attach <session-id-prefix> reattach this terminal to a running session
1802
2009
  mend shell [session-id-prefix] open a shell in a live session's workspace
@@ -1807,6 +2014,9 @@ const HELP = `mend — the agent workbench
1807
2014
  mend service init [--yes] scaffold mend.toml from package.json + compose ports
1808
2015
  mend service add [session] <port> [--name <n>] [--udp]
1809
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
1810
2020
  mend service list every live service and its observed state
1811
2021
  mend service logs <name-or-id> follow a supervised service's output (replay, then live)
1812
2022
  mend service restart <name-or-id> re-run its recorded command — same URL
@@ -1849,6 +2059,17 @@ const main = async () => {
1849
2059
  return keysCommand(config, rest);
1850
2060
  case "dotfiles":
1851
2061
  return dotfilesCommand(config, rest);
2062
+ case "accounts":
2063
+ return accountsCommand(config);
2064
+ case "connect":
2065
+ return connectCommand(config, rest);
2066
+ case "pair":
2067
+ return pairCommand(rest, boundApi(config));
2068
+ // Deliberately absent from HELP: the installer renders its own QR through this.
2069
+ case "qr":
2070
+ return qrCommand(rest);
2071
+ case "doctor":
2072
+ return doctorCommand(config, localCredential);
1852
2073
  case "env":
1853
2074
  return envCommand(config, rest);
1854
2075
  case "completions":
package/dist/pair.js ADDED
@@ -0,0 +1,83 @@
1
+ const paint = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}` : text;
2
+ const dim = paint("2");
3
+ const green = paint("32");
4
+ const say = (line) => process.stdout.write(`${line}\n`);
5
+ const fail = (message) => {
6
+ process.stderr.write(`mend: ${message}\n`);
7
+ process.exit(1);
8
+ };
9
+ const takeFlagValue = (args, flag) => {
10
+ const at = args.indexOf(flag);
11
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
12
+ };
13
+ /**
14
+ * The code as a human reads it aloud: 8 Crockford characters in two groups. The
15
+ * server compares case-insensitively with dashes stripped, so the dash is purely
16
+ * for the eye and either form can be typed back.
17
+ */
18
+ export const groupCode = (code) => {
19
+ const bare = code.replace(/[^0-9a-z]/gi, "").toUpperCase();
20
+ return bare.length <= 4 ? bare : `${bare.slice(0, 4)}-${bare.slice(4)}`;
21
+ };
22
+ /** The scanned string. The device reads the base URL and the code out of it, nothing else. */
23
+ export const pairingLink = (url, code) => `mend://pair?u=${encodeURIComponent(url)}&c=${code}`;
24
+ /**
25
+ * Tailscale hands every node an IPv4 in the CGNAT range 100.64.0.0/10 — the same
26
+ * observation packages/api/src/machine.ts makes. A tailnet URL is preferred because
27
+ * it keeps working when the phone leaves the LAN.
28
+ */
29
+ const isTailnetUrl = (value) => {
30
+ let host;
31
+ try {
32
+ host = new URL(value).hostname;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ const parts = host.split(".").map(Number);
38
+ const [first, second] = parts;
39
+ if (parts.length !== 4 || first === undefined || second === undefined)
40
+ return false;
41
+ return first === 100 && second >= 64 && second <= 127;
42
+ };
43
+ /** `--url` wins; otherwise the tailnet address, otherwise the first candidate the server offered. */
44
+ export const chooseUrl = (urls, override) => override ?? urls.find(isTailnetUrl) ?? urls[0] ?? null;
45
+ /** Whole minutes left, floored at 0; null when the server sent a date this CLI cannot read. */
46
+ export const minutesUntil = (expiresAt, now = Date.now()) => {
47
+ const at = Date.parse(expiresAt);
48
+ if (Number.isNaN(at))
49
+ return null;
50
+ return Math.max(0, Math.round((at - now) / 60_000));
51
+ };
52
+ /** The QR as block characters. Deliberately not gated on isTTY — a piped QR still scans. */
53
+ export const renderQr = async (text) => {
54
+ const { toString: toQrString } = await import("qrcode");
55
+ const options = { type: "terminal", small: true };
56
+ return (await toQrString(text, options)).trimEnd();
57
+ };
58
+ /**
59
+ * `mend pair [--url <base url>]`: mint a pairing code and show it. The device names
60
+ * itself when it claims the code, so this side sends nothing but the request.
61
+ */
62
+ export const pairCommand = async (args, api) => {
63
+ const pairing = await api("POST", "/me/devices/pairings");
64
+ const url = chooseUrl(pairing.urls, takeFlagValue(args, "--url"));
65
+ if (url === null) {
66
+ return fail("no LAN or tailnet address to hand the device — pass one: mend pair --url http://<host>:<port>");
67
+ }
68
+ const minutes = minutesUntil(pairing.expiresAt);
69
+ say(await renderQr(pairingLink(url, pairing.code)));
70
+ say(`${green("✓")} pairing code ${groupCode(pairing.code)}`);
71
+ say(` ${dim("url")} ${url}`);
72
+ say(` ${dim("expires")} ${minutes === null ? pairing.expiresAt : `in ${minutes} min`} ${dim("· one device, once")}`);
73
+ say("");
74
+ say(dim(" scan it in the Mend app, or enter the url and the code there by hand"));
75
+ say(dim(" the app is apps/mobile — build it yourself; it is not published yet"));
76
+ };
77
+ /** `mend qr <text>`: print a terminal QR of anything. Hidden from help; the installer uses it. */
78
+ export const qrCommand = async (args) => {
79
+ const text = args.join(" ").trim();
80
+ if (text === "")
81
+ return fail("usage: mend qr <text>");
82
+ say(await renderQr(text));
83
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.5.0",
3
+ "version": "0.7.1",
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",
@@ -32,11 +32,13 @@
32
32
  "@opentui/core": "^0.5.1",
33
33
  "@opentui/react": "^0.5.1",
34
34
  "@tanstack/react-query": "^5.101.4",
35
+ "qrcode": "^1.5.4",
35
36
  "react": "19.2.3"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@effect/vitest": "4.0.0-beta.98",
39
40
  "@types/node": "^22.10.2",
41
+ "@types/qrcode": "^1.5.5",
40
42
  "@types/react": "^19.2.0",
41
43
  "typescript": "^5.7.2",
42
44
  "vitest": "^3.0.5",