@sealant/mend 0.4.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/dashboard.js +2 -2
- package/dist/doctor.js +233 -0
- package/dist/main.js +386 -142
- package/dist/pair.js +83 -0
- package/dist/review-workflow.js +2 -9
- package/dist/review.js +89 -43
- package/dist/shared.js +151 -5
- 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
|
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tan
|
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
7
7
|
import { ReviewScreen } from "./review.js";
|
|
8
|
-
import { HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
|
|
8
|
+
import { cwdFacts, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
|
|
9
9
|
import { openUrl } from "./terminal.js";
|
|
10
10
|
import { AMBER, BG, COBALT, FAINT, GREEN, INK, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
11
|
const STATUS_GLYPH = {
|
|
@@ -66,7 +66,7 @@ const itemKey = (item) => item.kind === "session"
|
|
|
66
66
|
: String(item.harness);
|
|
67
67
|
const itemHeight = (view) => (view.kind === "sessions" ? 2 : 1);
|
|
68
68
|
const homeView = (cwd, data) => {
|
|
69
|
-
const home = matchProjectByCwd(data.projects, cwd);
|
|
69
|
+
const home = matchProjectByCwd(data.projects, cwdFacts(cwd));
|
|
70
70
|
return home === undefined ? { kind: "projects" } : { kind: "sessions", projectId: home.id };
|
|
71
71
|
};
|
|
72
72
|
const deriveItems = (view, data) => {
|
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
|
+
};
|