@sealant/mend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,12 +31,23 @@ mend sessions [--all] [--project p] [--json]
31
31
  mend status active sessions (alias of mend sessions)
32
32
  ```
33
33
 
34
+ ## Signing in
35
+
36
+ ```
37
+ mend login # asks for the email + password of your Mend account, saves a token
38
+ mend login --url https://mend.example.com --email you@example.com
39
+ mend logout
40
+ ```
41
+
42
+ The token is stored 0600 in the CLI config below; every command uses it until `mend logout`. On a
43
+ dev instance with `MEND_STATIC_TOKEN` set, `MEND_TOKEN=<that value>` also works.
44
+
34
45
  ## Configuration
35
46
 
36
47
  | Source | What |
37
48
  | ------------------------- | ----------------------------------------------------------------------------------------- |
38
49
  | `MEND_URL` | The Mend server (default `http://localhost:3105`) |
39
- | `MEND_TOKEN` | Bearer token for that server |
50
+ | `MEND_TOKEN` | Bearer token for that server (normally written by `mend login`) |
40
51
  | `MEND_DETACH_KEY` | Set to `none` when an outer multiplexer detaches |
41
52
  | `~/.config/mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win; a pre-XDG `~/.mend/cli.json` keeps working |
42
53
 
package/dist/env.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `mend env`: load a dotenv file into the project's env store, and show what is stored. Parsing
3
+ * AND routing happen SERVER-SIDE (one parser for the CLI and the browser); the CLI ships the file
4
+ * text once and prints the per-name report. Values are never printed.
5
+ */
6
+ /** Render the report as terse status lines — names, lanes, actions, reasons; never a value. */
7
+ export const formatLoadReport = (report, paint) => {
8
+ const width = Math.max(0, ...report.loaded.map((entry) => entry.name.length), ...report.rejected.map((entry) => entry.name.length));
9
+ const lines = [];
10
+ for (const entry of report.loaded) {
11
+ lines.push(` ${entry.name.padEnd(width)} ${entry.lane === "secret" ? "secret" : "configuration"} ${paint.dim(`· ${entry.action}${entry.lane === "configuration" ? " · plaintext" : ""}`)}`);
12
+ }
13
+ for (const entry of report.rejected) {
14
+ lines.push(` ${entry.name.padEnd(width)} ${paint.warn("rejected")} ${paint.dim(`· ${entry.reason}`)}`);
15
+ }
16
+ return lines;
17
+ };
package/dist/main.js CHANGED
@@ -6,6 +6,7 @@ import * as net from "node:net";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
+ import { formatLoadReport } from "./env.js";
9
10
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
10
11
  import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
11
12
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
@@ -93,7 +94,9 @@ const request = async (config, method, route, body) => {
93
94
  throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
94
95
  }
95
96
  if (response.status === 401) {
96
- throw new Error(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
97
+ throw new Error(config.token === null
98
+ ? `not signed in to ${config.url} — run: mend login`
99
+ : `unauthorized at ${config.url} — the saved token was rejected; run: mend login`);
97
100
  }
98
101
  const text = await response.text();
99
102
  if (!response.ok) {
@@ -767,6 +770,108 @@ const serviceCommand = async (config, args) => {
767
770
  return serviceRun(config, [verb, ...rest]);
768
771
  }
769
772
  };
773
+ // ─── login: obtain and save the bearer token ────────────────────────────────
774
+ const takeFlagValue = (args, flag) => {
775
+ const at = args.indexOf(flag);
776
+ return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
777
+ };
778
+ /**
779
+ * Read a password: raw-mode without echo on a TTY; on a pipe, one line through the SAME readline
780
+ * that read the email (a second reader would find the buffered pipe already drained).
781
+ */
782
+ const readSecretLine = async (prompt, rl) => {
783
+ if (process.stdin.isTTY !== true) {
784
+ return (await rl.question(prompt)).trim();
785
+ }
786
+ process.stdout.write(prompt);
787
+ return new Promise((resolve) => {
788
+ const stdin = process.stdin;
789
+ stdin.setRawMode(true);
790
+ stdin.resume();
791
+ stdin.setEncoding("utf8");
792
+ let buffer = "";
793
+ const onData = (chunk) => {
794
+ for (const char of chunk) {
795
+ if (char === "\r" || char === "\n") {
796
+ stdin.setRawMode(false);
797
+ stdin.pause();
798
+ stdin.off("data", onData);
799
+ process.stdout.write("\n");
800
+ resolve(buffer);
801
+ return;
802
+ }
803
+ if (char === "\u0003") {
804
+ stdin.setRawMode(false);
805
+ process.stdout.write("\n");
806
+ process.exit(130);
807
+ }
808
+ if (char === "\u007f" || char === "\b") {
809
+ buffer = buffer.slice(0, -1);
810
+ continue;
811
+ }
812
+ buffer += char;
813
+ }
814
+ };
815
+ stdin.on("data", onData);
816
+ });
817
+ };
818
+ const saveCliConfig = (next) => {
819
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
820
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ url: next.url, token: next.token }, null, 2)}\n`, { mode: 0o600 });
821
+ fs.chmodSync(CONFIG_PATH, 0o600);
822
+ };
823
+ /**
824
+ * `mend login [--url <server>] [--email <address>]`: sign in with email + password against the
825
+ * server's auth endpoint (the bearer plugin answers with a `set-auth-token` header) and save the
826
+ * token 0600 in the CLI config. Nothing else asks for a password again.
827
+ */
828
+ const login = async (config, args) => {
829
+ const url = takeFlagValue(args, "--url") ?? process.env["MEND_URL"] ?? config.url;
830
+ const emailFlag = takeFlagValue(args, "--email");
831
+ const readline = await import("node:readline/promises");
832
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
833
+ const email = emailFlag ?? (await rl.question(`email for ${url}: `)).trim();
834
+ if (email === "") {
835
+ rl.close();
836
+ return fail("an email is required");
837
+ }
838
+ const password = await readSecretLine("password: ", rl);
839
+ rl.close();
840
+ if (password === "")
841
+ return fail("a password is required");
842
+ let response;
843
+ try {
844
+ response = await fetch(`${url}/api/auth/sign-in/email`, {
845
+ method: "POST",
846
+ // better-auth's CSRF check rejects the `Origin: null` a non-browser fetch sends; the
847
+ // server's own URL is always a trusted origin, so present that.
848
+ headers: { "content-type": "application/json", origin: url },
849
+ body: JSON.stringify({ email, password }),
850
+ });
851
+ }
852
+ catch {
853
+ return fail(`cannot reach the Mend server at ${url} — is it running?`);
854
+ }
855
+ if (response.status === 401 || response.status === 403 || response.status === 400) {
856
+ return fail(`sign-in refused for ${email} at ${url}`);
857
+ }
858
+ if (!response.ok)
859
+ return fail(`sign-in failed: ${url} responded ${response.status}`);
860
+ const token = response.headers.get("set-auth-token");
861
+ if (token === null || token === "") {
862
+ return fail("the server signed you in but returned no bearer token (bearer plugin missing?)");
863
+ }
864
+ saveCliConfig({ ...config, url, token });
865
+ say(`${green("✓")} signed in as ${email} · ${dim(`token saved to ${CONFIG_PATH}`)}`);
866
+ };
867
+ const logout = (config) => {
868
+ if (!fs.existsSync(CONFIG_PATH)) {
869
+ say(dim("nothing saved — already signed out"));
870
+ return;
871
+ }
872
+ saveCliConfig({ ...config, token: null });
873
+ say(`${green("✓")} signed out · ${dim(`token removed from ${CONFIG_PATH}`)}`);
874
+ };
770
875
  // ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
771
876
  /** Print the public key with the one instruction that makes it useful. */
772
877
  const printGitKey = (key) => {
@@ -991,6 +1096,89 @@ const dotfilesSync = async (config, args) => {
991
1096
  return fail("the server accepted the sync but reports no snapshot");
992
1097
  say(`${green("synced")} ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} from ${os.hostname()} · ${dim(snapshot.sha.slice(0, 7))} ${dim("— applies from the next session launch")}`);
993
1098
  };
1099
+ const envLoad = async (config, args) => {
1100
+ const explicitProject = takeFlagValue(args, "--project");
1101
+ // `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
1102
+ // ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
1103
+ const secretFlag = args.indexOf("--secret");
1104
+ const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
1105
+ const secretNames = secretArg !== undefined &&
1106
+ !secretArg.startsWith("--") &&
1107
+ !secretArg.includes("/") &&
1108
+ !secretArg.includes(".")
1109
+ ? secretArg
1110
+ .split(",")
1111
+ .map((s) => s.trim())
1112
+ .filter((s) => s !== "")
1113
+ : [];
1114
+ const allSecret = secretFlag !== -1 && secretNames.length === 0;
1115
+ const consumed = new Set();
1116
+ if (secretFlag !== -1 && secretNames.length > 0)
1117
+ consumed.add(secretFlag + 1);
1118
+ const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project" && !consumed.has(i));
1119
+ const file = path.resolve(positional[0] ?? ".env");
1120
+ let contents;
1121
+ try {
1122
+ contents = fs.readFileSync(file, "utf8");
1123
+ }
1124
+ catch {
1125
+ return fail(`cannot read ${file} — pass a path: mend env load path/to/.env`);
1126
+ }
1127
+ if (contents.trim() === "") {
1128
+ say(dim(`${file} is empty`));
1129
+ return;
1130
+ }
1131
+ const project = await findProject(config, explicitProject);
1132
+ const report = await api(config, "POST", `/projects/${project.id}/environment/load`, { contents, allSecret, secretNames });
1133
+ if (report.loaded.length === 0 &&
1134
+ report.rejected.length === 0 &&
1135
+ report.malformedLines.length === 0) {
1136
+ say(dim(`${path.basename(file)} has no variables`));
1137
+ return;
1138
+ }
1139
+ say(`${green("✓")} loaded ${path.basename(file)} into ${project.name}`);
1140
+ for (const line of formatLoadReport(report, { dim, warn: amber }))
1141
+ say(line);
1142
+ const plaintextUrls = report.loaded.filter((entry) => entry.lane === "configuration" && /_(URL|URI|DSN)$/.test(entry.name));
1143
+ if (plaintextUrls.length > 0) {
1144
+ say(amber(` ${plaintextUrls.map((entry) => entry.name).join(", ")} stored as plaintext configuration — if a value embeds a password, store it as a secret: mend env load --secret ${plaintextUrls.map((entry) => entry.name).join(",")}`));
1145
+ }
1146
+ if (report.malformedLines.length > 0) {
1147
+ say(amber(` skipped ${report.malformedLines.length} malformed line${report.malformedLines.length === 1 ? "" : "s"}: ${report.malformedLines.join(", ")}`));
1148
+ }
1149
+ say(dim(` configuration r${report.environmentRevision} · secrets r${report.secretRevision} — applies from the next workspace launch, including resume; running workspaces keep what they started with`));
1150
+ };
1151
+ const envShow = async (config, args) => {
1152
+ const project = await findProject(config, takeFlagValue(args, "--project"));
1153
+ const [environment, secrets] = await Promise.all([
1154
+ api(config, "GET", `/projects/${project.id}/environment`),
1155
+ api(config, "GET", `/projects/${project.id}/secrets`),
1156
+ ]);
1157
+ say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision}`)}`);
1158
+ if (environment.variables.length === 0 && secrets.secrets.length === 0) {
1159
+ say(dim(` nothing stored — load a file: ${cobalt("mend env load")}`));
1160
+ return;
1161
+ }
1162
+ const width = Math.max(...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length));
1163
+ for (const variable of environment.variables) {
1164
+ say(` ${variable.name.padEnd(width)} configuration ${dim("· plaintext")}`);
1165
+ }
1166
+ for (const secret of secrets.secrets) {
1167
+ say(` ${secret.name.padEnd(width)} secret ${dim("· value set, never shown")}`);
1168
+ }
1169
+ };
1170
+ const envCommand = async (config, args) => {
1171
+ const [verb, ...rest] = args;
1172
+ switch (verb) {
1173
+ case "load":
1174
+ return envLoad(config, rest);
1175
+ case "show":
1176
+ case undefined:
1177
+ return envShow(config, rest);
1178
+ default:
1179
+ return fail(`unknown env command "${verb}" — try: mend env load | show`);
1180
+ }
1181
+ };
994
1182
  const dotfilesCommand = async (config, args) => {
995
1183
  const [verb, ...rest] = args;
996
1184
  switch (verb) {
@@ -1467,8 +1655,14 @@ const HELP = `mend — the agent workbench
1467
1655
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
1468
1656
  adopt a repository into the store (default: cwd; any git
1469
1657
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
1658
+ mend login [--url <server>] sign in with email + password; saves the token (0600)
1659
+ mend logout forget the saved token
1470
1660
  mend keys init generate the machine's Mend deploy key (ed25519)
1471
1661
  mend keys show print the public key — add it as a deploy key on your git host
1662
+ mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
1663
+ secret-shaped names → secrets; --secret sends all (or the
1664
+ named ones, e.g. DATABASE_URL) to secrets
1665
+ mend env show what the project store holds — names only, never values
1472
1666
  mend dotfiles your dotfiles on the server: repo + synced home files
1473
1667
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1474
1668
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
@@ -1518,10 +1712,16 @@ const main = async () => {
1518
1712
  return shellCommand(config, rest);
1519
1713
  case "service":
1520
1714
  return serviceCommand(config, rest);
1715
+ case "login":
1716
+ return login(config, rest);
1717
+ case "logout":
1718
+ return logout(config);
1521
1719
  case "keys":
1522
1720
  return keysCommand(config, rest);
1523
1721
  case "dotfiles":
1524
1722
  return dotfilesCommand(config, rest);
1723
+ case "env":
1724
+ return envCommand(config, rest);
1525
1725
  case "completions":
1526
1726
  return completionsCommand(rest);
1527
1727
  case "__complete":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",