@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 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}`;
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
- mend the dashboard: every project and session, live
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 login [--url <server>] sign in with email + password; saves the token (0600)
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}` : text;
2
+ const dim = paint("2");
3
+ const green = paint("32");
4
+ const say = (line) => process.stdout.write(`${line}\n`);
5
+ const fail = (message) => {
6
+ process.stderr.write(`mend: ${message}\n`);
7
+ process.exit(1);
8
+ };
9
+ const takeFlagValue = (args, flag) => {
10
+ const at = args.indexOf(flag);
11
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
12
+ };
13
+ /**
14
+ * The code as a human reads it aloud: 8 Crockford characters in two groups. The
15
+ * server compares case-insensitively with dashes stripped, so the dash is purely
16
+ * for the eye and either form can be typed back.
17
+ */
18
+ export const groupCode = (code) => {
19
+ const bare = code.replace(/[^0-9a-z]/gi, "").toUpperCase();
20
+ return bare.length <= 4 ? bare : `${bare.slice(0, 4)}-${bare.slice(4)}`;
21
+ };
22
+ /** The scanned string. The device reads the base URL and the code out of it, nothing else. */
23
+ export const pairingLink = (url, code) => `mend://pair?u=${encodeURIComponent(url)}&c=${code}`;
24
+ /**
25
+ * Tailscale hands every node an IPv4 in the CGNAT range 100.64.0.0/10 — the same
26
+ * observation packages/api/src/machine.ts makes. A tailnet URL is preferred because
27
+ * it keeps working when the phone leaves the LAN.
28
+ */
29
+ const isTailnetUrl = (value) => {
30
+ let host;
31
+ try {
32
+ host = new URL(value).hostname;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ const parts = host.split(".").map(Number);
38
+ const [first, second] = parts;
39
+ if (parts.length !== 4 || first === undefined || second === undefined)
40
+ return false;
41
+ return first === 100 && second >= 64 && second <= 127;
42
+ };
43
+ /** `--url` wins; otherwise the tailnet address, otherwise the first candidate the server offered. */
44
+ export const chooseUrl = (urls, override) => override ?? urls.find(isTailnetUrl) ?? urls[0] ?? null;
45
+ /** Whole minutes left, floored at 0; null when the server sent a date this CLI cannot read. */
46
+ export const minutesUntil = (expiresAt, now = Date.now()) => {
47
+ const at = Date.parse(expiresAt);
48
+ if (Number.isNaN(at))
49
+ return null;
50
+ return Math.max(0, Math.round((at - now) / 60_000));
51
+ };
52
+ /** The QR as block characters. Deliberately not gated on isTTY — a piped QR still scans. */
53
+ export const renderQr = async (text) => {
54
+ const { toString: toQrString } = await import("qrcode");
55
+ const options = { type: "terminal", small: true };
56
+ return (await toQrString(text, options)).trimEnd();
57
+ };
58
+ /**
59
+ * `mend pair [--url <base url>]`: mint a pairing code and show it. The device names
60
+ * itself when it claims the code, so this side sends nothing but the request.
61
+ */
62
+ export const pairCommand = async (args, api) => {
63
+ const pairing = await api("POST", "/me/devices/pairings");
64
+ const url = chooseUrl(pairing.urls, takeFlagValue(args, "--url"));
65
+ if (url === null) {
66
+ return fail("no LAN or tailnet address to hand the device — pass one: mend pair --url http://<host>:<port>");
67
+ }
68
+ const minutes = minutesUntil(pairing.expiresAt);
69
+ say(await renderQr(pairingLink(url, pairing.code)));
70
+ say(`${green("✓")} pairing code ${groupCode(pairing.code)}`);
71
+ say(` ${dim("url")} ${url}`);
72
+ say(` ${dim("expires")} ${minutes === null ? pairing.expiresAt : `in ${minutes} min`} ${dim("· one device, once")}`);
73
+ say("");
74
+ say(dim(" scan it in the Mend app, or enter the url and the code there by hand"));
75
+ say(dim(" the app is apps/mobile — build it yourself; it is not published yet"));
76
+ };
77
+ /** `mend qr <text>`: print a terminal QR of anything. Hidden from help; the installer uses it. */
78
+ export const qrCommand = async (args) => {
79
+ const text = args.join(" ").trim();
80
+ if (text === "")
81
+ return fail("usage: mend qr <text>");
82
+ say(await renderQr(text));
83
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.5.0",
3
+ "version": "0.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",