@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 +54 -0
- package/dist/dashboard.js +8 -0
- package/dist/doctor.js +233 -0
- package/dist/main.js +248 -27
- package/dist/pair.js +83 -0
- package/package.json +3 -1
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}[0m`;
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
|
558
|
-
|
|
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
|
-
|
|
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
|
|
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}[0m` : 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.
|
|
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",
|