@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 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}`;
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
+ };