@sealant/mend 0.2.0 → 0.4.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 +12 -1
- package/dist/env.js +17 -0
- package/dist/main.js +203 -2
- package/package.json +1 -1
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(
|
|
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) => {
|
|
@@ -935,8 +1040,9 @@ const dotfilesShow = async (config) => {
|
|
|
935
1040
|
}
|
|
936
1041
|
else {
|
|
937
1042
|
const branch = dotfiles.repository.ref ?? "default branch";
|
|
1043
|
+
const subdir = dotfiles.repository.subdirectory === null ? [] : [`${dotfiles.repository.subdirectory}/`];
|
|
938
1044
|
const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
|
|
939
|
-
say(`repo ${dotfiles.repository.url} ${dim(`(${branch
|
|
1045
|
+
say(`repo ${dotfiles.repository.url} ${dim(`(${[branch, ...subdir, bootstrap].join(" · ")})`)}`);
|
|
940
1046
|
}
|
|
941
1047
|
if (dotfiles.snapshot === null) {
|
|
942
1048
|
say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
|
|
@@ -991,6 +1097,89 @@ const dotfilesSync = async (config, args) => {
|
|
|
991
1097
|
return fail("the server accepted the sync but reports no snapshot");
|
|
992
1098
|
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
1099
|
};
|
|
1100
|
+
const envLoad = async (config, args) => {
|
|
1101
|
+
const explicitProject = takeFlagValue(args, "--project");
|
|
1102
|
+
// `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
|
|
1103
|
+
// ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
|
|
1104
|
+
const secretFlag = args.indexOf("--secret");
|
|
1105
|
+
const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
|
|
1106
|
+
const secretNames = secretArg !== undefined &&
|
|
1107
|
+
!secretArg.startsWith("--") &&
|
|
1108
|
+
!secretArg.includes("/") &&
|
|
1109
|
+
!secretArg.includes(".")
|
|
1110
|
+
? secretArg
|
|
1111
|
+
.split(",")
|
|
1112
|
+
.map((s) => s.trim())
|
|
1113
|
+
.filter((s) => s !== "")
|
|
1114
|
+
: [];
|
|
1115
|
+
const allSecret = secretFlag !== -1 && secretNames.length === 0;
|
|
1116
|
+
const consumed = new Set();
|
|
1117
|
+
if (secretFlag !== -1 && secretNames.length > 0)
|
|
1118
|
+
consumed.add(secretFlag + 1);
|
|
1119
|
+
const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project" && !consumed.has(i));
|
|
1120
|
+
const file = path.resolve(positional[0] ?? ".env");
|
|
1121
|
+
let contents;
|
|
1122
|
+
try {
|
|
1123
|
+
contents = fs.readFileSync(file, "utf8");
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
return fail(`cannot read ${file} — pass a path: mend env load path/to/.env`);
|
|
1127
|
+
}
|
|
1128
|
+
if (contents.trim() === "") {
|
|
1129
|
+
say(dim(`${file} is empty`));
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const project = await findProject(config, explicitProject);
|
|
1133
|
+
const report = await api(config, "POST", `/projects/${project.id}/environment/load`, { contents, allSecret, secretNames });
|
|
1134
|
+
if (report.loaded.length === 0 &&
|
|
1135
|
+
report.rejected.length === 0 &&
|
|
1136
|
+
report.malformedLines.length === 0) {
|
|
1137
|
+
say(dim(`${path.basename(file)} has no variables`));
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
say(`${green("✓")} loaded ${path.basename(file)} into ${project.name}`);
|
|
1141
|
+
for (const line of formatLoadReport(report, { dim, warn: amber }))
|
|
1142
|
+
say(line);
|
|
1143
|
+
const plaintextUrls = report.loaded.filter((entry) => entry.lane === "configuration" && /_(URL|URI|DSN)$/.test(entry.name));
|
|
1144
|
+
if (plaintextUrls.length > 0) {
|
|
1145
|
+
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(",")}`));
|
|
1146
|
+
}
|
|
1147
|
+
if (report.malformedLines.length > 0) {
|
|
1148
|
+
say(amber(` skipped ${report.malformedLines.length} malformed line${report.malformedLines.length === 1 ? "" : "s"}: ${report.malformedLines.join(", ")}`));
|
|
1149
|
+
}
|
|
1150
|
+
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`));
|
|
1151
|
+
};
|
|
1152
|
+
const envShow = async (config, args) => {
|
|
1153
|
+
const project = await findProject(config, takeFlagValue(args, "--project"));
|
|
1154
|
+
const [environment, secrets] = await Promise.all([
|
|
1155
|
+
api(config, "GET", `/projects/${project.id}/environment`),
|
|
1156
|
+
api(config, "GET", `/projects/${project.id}/secrets`),
|
|
1157
|
+
]);
|
|
1158
|
+
say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision}`)}`);
|
|
1159
|
+
if (environment.variables.length === 0 && secrets.secrets.length === 0) {
|
|
1160
|
+
say(dim(` nothing stored — load a file: ${cobalt("mend env load")}`));
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const width = Math.max(...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length));
|
|
1164
|
+
for (const variable of environment.variables) {
|
|
1165
|
+
say(` ${variable.name.padEnd(width)} configuration ${dim("· plaintext")}`);
|
|
1166
|
+
}
|
|
1167
|
+
for (const secret of secrets.secrets) {
|
|
1168
|
+
say(` ${secret.name.padEnd(width)} secret ${dim("· value set, never shown")}`);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
const envCommand = async (config, args) => {
|
|
1172
|
+
const [verb, ...rest] = args;
|
|
1173
|
+
switch (verb) {
|
|
1174
|
+
case "load":
|
|
1175
|
+
return envLoad(config, rest);
|
|
1176
|
+
case "show":
|
|
1177
|
+
case undefined:
|
|
1178
|
+
return envShow(config, rest);
|
|
1179
|
+
default:
|
|
1180
|
+
return fail(`unknown env command "${verb}" — try: mend env load | show`);
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
994
1183
|
const dotfilesCommand = async (config, args) => {
|
|
995
1184
|
const [verb, ...rest] = args;
|
|
996
1185
|
switch (verb) {
|
|
@@ -1467,8 +1656,14 @@ const HELP = `mend — the agent workbench
|
|
|
1467
1656
|
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
1468
1657
|
adopt a repository into the store (default: cwd; any git
|
|
1469
1658
|
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
1659
|
+
mend login [--url <server>] sign in with email + password; saves the token (0600)
|
|
1660
|
+
mend logout forget the saved token
|
|
1470
1661
|
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1471
1662
|
mend keys show print the public key — add it as a deploy key on your git host
|
|
1663
|
+
mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
|
|
1664
|
+
secret-shaped names → secrets; --secret sends all (or the
|
|
1665
|
+
named ones, e.g. DATABASE_URL) to secrets
|
|
1666
|
+
mend env show what the project store holds — names only, never values
|
|
1472
1667
|
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
1473
1668
|
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
1474
1669
|
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
@@ -1518,10 +1713,16 @@ const main = async () => {
|
|
|
1518
1713
|
return shellCommand(config, rest);
|
|
1519
1714
|
case "service":
|
|
1520
1715
|
return serviceCommand(config, rest);
|
|
1716
|
+
case "login":
|
|
1717
|
+
return login(config, rest);
|
|
1718
|
+
case "logout":
|
|
1719
|
+
return logout(config);
|
|
1521
1720
|
case "keys":
|
|
1522
1721
|
return keysCommand(config, rest);
|
|
1523
1722
|
case "dotfiles":
|
|
1524
1723
|
return dotfilesCommand(config, rest);
|
|
1724
|
+
case "env":
|
|
1725
|
+
return envCommand(config, rest);
|
|
1525
1726
|
case "completions":
|
|
1526
1727
|
return completionsCommand(rest);
|
|
1527
1728
|
case "__complete":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|