@sealant/mend 0.5.0 → 0.6.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/README.md +54 -0
- package/dist/doctor.js +233 -0
- package/dist/main.js +125 -9
- 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/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)
|
|
@@ -877,6 +881,92 @@ const serviceCommand = async (config, args) => {
|
|
|
877
881
|
return serviceRun(config, [verb, ...rest]);
|
|
878
882
|
}
|
|
879
883
|
};
|
|
884
|
+
const isProvider = (value) => value === "claude" || value === "codex" || value === "github";
|
|
885
|
+
const readIfExists = (file) => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
|
|
886
|
+
/** The credential as THIS machine holds it — the same files the agent CLIs wrote at login. */
|
|
887
|
+
const localCredential = (provider) => {
|
|
888
|
+
const home = os.homedir();
|
|
889
|
+
switch (provider) {
|
|
890
|
+
case "codex":
|
|
891
|
+
return readIfExists(path.join(process.env["CODEX_HOME"] ?? path.join(home, ".codex"), "auth.json"));
|
|
892
|
+
case "claude":
|
|
893
|
+
return readIfExists(path.join(process.env["CLAUDE_CONFIG_DIR"] ?? path.join(home, ".claude"), ".credentials.json"));
|
|
894
|
+
case "github": {
|
|
895
|
+
const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
896
|
+
const token = result.status === 0 ? result.stdout.trim() : "";
|
|
897
|
+
return token === "" ? null : token;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const accountLine = (account) => {
|
|
902
|
+
const meta = account.metadata;
|
|
903
|
+
const pick = (key) => (typeof meta[key] === "string" ? String(meta[key]) : null);
|
|
904
|
+
const identity = pick("login") ?? pick("email") ?? pick("accountEmail") ?? pick("accountId");
|
|
905
|
+
const suffix = pick("tokenSuffix");
|
|
906
|
+
const facts = [
|
|
907
|
+
account.status === "active" ? "connected" : account.status,
|
|
908
|
+
identity,
|
|
909
|
+
suffix === null ? null : `…${suffix}`,
|
|
910
|
+
`since ${account.connectedAt.slice(0, 10)}`,
|
|
911
|
+
].filter((fact) => fact !== null);
|
|
912
|
+
return `${account.provider.padEnd(8)} ${facts.join(" · ")}`;
|
|
913
|
+
};
|
|
914
|
+
/**
|
|
915
|
+
* `mend accounts`: the signed-in user's own connected accounts on the platform — each person's
|
|
916
|
+
* subscriptions, under their own Sealant user (docs/SEALANT-IDENTITY.md).
|
|
917
|
+
*/
|
|
918
|
+
const accountsCommand = async (config) => {
|
|
919
|
+
const identity = await api(config, "GET", "/me/sealant");
|
|
920
|
+
process.stdout.write(`platform user ${identity.sealantUserId}\n`);
|
|
921
|
+
const providers = ["claude", "codex", "github"];
|
|
922
|
+
for (const provider of providers) {
|
|
923
|
+
const account = identity.accounts.find((row) => row.provider === provider && row.name === "default") ??
|
|
924
|
+
identity.accounts.find((row) => row.provider === provider);
|
|
925
|
+
process.stdout.write(` ${account === undefined ? `${provider.padEnd(8)} not connected` : accountLine(account)}\n`);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
/**
|
|
929
|
+
* `mend connect claude|codex|github [--from-stdin] [--remove]`: send THIS machine's credential
|
|
930
|
+
* for the provider to the platform under your own user. The file the provider's CLI wrote at
|
|
931
|
+
* login is read verbatim (codex: ~/.codex/auth.json; claude: ~/.claude/.credentials.json;
|
|
932
|
+
* github: `gh auth token`); `--from-stdin` takes a pasted token or file instead. Mend forwards
|
|
933
|
+
* it once and stores nothing.
|
|
934
|
+
*/
|
|
935
|
+
const connectCommand = async (config, args) => {
|
|
936
|
+
const [providerArg, ...flags] = args;
|
|
937
|
+
if (!isProvider(providerArg)) {
|
|
938
|
+
return fail("usage: mend connect claude|codex|github [--from-stdin] [--remove]");
|
|
939
|
+
}
|
|
940
|
+
const provider = providerArg;
|
|
941
|
+
if (flags.includes("--remove")) {
|
|
942
|
+
const identity = await api(config, "GET", "/me/sealant");
|
|
943
|
+
const account = identity.accounts.find((row) => row.provider === provider);
|
|
944
|
+
if (account === undefined)
|
|
945
|
+
return fail(`${provider}: nothing connected`);
|
|
946
|
+
await api(config, "DELETE", `/me/sealant/accounts/${account.id}`);
|
|
947
|
+
process.stdout.write(`${provider}: disconnected\n`);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
let secret;
|
|
951
|
+
if (flags.includes("--from-stdin")) {
|
|
952
|
+
secret = fs.readFileSync(0, "utf8").trim();
|
|
953
|
+
if (secret === "")
|
|
954
|
+
return fail("nothing on stdin");
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
secret = localCredential(provider);
|
|
958
|
+
if (secret === null) {
|
|
959
|
+
const where = provider === "github"
|
|
960
|
+
? "`gh auth login` first, or pipe a token: gh auth token | mend connect github --from-stdin"
|
|
961
|
+
: provider === "codex"
|
|
962
|
+
? "`codex login` first, or: mend connect codex --from-stdin < auth.json"
|
|
963
|
+
: "`claude setup-token` then: mend connect claude --from-stdin";
|
|
964
|
+
return fail(`${provider}: no credential on this machine — ${where}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const account = await withSpinner(`connecting ${provider}`, api(config, "POST", "/me/sealant/accounts", { provider, secret }));
|
|
968
|
+
process.stdout.write(`${accountLine(account)}\n`);
|
|
969
|
+
};
|
|
880
970
|
// ─── login: obtain and save the bearer token ────────────────────────────────
|
|
881
971
|
const takeFlagValue = (args, flag) => {
|
|
882
972
|
const at = args.indexOf(flag);
|
|
@@ -1333,6 +1423,9 @@ _mend() {
|
|
|
1333
1423
|
'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
|
|
1334
1424
|
'service:reachable ports — add, list, stop'
|
|
1335
1425
|
'keys:the machine Mend deploy key — init, show, share'
|
|
1426
|
+
'accounts:your connected accounts on the platform'
|
|
1427
|
+
'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
|
|
1428
|
+
'connect:send this machine'"'"'s claude/codex/github credential to the platform'
|
|
1336
1429
|
'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
|
|
1337
1430
|
'rejoin:attach if live, otherwise resume'
|
|
1338
1431
|
'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
|
|
@@ -1355,7 +1448,7 @@ _mend "$@"
|
|
|
1355
1448
|
const BASH_COMPLETIONS = `_mend() {
|
|
1356
1449
|
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1357
1450
|
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") )
|
|
1451
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys pair doctor continue resume rejoin projects sessions status ui help" -- "$cur") )
|
|
1359
1452
|
return
|
|
1360
1453
|
fi
|
|
1361
1454
|
case \${COMP_WORDS[1]} in
|
|
@@ -1775,11 +1868,28 @@ const dashboard = async (config) => {
|
|
|
1775
1868
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
1776
1869
|
const HELP = `mend — the agent workbench
|
|
1777
1870
|
|
|
1778
|
-
|
|
1871
|
+
start
|
|
1872
|
+
mend login [--url <server>] sign in with email + password; saves the token (0600)
|
|
1873
|
+
mend connect <provider> [--from-stdin] [--remove]
|
|
1874
|
+
send THIS machine's claude/codex/github credential to the
|
|
1875
|
+
platform under your own user (reads the file the provider's
|
|
1876
|
+
CLI wrote at login; --from-stdin pastes one instead)
|
|
1779
1877
|
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
1780
1878
|
adopt a repository into the store (default: cwd; any git
|
|
1781
1879
|
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
1782
|
-
mend
|
|
1880
|
+
mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
|
|
1881
|
+
[--base <ref>] [--ask] [--fast]
|
|
1882
|
+
new session worktree + launch the harness in it; a quoted
|
|
1883
|
+
prompt becomes its first message (and names the session),
|
|
1884
|
+
--ask restores the harness's permission prompts, --fast
|
|
1885
|
+
requests priority processing (codex service tier)
|
|
1886
|
+
mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
|
|
1887
|
+
the URL to reach this server (one device, once, 10 minutes)
|
|
1888
|
+
mend doctor read-only checklist of this machine's setup — one line per
|
|
1889
|
+
fact, each unfinished one ending in the command that fixes it
|
|
1890
|
+
|
|
1891
|
+
everything else
|
|
1892
|
+
mend the dashboard: every project and session, live
|
|
1783
1893
|
mend logout forget the saved token
|
|
1784
1894
|
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1785
1895
|
mend keys show print the public key — add it as a deploy key on your git host
|
|
@@ -1787,16 +1897,11 @@ const HELP = `mend — the agent workbench
|
|
|
1787
1897
|
secret-shaped names → secrets; --secret sends all (or the
|
|
1788
1898
|
named ones, e.g. DATABASE_URL) to secrets
|
|
1789
1899
|
mend env show what the project store holds — names only, never values
|
|
1900
|
+
mend accounts your connected accounts on the platform (claude, codex, github)
|
|
1790
1901
|
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
1791
1902
|
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
1792
1903
|
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
1793
1904
|
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
1905
|
mend run -- <command...> same, with an arbitrary command
|
|
1801
1906
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
1802
1907
|
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
@@ -1849,6 +1954,17 @@ const main = async () => {
|
|
|
1849
1954
|
return keysCommand(config, rest);
|
|
1850
1955
|
case "dotfiles":
|
|
1851
1956
|
return dotfilesCommand(config, rest);
|
|
1957
|
+
case "accounts":
|
|
1958
|
+
return accountsCommand(config);
|
|
1959
|
+
case "connect":
|
|
1960
|
+
return connectCommand(config, rest);
|
|
1961
|
+
case "pair":
|
|
1962
|
+
return pairCommand(rest, boundApi(config));
|
|
1963
|
+
// Deliberately absent from HELP: the installer renders its own QR through this.
|
|
1964
|
+
case "qr":
|
|
1965
|
+
return qrCommand(rest);
|
|
1966
|
+
case "doctor":
|
|
1967
|
+
return doctorCommand(config, localCredential);
|
|
1852
1968
|
case "env":
|
|
1853
1969
|
return envCommand(config, rest);
|
|
1854
1970
|
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.6.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",
|
|
@@ -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",
|