@sealant/mend 0.8.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 +184 -134
- 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/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) ───────────────
|
|
@@ -1450,33 +1424,106 @@ const envLoad = async (config, args) => {
|
|
|
1450
1424
|
};
|
|
1451
1425
|
const envShow = async (config, args) => {
|
|
1452
1426
|
const project = await findProject(config, takeFlagValue(args, "--project"));
|
|
1453
|
-
const [environment, secrets] = await Promise.all([
|
|
1427
|
+
const [environment, secrets, cluster] = await Promise.all([
|
|
1454
1428
|
api(config, "GET", `/projects/${project.id}/environment`),
|
|
1455
1429
|
api(config, "GET", `/projects/${project.id}/secrets`),
|
|
1430
|
+
api(config, "GET", `/projects/${project.id}/cluster-bindings`),
|
|
1456
1431
|
]);
|
|
1457
|
-
say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision}`)}`);
|
|
1458
|
-
if (environment.variables.length === 0 &&
|
|
1432
|
+
say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision} · cluster r${cluster.revision}`)}`);
|
|
1433
|
+
if (environment.variables.length === 0 &&
|
|
1434
|
+
secrets.secrets.length === 0 &&
|
|
1435
|
+
cluster.bindings.length === 0 &&
|
|
1436
|
+
cluster.serviceAccount === null) {
|
|
1459
1437
|
say(dim(` nothing stored — load a file: ${cobalt("mend env load")}`));
|
|
1460
1438
|
return;
|
|
1461
1439
|
}
|
|
1462
|
-
const
|
|
1440
|
+
const bindingNames = cluster.bindings.map((b) => `${b.kind}/${b.objectName}`);
|
|
1441
|
+
const width = Math.max(0, ...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length), ...bindingNames.map((name) => name.length));
|
|
1463
1442
|
for (const variable of environment.variables) {
|
|
1464
1443
|
say(` ${variable.name.padEnd(width)} configuration ${dim("· plaintext")}`);
|
|
1465
1444
|
}
|
|
1466
1445
|
for (const secret of secrets.secrets) {
|
|
1467
1446
|
say(` ${secret.name.padEnd(width)} secret ${dim("· value set, never shown")}`);
|
|
1468
1447
|
}
|
|
1448
|
+
for (const name of bindingNames) {
|
|
1449
|
+
say(` ${name.padEnd(width)} cluster binding ${dim("· resolved by the platform at launch · contents unknown to Mend")}`);
|
|
1450
|
+
}
|
|
1451
|
+
if (cluster.serviceAccount !== null) {
|
|
1452
|
+
say(` ${cluster.serviceAccount.padEnd(width)} service account ${dim("· workspace pod identity · allowlisted by the operator")}`);
|
|
1453
|
+
}
|
|
1454
|
+
if (!cluster.clusterCapable && (cluster.bindings.length > 0 || cluster.serviceAccount !== null)) {
|
|
1455
|
+
say(amber(` ${cluster.bindings.length} cluster binding${cluster.bindings.length === 1 ? "" : "s"}${cluster.serviceAccount === null ? "" : " · service account set"} · local runner — cluster bindings do not resolve here`));
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
/**
|
|
1459
|
+
* Cluster bindings (`.plans/cluster-env-sources.md`): NAMES of Kubernetes Secrets/ConfigMaps the
|
|
1460
|
+
* platform resolves at launch — Mend never holds the values. Every verb works on every install
|
|
1461
|
+
* (a non-cluster install must be able to remove bindings to launch); only resolution is
|
|
1462
|
+
* Kubernetes-only, and the platform refuses launches there, readably.
|
|
1463
|
+
*/
|
|
1464
|
+
const envCluster = async (config, args) => {
|
|
1465
|
+
const explicitProject = takeFlagValue(args, "--project");
|
|
1466
|
+
const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project");
|
|
1467
|
+
const [verb, ...rest] = positional;
|
|
1468
|
+
const usage = "try: mend env cluster add secret <name> | add configmap <name> | remove <kind>/<name> | sa <name> | sa --clear";
|
|
1469
|
+
const project = await findProject(config, explicitProject);
|
|
1470
|
+
const route = `/projects/${project.id}/cluster-bindings`;
|
|
1471
|
+
const appliesLine = () => say(dim(" applies from the next workspace launch; running workspaces keep what they started with"));
|
|
1472
|
+
if (verb === "add") {
|
|
1473
|
+
const [kind, objectName] = rest;
|
|
1474
|
+
if ((kind !== "secret" && kind !== "configmap") || objectName === undefined) {
|
|
1475
|
+
return fail(`env cluster add takes a kind and an object name — ${usage}`);
|
|
1476
|
+
}
|
|
1477
|
+
const result = await api(config, "POST", route, {
|
|
1478
|
+
kind,
|
|
1479
|
+
objectName,
|
|
1480
|
+
});
|
|
1481
|
+
say(`${green("✓")} bound ${kind}/${objectName} ${dim(`· cluster r${result.revision}`)}`);
|
|
1482
|
+
say(dim(" resolved by the platform at launch · contents unknown to Mend"));
|
|
1483
|
+
return appliesLine();
|
|
1484
|
+
}
|
|
1485
|
+
if (verb === "remove") {
|
|
1486
|
+
const [ref] = rest;
|
|
1487
|
+
const [kind, objectName] = ref?.split("/", 2) ?? [];
|
|
1488
|
+
if ((kind !== "secret" && kind !== "configmap") || objectName === undefined) {
|
|
1489
|
+
return fail(`env cluster remove takes <kind>/<name>, e.g. secret/app-env — ${usage}`);
|
|
1490
|
+
}
|
|
1491
|
+
const snapshot = await api(config, "GET", route);
|
|
1492
|
+
const binding = snapshot.bindings.find((b) => b.kind === kind && b.objectName === objectName);
|
|
1493
|
+
if (binding === undefined)
|
|
1494
|
+
return fail(`${kind}/${objectName} is not bound on ${project.name}`);
|
|
1495
|
+
const result = await api(config, "DELETE", `${route}/${binding.id}`);
|
|
1496
|
+
say(`${green("✓")} removed ${kind}/${objectName} ${dim(`· cluster r${result.revision}`)}`);
|
|
1497
|
+
return appliesLine();
|
|
1498
|
+
}
|
|
1499
|
+
if (verb === "sa") {
|
|
1500
|
+
const clear = args.includes("--clear");
|
|
1501
|
+
const [name] = rest;
|
|
1502
|
+
if (!clear && name === undefined)
|
|
1503
|
+
return fail(`env cluster sa takes a name or --clear — ${usage}`);
|
|
1504
|
+
const result = await api(config, "PUT", `${route}/service-account`, { serviceAccount: clear ? null : name });
|
|
1505
|
+
say(result.serviceAccount === null
|
|
1506
|
+
? `${green("✓")} cleared the workspace service account ${dim(`· cluster r${result.revision}`)}`
|
|
1507
|
+
: `${green("✓")} service account ${result.serviceAccount} ${dim(`· cluster r${result.revision}`)}`);
|
|
1508
|
+
if (result.serviceAccount !== null) {
|
|
1509
|
+
say(amber(" the session agent holds this role's full permissions for the whole session — bind a least-privilege role intended for untrusted code; names outside the platform allowlist fail the launch"));
|
|
1510
|
+
}
|
|
1511
|
+
return appliesLine();
|
|
1512
|
+
}
|
|
1513
|
+
return fail(`unknown env cluster command "${verb ?? ""}" — ${usage}`);
|
|
1469
1514
|
};
|
|
1470
1515
|
const envCommand = async (config, args) => {
|
|
1471
1516
|
const [verb, ...rest] = args;
|
|
1472
1517
|
switch (verb) {
|
|
1473
1518
|
case "load":
|
|
1474
1519
|
return envLoad(config, rest);
|
|
1520
|
+
case "cluster":
|
|
1521
|
+
return envCluster(config, rest);
|
|
1475
1522
|
case "show":
|
|
1476
1523
|
case undefined:
|
|
1477
1524
|
return envShow(config, rest);
|
|
1478
1525
|
default:
|
|
1479
|
-
return fail(`unknown env command "${verb}" — try: mend env load | show`);
|
|
1526
|
+
return fail(`unknown env command "${verb}" — try: mend env load | show | cluster`);
|
|
1480
1527
|
}
|
|
1481
1528
|
};
|
|
1482
1529
|
const dotfilesCommand = async (config, args) => {
|
|
@@ -1971,7 +2018,10 @@ const dashboard = async (config) => {
|
|
|
1971
2018
|
const HELP = `mend — the agent workbench
|
|
1972
2019
|
|
|
1973
2020
|
start
|
|
1974
|
-
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
|
|
1975
2025
|
mend connect <provider> [--from-stdin] [--remove]
|
|
1976
2026
|
send THIS machine's claude/codex/github credential to the
|
|
1977
2027
|
platform under your own user (reads the file the provider's
|
|
@@ -1992,7 +2042,7 @@ start
|
|
|
1992
2042
|
|
|
1993
2043
|
everything else
|
|
1994
2044
|
mend the dashboard: every project and session, live
|
|
1995
|
-
mend logout
|
|
2045
|
+
mend logout revoke this terminal's device token and forget it
|
|
1996
2046
|
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1997
2047
|
mend keys show print the public key — add it as a deploy key on your git host
|
|
1998
2048
|
mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
|
|
@@ -2032,7 +2082,7 @@ everything else
|
|
|
2032
2082
|
|
|
2033
2083
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
2034
2084
|
detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
|
|
2035
|
-
config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
|
|
2085
|
+
config file: ~/.config/mend/cli.json { "url": ..., "token": ..., "deviceId": ... }
|
|
2036
2086
|
`;
|
|
2037
2087
|
const main = async () => {
|
|
2038
2088
|
const [command, ...rest] = process.argv.slice(2);
|