@sealant/mend 0.10.0 → 0.11.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 +6 -0
- package/dist/ssh-setup.js +181 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -12,6 +12,7 @@ import { loginCommand } from "./login.js";
|
|
|
12
12
|
import { pairCommand, qrCommand } from "./pair.js";
|
|
13
13
|
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
14
14
|
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
15
|
+
import { sshCommand } from "./ssh-setup.js";
|
|
15
16
|
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
16
17
|
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
17
18
|
const mendCliHome = () => {
|
|
@@ -2078,6 +2079,9 @@ everything else
|
|
|
2078
2079
|
mend sessions [--all] [--project p] [--json]
|
|
2079
2080
|
sessions with review facts; JSON is stable for integrations
|
|
2080
2081
|
mend status active sessions (alias of mend sessions)
|
|
2082
|
+
mend ssh workspace SSH status: gateway, registered keys, ssh config
|
|
2083
|
+
mend ssh setup [--key <path>] make this machine ready once: offer a key (ssh-agent
|
|
2084
|
+
preferred — nothing new created), write Host mend-ws
|
|
2081
2085
|
mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
|
|
2082
2086
|
|
|
2083
2087
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
@@ -2122,6 +2126,8 @@ const main = async () => {
|
|
|
2122
2126
|
return doctorCommand(config, localCredential);
|
|
2123
2127
|
case "env":
|
|
2124
2128
|
return envCommand(config, rest);
|
|
2129
|
+
case "ssh":
|
|
2130
|
+
return sshCommand(rest, boundApi(config), mendCliHome());
|
|
2125
2131
|
case "completions":
|
|
2126
2132
|
return completionsCommand(rest);
|
|
2127
2133
|
case "__complete":
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mend ssh`: workspace SSH for this machine (docs/WORKSPACE-SSH.md phase 1).
|
|
3
|
+
*
|
|
4
|
+
* `mend ssh` shows the observed state — the deployment's gateway, this user's registered keys,
|
|
5
|
+
* and whether `~/.ssh/config` carries the managed block. `mend ssh setup` makes this machine
|
|
6
|
+
* ready once: pick a public key (an explicit `--key` path; else the running ssh-agent's first
|
|
7
|
+
* key, so no new key material exists; else an existing dedicated key; else a freshly generated
|
|
8
|
+
* one), offer it to the platform under the signed-in user, and write one static Host block.
|
|
9
|
+
* The workspace id travels in the SSH username, so the block never changes per session:
|
|
10
|
+
* `ssh <prefix>-<workspaceId>@mend-ws` — and the VS Code extension connects the same way.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
export const SSH_HOST_ALIAS = "mend-ws";
|
|
17
|
+
const BLOCK_BEGIN = "# >>> mend workspace ssh (managed by `mend ssh setup`) >>>";
|
|
18
|
+
const BLOCK_END = "# <<< mend workspace ssh <<<";
|
|
19
|
+
/** The first key a running ssh-agent offers (`ssh-add -L` output), or null. */
|
|
20
|
+
export const firstAgentKey = (output) => {
|
|
21
|
+
for (const line of output.split("\n")) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
const parts = trimmed.split(/\s+/);
|
|
24
|
+
if (parts.length >= 2 && parts[0]?.startsWith("ssh-") === true)
|
|
25
|
+
return trimmed;
|
|
26
|
+
if (parts.length >= 2 && parts[0]?.startsWith("ecdsa-") === true)
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
/** The managed Host block. `identityFile` null = the ssh-agent supplies the key. */
|
|
32
|
+
export const managedSshConfigBlock = (input) => [
|
|
33
|
+
BLOCK_BEGIN,
|
|
34
|
+
`Host ${SSH_HOST_ALIAS}`,
|
|
35
|
+
` HostName ${input.host}`,
|
|
36
|
+
` Port ${input.port}`,
|
|
37
|
+
...(input.identityFile === null
|
|
38
|
+
? []
|
|
39
|
+
: [` IdentityFile ${input.identityFile}`, " IdentitiesOnly yes"]),
|
|
40
|
+
" StrictHostKeyChecking accept-new",
|
|
41
|
+
BLOCK_END,
|
|
42
|
+
"",
|
|
43
|
+
].join("\n");
|
|
44
|
+
/** Replace the managed block in an ssh config, or append it; everything else stays verbatim. */
|
|
45
|
+
export const upsertManagedBlock = (existing, block) => {
|
|
46
|
+
const begin = existing.indexOf(BLOCK_BEGIN);
|
|
47
|
+
const end = existing.indexOf(BLOCK_END);
|
|
48
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
49
|
+
const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
|
|
50
|
+
return `${existing.slice(0, begin)}${block}${after}`;
|
|
51
|
+
}
|
|
52
|
+
if (existing === "")
|
|
53
|
+
return block;
|
|
54
|
+
return `${existing.replace(/\n*$/, "\n\n")}${block}`;
|
|
55
|
+
};
|
|
56
|
+
/** Whether an ssh config already carries the managed block. */
|
|
57
|
+
export const hasManagedBlock = (config) => config.includes(BLOCK_BEGIN);
|
|
58
|
+
const ansi = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}[0m` : text;
|
|
59
|
+
const dim = ansi("2");
|
|
60
|
+
const green = ansi("32");
|
|
61
|
+
const warn = ansi("33");
|
|
62
|
+
const say = (line) => console.log(line);
|
|
63
|
+
const readIfPresent = (file) => {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(file, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/** Resolve the public key to offer, favoring what already exists over creating anything. */
|
|
72
|
+
const pickKey = (cliHome, keyFlag) => {
|
|
73
|
+
if (keyFlag !== null) {
|
|
74
|
+
const pubPath = keyFlag.endsWith(".pub") ? keyFlag : `${keyFlag}.pub`;
|
|
75
|
+
const publicKey = readIfPresent(pubPath).trim();
|
|
76
|
+
if (publicKey === "")
|
|
77
|
+
return { error: `no public key at ${pubPath}` };
|
|
78
|
+
return {
|
|
79
|
+
publicKey,
|
|
80
|
+
identityFile: pubPath.endsWith(".pub") ? pubPath.slice(0, -".pub".length) : pubPath,
|
|
81
|
+
source: "flag",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (process.env["SSH_AUTH_SOCK"] !== undefined && process.env["SSH_AUTH_SOCK"] !== "") {
|
|
85
|
+
const listed = spawnSync("ssh-add", ["-L"], { encoding: "utf8" });
|
|
86
|
+
if (listed.status === 0) {
|
|
87
|
+
const key = firstAgentKey(listed.stdout);
|
|
88
|
+
if (key !== null)
|
|
89
|
+
return { publicKey: key, identityFile: null, source: "agent" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const dedicated = path.join(cliHome, "ssh", "id_ed25519");
|
|
93
|
+
const existing = readIfPresent(`${dedicated}.pub`).trim();
|
|
94
|
+
if (existing !== "")
|
|
95
|
+
return { publicKey: existing, identityFile: dedicated, source: "existing" };
|
|
96
|
+
fs.mkdirSync(path.dirname(dedicated), { recursive: true, mode: 0o700 });
|
|
97
|
+
const generated = spawnSync("ssh-keygen", ["-t", "ed25519", "-f", dedicated, "-N", "", "-C", `mend-${os.hostname()}`], { encoding: "utf8" });
|
|
98
|
+
if (generated.status !== 0) {
|
|
99
|
+
return { error: `ssh-keygen failed: ${generated.stderr.trim() || "unknown error"}` };
|
|
100
|
+
}
|
|
101
|
+
const fresh = readIfPresent(`${dedicated}.pub`).trim();
|
|
102
|
+
if (fresh === "")
|
|
103
|
+
return { error: `ssh-keygen reported success but ${dedicated}.pub is missing` };
|
|
104
|
+
return { publicKey: fresh, identityFile: dedicated, source: "generated" };
|
|
105
|
+
};
|
|
106
|
+
const sshConfigPath = () => path.join(os.homedir(), ".ssh", "config");
|
|
107
|
+
const showStatus = async (api) => {
|
|
108
|
+
const view = await api("GET", "/workspace-ssh");
|
|
109
|
+
if (view.gateway === null) {
|
|
110
|
+
say(`workspace ssh ${warn("no gateway")} ${dim("· this deployment exposes none")}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
say(`gateway ${view.gateway.host}:${view.gateway.port} ${dim(`· username ${view.gateway.usernamePrefix}-<workspace-id>`)}`);
|
|
114
|
+
if (view.keys.length === 0) {
|
|
115
|
+
say(`keys ${warn("none registered")} ${dim("· run: mend ssh setup")}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
for (const key of view.keys) {
|
|
119
|
+
say(`key ${key.name} ${dim(`· ${key.fingerprint}`)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const configured = hasManagedBlock(readIfPresent(sshConfigPath()));
|
|
123
|
+
say(configured
|
|
124
|
+
? `ssh config ${green("●")} Host ${SSH_HOST_ALIAS} ${dim(`· ${sshConfigPath()}`)}`
|
|
125
|
+
: `ssh config ${warn("no managed block")} ${dim("· run: mend ssh setup")}`);
|
|
126
|
+
};
|
|
127
|
+
const setup = async (api, cliHome, args) => {
|
|
128
|
+
const keyFlagIndex = args.indexOf("--key");
|
|
129
|
+
const keyFlag = keyFlagIndex !== -1 && args[keyFlagIndex + 1] !== undefined
|
|
130
|
+
? String(args[keyFlagIndex + 1])
|
|
131
|
+
: null;
|
|
132
|
+
const view = await api("GET", "/workspace-ssh");
|
|
133
|
+
if (view.gateway === null) {
|
|
134
|
+
say(`mend: this deployment exposes no workspace SSH gateway`);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const picked = pickKey(cliHome, keyFlag);
|
|
139
|
+
if ("error" in picked) {
|
|
140
|
+
say(`mend: ${picked.error}`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const sourceLabel = {
|
|
145
|
+
flag: "from --key",
|
|
146
|
+
agent: "from your ssh-agent (nothing new created)",
|
|
147
|
+
existing: "existing dedicated key",
|
|
148
|
+
generated: "generated dedicated key",
|
|
149
|
+
}[picked.source];
|
|
150
|
+
const registered = await api("POST", "/workspace-ssh/keys", {
|
|
151
|
+
publicKey: picked.publicKey,
|
|
152
|
+
name: os.hostname(),
|
|
153
|
+
});
|
|
154
|
+
say(`key ${green("●")} ${registered.fingerprint} ${dim(`· ${sourceLabel}`)}`);
|
|
155
|
+
const configFile = sshConfigPath();
|
|
156
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true, mode: 0o700 });
|
|
157
|
+
const block = managedSshConfigBlock({
|
|
158
|
+
host: view.gateway.host,
|
|
159
|
+
port: view.gateway.port,
|
|
160
|
+
identityFile: picked.identityFile,
|
|
161
|
+
});
|
|
162
|
+
const before = readIfPresent(configFile);
|
|
163
|
+
fs.writeFileSync(configFile, upsertManagedBlock(before, block), { mode: 0o600 });
|
|
164
|
+
say(`ssh config ${green("●")} Host ${SSH_HOST_ALIAS} ${dim(`· ${configFile}`)}`);
|
|
165
|
+
say("");
|
|
166
|
+
say(`connect with ssh ${view.gateway.usernamePrefix}-<workspace-id>@${SSH_HOST_ALIAS} ${dim("· the VS Code extension uses this automatically")}`);
|
|
167
|
+
};
|
|
168
|
+
export const sshCommand = async (args, api, cliHome) => {
|
|
169
|
+
const [subcommand, ...rest] = args;
|
|
170
|
+
switch (subcommand) {
|
|
171
|
+
case undefined:
|
|
172
|
+
case "status":
|
|
173
|
+
return showStatus(api);
|
|
174
|
+
case "setup":
|
|
175
|
+
return setup(api, cliHome, rest);
|
|
176
|
+
default:
|
|
177
|
+
say(`mend: unknown ssh subcommand "${subcommand}" — try: mend ssh · mend ssh setup [--key <path>]`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|