@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/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) ───────────────
@@ -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 && secrets.secrets.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 width = Math.max(...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length));
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 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
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 forget the saved token
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);