@sealant/mend 0.9.0 → 0.10.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/dist/login.js ADDED
@@ -0,0 +1,240 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as os from "node:os";
3
+ import { groupCode } from "./pair.js";
4
+ /**
5
+ * The opened request as the server's JSON, checked field by field before the
6
+ * CLI acts on it — a captive portal or a non-Mend server answering 200 must
7
+ * read as "not a Mend server", never as a crash. Null follows the CLI's
8
+ * absence idiom (chooseUrl, minutesUntil); the one caller turns it into the
9
+ * exit message.
10
+ */
11
+ const parseCliAuthStart = (json) => {
12
+ if (typeof json !== "object" || json === null)
13
+ return null;
14
+ if (!("deviceCode" in json &&
15
+ "code" in json &&
16
+ "verifyPath" in json &&
17
+ "expiresAt" in json &&
18
+ "intervalSeconds" in json)) {
19
+ return null;
20
+ }
21
+ const { deviceCode, code, verifyPath, expiresAt, intervalSeconds } = json;
22
+ if (typeof deviceCode !== "string" ||
23
+ typeof code !== "string" ||
24
+ typeof verifyPath !== "string" ||
25
+ typeof expiresAt !== "string" ||
26
+ typeof intervalSeconds !== "number") {
27
+ return null;
28
+ }
29
+ return { deviceCode, code, verifyPath, expiresAt, intervalSeconds };
30
+ };
31
+ /** A poll answer's JSON, checked the same way; anything off-shape answers null. */
32
+ const parseCliAuthPoll = (json) => {
33
+ if (typeof json !== "object" || json === null || !("status" in json))
34
+ return null;
35
+ if (json.status === "pending")
36
+ return { status: "pending" };
37
+ if (json.status !== "approved")
38
+ return null;
39
+ if (!("token" in json && "user" in json && "device" in json))
40
+ return null;
41
+ const { token, user, device } = json;
42
+ if (typeof token !== "string")
43
+ return null;
44
+ if (typeof user !== "object" || user === null)
45
+ return null;
46
+ if (!("id" in user && "name" in user && "email" in user))
47
+ return null;
48
+ if (typeof user.id !== "string" ||
49
+ typeof user.name !== "string" ||
50
+ typeof user.email !== "string") {
51
+ return null;
52
+ }
53
+ if (typeof device !== "object" || device === null)
54
+ return null;
55
+ if (!("id" in device && "name" in device))
56
+ return null;
57
+ if (typeof device.id !== "string" || typeof device.name !== "string")
58
+ return null;
59
+ return {
60
+ status: "approved",
61
+ token,
62
+ user: { id: user.id, name: user.name, email: user.email },
63
+ device: { id: device.id, name: device.name },
64
+ };
65
+ };
66
+ const paint = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}` : text;
67
+ const dim = paint("2");
68
+ const green = paint("32");
69
+ const cobalt = paint("34");
70
+ const say = (line) => process.stdout.write(`${line}\n`);
71
+ const fail = (message) => {
72
+ process.stderr.write(`mend: ${message}\n`);
73
+ process.exit(1);
74
+ };
75
+ const takeFlagValue = (args, flag) => {
76
+ const at = args.indexOf(flag);
77
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
78
+ };
79
+ /**
80
+ * The server URL as a human types it: a bare `host:port` gets http://, a
81
+ * trailing slash goes, anything the URL parser rejects answers null so the
82
+ * caller can say so. The path is kept — an instance can live under one.
83
+ */
84
+ export const normalizeServerUrl = (input) => {
85
+ const trimmed = input.trim();
86
+ if (trimmed === "")
87
+ return null;
88
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
89
+ let url;
90
+ try {
91
+ url = new URL(withScheme);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ if (url.protocol !== "http:" && url.protocol !== "https:")
97
+ return null;
98
+ return `${url.origin}${url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "")}`;
99
+ };
100
+ /** The page a human approves on — the verify path resolved against the URL the CLI dialed. */
101
+ export const authorizeUrl = (base, verifyPath) => `${base}${verifyPath}`;
102
+ /** How the browser opens on this platform; null means print the URL and let the human click. */
103
+ export const browserCommand = (platform) => {
104
+ if (platform === "darwin")
105
+ return { command: "open", args: [] };
106
+ if (platform === "linux")
107
+ return { command: "xdg-open", args: [] };
108
+ return null;
109
+ };
110
+ /** Polling cadence: what the server asked for, held to a sane band. */
111
+ export const pollDelayMs = (intervalSeconds) => Math.min(Math.max(Math.round(intervalSeconds), 1), 10) * 1000;
112
+ /** When to stop polling: the request's own expiry, or ten minutes for an unreadable date. */
113
+ export const pollDeadline = (expiresAt, now = Date.now()) => {
114
+ const at = Date.parse(expiresAt);
115
+ return Number.isNaN(at) ? now + 10 * 60_000 : at;
116
+ };
117
+ /** Fire-and-forget: a browser that fails to open must never take the login down. */
118
+ const openBrowser = (url) => {
119
+ const opener = browserCommand(process.platform);
120
+ if (opener === null || process.stdout.isTTY !== true)
121
+ return;
122
+ try {
123
+ const child = spawn(opener.command, [...opener.args, url], {
124
+ stdio: "ignore",
125
+ detached: true,
126
+ });
127
+ child.on("error", () => undefined);
128
+ child.unref();
129
+ }
130
+ catch {
131
+ // The URL is already printed; clicking it is the fallback.
132
+ }
133
+ };
134
+ /** One unauthenticated POST to the authorize surface; anything but 2xx surfaces as a status. */
135
+ const post = async (url, body) => {
136
+ let response;
137
+ try {
138
+ response = await fetch(url, {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify(body),
142
+ });
143
+ }
144
+ catch {
145
+ return fail(`cannot reach the Mend server at ${new URL(url).origin} — is it running?`);
146
+ }
147
+ const text = await response.text();
148
+ let json = null;
149
+ try {
150
+ json = JSON.parse(text);
151
+ }
152
+ catch {
153
+ // A non-JSON body only matters for the happy path, which always is JSON.
154
+ }
155
+ return { status: response.status, json };
156
+ };
157
+ const retryAfterSecondsOf = (json) => {
158
+ if (typeof json === "object" && json !== null && "retryAfterSeconds" in json) {
159
+ const seconds = Number(json.retryAfterSeconds);
160
+ if (Number.isFinite(seconds) && seconds > 0)
161
+ return seconds;
162
+ }
163
+ return 5;
164
+ };
165
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
166
+ /**
167
+ * Where this login points: `--url` wins, then whatever is already configured
168
+ * (MEND_URL or the config file) — a set URL is never asked about again;
169
+ * `--url` is how you point somewhere else. Only a fresh machine prompts, and
170
+ * only on a terminal; a piped stdin takes the default silently.
171
+ */
172
+ const resolveServerUrl = async (args, deps) => {
173
+ const given = takeFlagValue(args, "--url") ?? deps.configuredUrl;
174
+ if (given !== null) {
175
+ return normalizeServerUrl(given) ?? fail(`"${given}" is not a URL the CLI can dial`);
176
+ }
177
+ if (process.stdin.isTTY !== true) {
178
+ return (normalizeServerUrl(deps.defaultUrl) ??
179
+ fail(`"${deps.defaultUrl}" is not a URL the CLI can dial`));
180
+ }
181
+ const readline = await import("node:readline/promises");
182
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
183
+ const typed = (await rl.question(`mend server url ${dim(`[${deps.defaultUrl}]`)}: `)).trim();
184
+ rl.close();
185
+ const chosen = typed === "" ? deps.defaultUrl : typed;
186
+ return normalizeServerUrl(chosen) ?? fail(`"${chosen}" is not a URL the CLI can dial`);
187
+ };
188
+ /**
189
+ * `mend login [--url <server>]` — open, show, wait, save. The token lands in
190
+ * the CLI config (0600) together with the device id, so `mend logout` can
191
+ * revoke the token server-side instead of merely forgetting it.
192
+ */
193
+ export const loginCommand = async (args, deps) => {
194
+ const base = await resolveServerUrl(args, deps);
195
+ const started = await post(`${base}/api/cli/auth`, { name: os.hostname() });
196
+ if (started.status === 429) {
197
+ return fail(`the server asked for a pause — try again in ${retryAfterSecondsOf(started.json)}s`);
198
+ }
199
+ if (started.status < 200 || started.status >= 300) {
200
+ return fail(`the server at ${base} refused to open an authorize request (${started.status})`);
201
+ }
202
+ const opened = parseCliAuthStart(started.json) ??
203
+ fail(`the server at ${base} did not answer like a Mend server — is that the right URL?`);
204
+ const url = authorizeUrl(base, opened.verifyPath);
205
+ say(`${green("✓")} authorize request open at ${base}`);
206
+ say(` ${dim("code")} ${groupCode(opened.code)} ${dim("· approve only if the browser shows the same code")}`);
207
+ say(` ${dim("browser")} ${cobalt(url)}`);
208
+ say("");
209
+ say(dim(" waiting for approval… Ctrl-C stops; nothing is granted until someone approves"));
210
+ openBrowser(url);
211
+ const deadline = pollDeadline(opened.expiresAt);
212
+ const delay = pollDelayMs(opened.intervalSeconds);
213
+ while (Date.now() < deadline) {
214
+ await sleep(delay);
215
+ const poll = await post(`${base}/api/cli/auth/token`, { deviceCode: opened.deviceCode });
216
+ if (poll.status === 429) {
217
+ await sleep(retryAfterSecondsOf(poll.json) * 1000);
218
+ continue;
219
+ }
220
+ if (poll.status === 403)
221
+ return fail("denied in the browser; nothing was granted");
222
+ if (poll.status === 404 || poll.status === 410) {
223
+ return fail("the authorize request is no longer open — run mend login again");
224
+ }
225
+ if (poll.status < 200 || poll.status >= 300) {
226
+ return fail(`the server answered ${poll.status} while waiting — run mend login again`);
227
+ }
228
+ const result = parseCliAuthPoll(poll.json);
229
+ if (result === null) {
230
+ return fail("the server's answer stopped making sense while waiting — run mend login again");
231
+ }
232
+ if (result.status === "pending")
233
+ continue;
234
+ deps.save({ url: base, token: result.token, deviceId: result.device.id });
235
+ say(`${green("✓")} signed in as ${result.user.email}`);
236
+ say(` ${dim("this terminal is the device")} ${result.device.name} ${dim("· revoke it any time under Settings → Devices")}`);
237
+ return;
238
+ }
239
+ return fail("the authorize request expired before anyone approved — run mend login again");
240
+ };
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import * as path from "node:path";
8
8
  import { doctorCommand } from "./doctor.js";
9
9
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
10
10
  import { formatLoadReport } from "./env.js";
11
+ import { loginCommand } from "./login.js";
11
12
  import { pairCommand, qrCommand } from "./pair.js";
12
13
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
13
14
  import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
@@ -30,9 +31,12 @@ const loadConfig = () => {
30
31
  fail(`could not parse ${CONFIG_PATH}`);
31
32
  }
32
33
  }
34
+ const configuredUrl = process.env["MEND_URL"] ?? fileConfig.url ?? null;
33
35
  return {
34
- url: process.env["MEND_URL"] ?? fileConfig.url ?? "http://localhost:3105",
36
+ url: configuredUrl ?? "http://localhost:3105",
37
+ configuredUrl,
35
38
  token: process.env["MEND_TOKEN"] ?? fileConfig.token ?? null,
39
+ deviceId: fileConfig.deviceId ?? null,
36
40
  };
37
41
  };
38
42
  const fail = (message) => {
@@ -547,7 +551,7 @@ const serviceUrl = (service) => service.browserUrl ??
547
551
  * authority is our own address; on a remote one it is an address on the
548
552
  * server's network — the honest client path is the authenticated tunnel.
549
553
  */
550
- const printServiceAccess = (config, service) => {
554
+ const printServiceAccess = (config, service, tunneling = false) => {
551
555
  const gate = "no Mend sign-in on this port — network reach is the only gate";
552
556
  if (serverIsLocal(config)) {
553
557
  if (service.authority !== null)
@@ -561,8 +565,11 @@ const printServiceAccess = (config, service) => {
561
565
  }
562
566
  return;
563
567
  }
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`)}`);
568
+ if (!tunneling) {
569
+ // Suggest the tunnel only when this command is not about to open it.
570
+ const local = service.hostPort ?? service.workspacePort;
571
+ say(` ${cobalt(`mend service connect ${service.label}`)} ${dim(`→ 127.0.0.1:${local} on this machine, authenticated as you`)}`);
572
+ }
566
573
  if (service.authority !== null) {
567
574
  say(` ${dim(`server-side listener ${service.authority} · ${gate}`)}`);
568
575
  }
@@ -573,8 +580,8 @@ const printWorkspaceTtlFailure = (service) => {
573
580
  say(amber(` workspace TTL renewal failed · ${service.workspaceTtlRenewalError}`));
574
581
  say(dim(` last renewed ${service.workspaceTtlRenewedAt ?? "unknown"} · known expiry ${service.workspaceExpiresAt ?? "unknown"} · failed ${service.workspaceTtlRenewalFailedAt ?? "unknown"}`));
575
582
  };
576
- const printServiceEndpoint = (config, service) => {
577
- printServiceAccess(config, service);
583
+ const printServiceEndpoint = (config, service, tunneling = false) => {
584
+ printServiceAccess(config, service, tunneling);
578
585
  printWorkspaceTtlFailure(service);
579
586
  };
580
587
  const printService = (config, service) => {
@@ -670,10 +677,22 @@ const findLiveService = async (config, needle) => {
670
677
  * declared port to answer, then exposes it like any Service. The command
671
678
  * never occupies the agent's terminal or a tool call.
672
679
  */
680
+ /**
681
+ * The point of starting a Service is reaching it. On a local server the
682
+ * bound authority already answers on this machine, so start-and-return is
683
+ * complete. On a remote server nothing local answers — stay attached and
684
+ * tunnel the port here, exactly what `mend service connect` would do next.
685
+ * Ctrl-C closes the tunnel, never the Service.
686
+ */
687
+ const willAutoConnect = (config, service, optOut) => !optOut && !serverIsLocal(config) && service.protocol !== "udp";
688
+ const autoConnect = async (config, service) => {
689
+ say(dim(" remote server — tunneling the port here · Ctrl-C stops the tunnel, not the Service"));
690
+ await tunnelServices(config, [service], null);
691
+ };
673
692
  const serviceRun = async (config, args) => {
674
693
  const dashdash = args.indexOf("--");
675
- const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] [--http|--https] -- <command...>\n" +
676
- " mend service run [session] <name> (a declared recipe)";
694
+ const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] [--http|--https] [--no-connect] -- <command...>\n" +
695
+ " mend service run [session] <name> [--no-connect] (a declared recipe)";
677
696
  // No explicit command = a DECLARED Service: resolve the name against the
678
697
  // session worktree's mend.toml and start (or adopt) its recipe.
679
698
  if (dashdash === -1) {
@@ -698,9 +717,12 @@ const serviceRun = async (config, args) => {
698
717
  : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/recipe`, {
699
718
  name: recipe.name,
700
719
  }));
720
+ const tunneling = willAutoConnect(config, service, args.includes("--no-connect"));
701
721
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
702
- printServiceEndpoint(config, service);
722
+ printServiceEndpoint(config, service, tunneling);
703
723
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
724
+ if (tunneling)
725
+ await autoConnect(config, service);
704
726
  return;
705
727
  }
706
728
  const argv = args.slice(dashdash + 1);
@@ -732,9 +754,12 @@ const serviceRun = async (config, args) => {
732
754
  protocol,
733
755
  browserScheme,
734
756
  }));
757
+ const tunneling = willAutoConnect(config, service, head.includes("--no-connect"));
735
758
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
736
- printServiceEndpoint(config, service);
759
+ printServiceEndpoint(config, service, tunneling);
737
760
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
761
+ if (tunneling)
762
+ await autoConnect(config, service);
738
763
  };
739
764
  /** Read sequence-addressed PTY output without attaching an input-capable terminal. */
740
765
  const serviceLogs = async (config, args) => {
@@ -881,34 +906,14 @@ const serviceInit = async (args) => {
881
906
  say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
882
907
  };
883
908
  /**
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.
909
+ * The location-independent data plane for Services: bind each Service's port
910
+ * on THIS machine's loopback and pump every accepted connection over one
911
+ * authenticated WebSocket to the server, which dials the same workspace
912
+ * forward the server-side listener uses. No ports are opened anywhere but
913
+ * here, and every connection carries the caller's Mend auth. Blocks until
914
+ * interrupted — the listeners keep the process alive.
892
915
  */
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
- }
916
+ const tunnelServices = async (config, services, portOverride) => {
912
917
  const tunnelUrl = (serviceId) => {
913
918
  const url = parseMendUrl(`${config.url}/api/service-tunnel`);
914
919
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -917,7 +922,7 @@ const serviceConnect = async (config, args) => {
917
922
  url.searchParams.set("token", config.token);
918
923
  return url;
919
924
  };
920
- for (const service of picked) {
925
+ for (const service of services) {
921
926
  const port = portOverride ?? service.hostPort ?? service.workspacePort;
922
927
  const server = net.createServer((socket) => {
923
928
  // Hold local bytes until the tunnel is open; loopback buffers are tiny.
@@ -950,12 +955,39 @@ const serviceConnect = async (config, args) => {
950
955
  });
951
956
  server.listen(port, "127.0.0.1", () => resolve());
952
957
  }).catch((error) => fail(error.message));
953
- say(`${green("●")} ${service.label} → 127.0.0.1:${port} ${dim(`(tunnel to ${config.url})`)}`);
958
+ say(`${green("●")} ${service.label ?? service.id.slice(0, 8)} → 127.0.0.1:${port} ${dim(`(tunnel to ${config.url})`)}`);
954
959
  }
955
960
  say(dim(" connections are authenticated as you · Ctrl-C stops"));
956
961
  // The listeners keep the process alive until the user stops it.
957
962
  await new Promise(() => { });
958
963
  };
964
+ /**
965
+ * `mend service connect [name…] [--port <n>]`: the standalone entry to the
966
+ * tunnel. The server's own listener binds the SERVER's interfaces
967
+ * (`MEND_SERVICE_HOSTS`) — exactly right when the server is this machine,
968
+ * unreachable when it is a Pod or a VPS; this brings the port here instead.
969
+ */
970
+ const serviceConnect = async (config, args) => {
971
+ const portFlag = args.indexOf("--port");
972
+ const portOverride = portFlag === -1 ? null : Number(args[portFlag + 1]);
973
+ if (portOverride !== null && !Number.isInteger(portOverride)) {
974
+ return fail("--port takes a port number");
975
+ }
976
+ const names = args.filter((a, i) => !a.startsWith("--") && i !== portFlag + 1);
977
+ const live = (await fetchServices(config)).filter((s) => s.protocol === "tcp");
978
+ const picked = names.length === 0
979
+ ? live
980
+ : live.filter((s) => names.some((n) => s.label === n || s.id.startsWith(n)));
981
+ if (picked.length === 0) {
982
+ return fail(names.length === 0
983
+ ? "no live TCP services — mend service run starts one"
984
+ : `no live TCP service matches "${names.join('", "')}"`);
985
+ }
986
+ if (portOverride !== null && picked.length !== 1) {
987
+ return fail("--port applies to exactly one service — name it");
988
+ }
989
+ await tunnelServices(config, picked, portOverride);
990
+ };
959
991
  const serviceCommand = async (config, args) => {
960
992
  const [verb, ...rest] = args;
961
993
  switch (verb) {
@@ -1069,106 +1101,48 @@ const connectCommand = async (config, args) => {
1069
1101
  const account = await withSpinner(`connecting ${provider}`, api(config, "POST", "/me/sealant/accounts", { provider, secret }));
1070
1102
  process.stdout.write(`${accountLine(account)}\n`);
1071
1103
  };
1072
- // ─── login: obtain and save the bearer token ────────────────────────────────
1104
+ // ─── login: authorize this terminal through the browser (login.ts) ──────────
1073
1105
  const takeFlagValue = (args, flag) => {
1074
1106
  const at = args.indexOf(flag);
1075
1107
  return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
1076
1108
  };
1077
- /**
1078
- * Read a password: raw-mode without echo on a TTY; on a pipe, one line through the SAME readline
1079
- * that read the email (a second reader would find the buffered pipe already drained).
1080
- */
1081
- const readSecretLine = async (prompt, rl) => {
1082
- if (process.stdin.isTTY !== true) {
1083
- return (await rl.question(prompt)).trim();
1084
- }
1085
- process.stdout.write(prompt);
1086
- return new Promise((resolve) => {
1087
- const stdin = process.stdin;
1088
- stdin.setRawMode(true);
1089
- stdin.resume();
1090
- stdin.setEncoding("utf8");
1091
- let buffer = "";
1092
- const onData = (chunk) => {
1093
- for (const char of chunk) {
1094
- if (char === "\r" || char === "\n") {
1095
- stdin.setRawMode(false);
1096
- stdin.pause();
1097
- stdin.off("data", onData);
1098
- process.stdout.write("\n");
1099
- resolve(buffer);
1100
- return;
1101
- }
1102
- if (char === "\u0003") {
1103
- stdin.setRawMode(false);
1104
- process.stdout.write("\n");
1105
- process.exit(130);
1106
- }
1107
- if (char === "\u007f" || char === "\b") {
1108
- buffer = buffer.slice(0, -1);
1109
- continue;
1110
- }
1111
- buffer += char;
1112
- }
1113
- };
1114
- stdin.on("data", onData);
1115
- });
1116
- };
1109
+ // Only these three fields persist; `configuredUrl` is derived on every load.
1117
1110
  const saveCliConfig = (next) => {
1118
1111
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
1119
- fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ url: next.url, token: next.token }, null, 2)}\n`, { mode: 0o600 });
1112
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ url: next.url, token: next.token, deviceId: next.deviceId }, null, 2)}\n`, { mode: 0o600 });
1120
1113
  fs.chmodSync(CONFIG_PATH, 0o600);
1121
1114
  };
1122
- /**
1123
- * `mend login [--url <server>] [--email <address>]`: sign in with email + password against the
1124
- * server's auth endpoint (the bearer plugin answers with a `set-auth-token` header) and save the
1125
- * token 0600 in the CLI config. Nothing else asks for a password again.
1126
- */
1115
+ /** `mend login [--url <server>]` — the browser authorize walk; login.ts owns the flow. */
1127
1116
  const login = async (config, args) => {
1128
- const url = takeFlagValue(args, "--url") ?? process.env["MEND_URL"] ?? config.url;
1129
- const emailFlag = takeFlagValue(args, "--email");
1130
- const readline = await import("node:readline/promises");
1131
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1132
- const email = emailFlag ?? (await rl.question(`email for ${url}: `)).trim();
1133
- if (email === "") {
1134
- rl.close();
1135
- return fail("an email is required");
1136
- }
1137
- const password = await readSecretLine("password: ", rl);
1138
- rl.close();
1139
- if (password === "")
1140
- return fail("a password is required");
1141
- let response;
1142
- try {
1143
- response = await fetch(`${url}/api/auth/sign-in/email`, {
1144
- method: "POST",
1145
- // better-auth's CSRF check rejects the `Origin: null` a non-browser fetch sends; the
1146
- // server's own URL is always a trusted origin, so present that.
1147
- headers: { "content-type": "application/json", origin: url },
1148
- body: JSON.stringify({ email, password }),
1149
- });
1150
- }
1151
- catch {
1152
- return fail(`cannot reach the Mend server at ${url} — is it running?`);
1153
- }
1154
- if (response.status === 401 || response.status === 403 || response.status === 400) {
1155
- return fail(`sign-in refused for ${email} at ${url}`);
1156
- }
1157
- if (!response.ok)
1158
- return fail(`sign-in failed: ${url} responded ${response.status}`);
1159
- const token = response.headers.get("set-auth-token");
1160
- if (token === null || token === "") {
1161
- return fail("the server signed you in but returned no bearer token (bearer plugin missing?)");
1162
- }
1163
- saveCliConfig({ ...config, url, token });
1164
- say(`${green("✓")} signed in as ${email} · ${dim(`token saved to ${CONFIG_PATH}`)}`);
1117
+ await loginCommand(args, {
1118
+ configuredUrl: config.configuredUrl,
1119
+ defaultUrl: config.url,
1120
+ save: (next) => {
1121
+ saveCliConfig({ url: next.url, token: next.token, deviceId: next.deviceId });
1122
+ say(dim(` token saved to ${CONFIG_PATH} (0600)`));
1123
+ },
1124
+ });
1165
1125
  };
1166
- const logout = (config) => {
1167
- if (!fs.existsSync(CONFIG_PATH)) {
1126
+ /**
1127
+ * Signing out revokes the device server-side when it can — merely forgetting
1128
+ * a live token would leave it valid until someone found it in Settings →
1129
+ * Devices. A server that cannot be reached still loses the local copy.
1130
+ */
1131
+ const logout = async (config) => {
1132
+ if (!fs.existsSync(CONFIG_PATH) && config.token === null) {
1168
1133
  say(dim("nothing saved — already signed out"));
1169
1134
  return;
1170
1135
  }
1171
- saveCliConfig({ ...config, token: null });
1136
+ if (config.token !== null && config.deviceId !== null) {
1137
+ try {
1138
+ await request(config, "DELETE", `/me/devices/${config.deviceId}`);
1139
+ say(`${green("✓")} device revoked on ${config.url}`);
1140
+ }
1141
+ catch {
1142
+ say(dim(" could not revoke on the server — end it under Settings → Devices"));
1143
+ }
1144
+ }
1145
+ saveCliConfig({ ...config, token: null, deviceId: null });
1172
1146
  say(`${green("✓")} signed out · ${dim(`token removed from ${CONFIG_PATH}`)}`);
1173
1147
  };
1174
1148
  // ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
@@ -2044,7 +2018,10 @@ const dashboard = async (config) => {
2044
2018
  const HELP = `mend — the agent workbench
2045
2019
 
2046
2020
  start
2047
- mend login [--url <server>] sign in with email + password; saves the token (0600)
2021
+ mend login [--url <server>] sign this terminal in through the browser: opens
2022
+ <server>/authorize, you press Authorize there, and the
2023
+ CLI saves a revocable device token (0600); no password
2024
+ is ever typed here
2048
2025
  mend connect <provider> [--from-stdin] [--remove]
2049
2026
  send THIS machine's claude/codex/github credential to the
2050
2027
  platform under your own user (reads the file the provider's
@@ -2065,7 +2042,7 @@ start
2065
2042
 
2066
2043
  everything else
2067
2044
  mend the dashboard: every project and session, live
2068
- mend logout forget the saved token
2045
+ mend logout revoke this terminal's device token and forget it
2069
2046
  mend keys init generate the machine's Mend deploy key (ed25519)
2070
2047
  mend keys show print the public key — add it as a deploy key on your git host
2071
2048
  mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
@@ -2105,7 +2082,7 @@ everything else
2105
2082
 
2106
2083
  server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
2107
2084
  detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
2108
- config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
2085
+ config file: ~/.config/mend/cli.json { "url": ..., "token": ..., "deviceId": ... }
2109
2086
  `;
2110
2087
  const main = async () => {
2111
2088
  const [command, ...rest] = process.argv.slice(2);