@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/README.md +17 -9
- package/dist/dashboard.js +397 -282
- package/dist/login.js +240 -0
- package/dist/main.js +106 -129
- package/dist/review.js +172 -87
- package/dist/shared.js +8 -0
- package/dist/tui-theme.js +33 -22
- package/dist/workbench-events.js +71 -0
- package/package.json +1 -1
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}[0m` : 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:
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|