moshcode 0.90.0 → 0.91.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 +91 -0
- package/bin/moshcode.mjs +7 -0
- package/package.json +1 -1
- package/prd/0013-persistent-ssh-workspaces.md +1181 -0
- package/prd/README.md +2 -0
- package/src/cli-schema.mjs +109 -0
- package/src/commands.mjs +164 -0
- package/src/ssh.mjs +1228 -0
- package/src/tui.mjs +14 -0
package/src/ssh.mjs
ADDED
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
// Persistent SSH workspaces — one authenticated transport, many clean commands
|
|
2
|
+
// (PRD 0013).
|
|
3
|
+
//
|
|
4
|
+
// A coding run against a remote box is a few hundred small operations: read a
|
|
5
|
+
// file, `git status`, apply a patch, run the tests. Spawning `ssh` for each one
|
|
6
|
+
// is fine; paying for a TCP handshake, key exchange, host-key check and
|
|
7
|
+
// authentication for each one is not, and that is what a fresh `ssh user@host
|
|
8
|
+
// cmd` costs every time. OpenSSH has carried the fix for twenty years:
|
|
9
|
+
// ControlMaster keeps one authenticated connection alive and later clients
|
|
10
|
+
// open channels on it through a Unix socket, so the second command costs a
|
|
11
|
+
// socket connect instead of a handshake. Measured on a loopback sshd, that is
|
|
12
|
+
// ~12ms a command against ~96ms — and on a real network the handshake is the
|
|
13
|
+
// part that grows.
|
|
14
|
+
//
|
|
15
|
+
// So this module is a thin, careful wrapper over that feature. It owns:
|
|
16
|
+
//
|
|
17
|
+
// · the registry of named targets (~/.moshcode/ssh/targets.json) — a name,
|
|
18
|
+
// a host or ssh_config alias, a port, a default cwd. Never a password or a
|
|
19
|
+
// key: OpenSSH already has ~/.ssh, an agent, and a known_hosts, and every
|
|
20
|
+
// one of those stays authoritative;
|
|
21
|
+
// · the control socket for each target, in a directory only this user can
|
|
22
|
+
// read, at a path short enough for sun_path;
|
|
23
|
+
// · open / check / close, spelled with ssh's own `-O` control operations
|
|
24
|
+
// rather than by tracking and killing PIDs;
|
|
25
|
+
// · exec: a remote command built from argv with real quoting, no PTY unless
|
|
26
|
+
// asked, stdin forwarded raw, stdout/stderr/exit status returned as data;
|
|
27
|
+
// · attach, put/get over scp, and an optional remote tmux shell for the
|
|
28
|
+
// workflows that genuinely need shell state.
|
|
29
|
+
//
|
|
30
|
+
// It does NOT implement SSH, and never will. No ssh2, no node-pty, no libssh.
|
|
31
|
+
// The `ssh` on PATH is the implementation; this file decides what to ask it.
|
|
32
|
+
//
|
|
33
|
+
// Two OpenSSH facts shaped the invocations below, both found by running them:
|
|
34
|
+
//
|
|
35
|
+
// 1. `-M` and `-o ControlMaster=yes` together do not mean "yes, twice". ssh
|
|
36
|
+
// reads a second request for master mode as a request for *ask* mode —
|
|
37
|
+
// every later client then triggers an askpass prompt, and with no askpass
|
|
38
|
+
// the answer is "Master refused session request: Permission denied". The
|
|
39
|
+
// master here is `-o ControlMaster=yes -N -f`, and `-M` never appears.
|
|
40
|
+
//
|
|
41
|
+
// 2. `ControlMaster=auto` on a client is the stale-socket recovery the PRD
|
|
42
|
+
// asks for, natively: a socket nobody is listening on gets unlinked and
|
|
43
|
+
// the client becomes the new master. exec runs with `auto` so a master
|
|
44
|
+
// that died between two commands costs one reconnect, not an error.
|
|
45
|
+
import { spawnSync } from "node:child_process";
|
|
46
|
+
import crypto from "node:crypto";
|
|
47
|
+
import fs from "node:fs";
|
|
48
|
+
import os from "node:os";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
import { ash, bone, err, info, ok, table, warn } from "./ui.mjs";
|
|
51
|
+
|
|
52
|
+
/* ------------------------------------------------------------ constants */
|
|
53
|
+
|
|
54
|
+
/** Where the last client's disconnect leaves the master alive, by default. */
|
|
55
|
+
export const DEFAULT_PERSIST = "10m";
|
|
56
|
+
|
|
57
|
+
/** Keepalives for a transport an unattended agent is relying on (R14). */
|
|
58
|
+
export const KEEPALIVE = { ServerAliveInterval: 30, ServerAliveCountMax: 3 };
|
|
59
|
+
|
|
60
|
+
/** `MOSHCODE_SSH_PERSIST=30m` overrides the default persist window. */
|
|
61
|
+
export const PERSIST_ENV = "MOSHCODE_SSH_PERSIST";
|
|
62
|
+
|
|
63
|
+
/** `MOSHCODE_SSH_CONFIG=<file>` points ssh at a config other than ~/.ssh/config. */
|
|
64
|
+
export const CONFIG_ENV = "MOSHCODE_SSH_CONFIG";
|
|
65
|
+
|
|
66
|
+
/** `MOSHCODE_SSH_DEBUG=1` prints each ssh argv to stderr — redacted, see debugLine. */
|
|
67
|
+
export const DEBUG_ENV = "MOSHCODE_SSH_DEBUG";
|
|
68
|
+
|
|
69
|
+
/** How long ssh itself waits on a TCP connect before giving up. */
|
|
70
|
+
export const CONNECT_TIMEOUT = 20;
|
|
71
|
+
|
|
72
|
+
/** 64 MiB of stdout is a file listing gone wrong, not a use case. */
|
|
73
|
+
const MAX_OUTPUT = 64 * 1024 * 1024;
|
|
74
|
+
|
|
75
|
+
/** The longest control-socket path we will ask the kernel to bind (R16). */
|
|
76
|
+
const MAX_SOCKET_PATH = 100;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Target names are typed at a prompt, used as a filename component, and hashed
|
|
80
|
+
* into a socket path. The herd's shape, for the same reasons: nothing that
|
|
81
|
+
* could be a path separator, a traversal, or a tmux target separator (R66).
|
|
82
|
+
*/
|
|
83
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
84
|
+
export const validName = (name) => NAME_RE.test(String(name || ""));
|
|
85
|
+
|
|
86
|
+
/** Session names for remote tmux shells: same alphabet, so `dev/app` parses cleanly. */
|
|
87
|
+
export const SESSION_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
88
|
+
|
|
89
|
+
/** POSIX shell variable names, for --env K=V. */
|
|
90
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
91
|
+
|
|
92
|
+
/* ------------------------------------------------------------- registry */
|
|
93
|
+
|
|
94
|
+
/** Where the registry and, by default, the control sockets live. */
|
|
95
|
+
export function sshDir() {
|
|
96
|
+
return process.env.MOSHCODE_SSH_DIR || path.join(os.homedir(), ".moshcode", "ssh");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const targetsPath = () => path.join(sshDir(), "targets.json");
|
|
100
|
+
|
|
101
|
+
function ensureDir(dir) {
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
103
|
+
// mkdir's mode only applies on create; an older, looser directory is fixed
|
|
104
|
+
// here rather than trusted (R17, R65).
|
|
105
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* not ours to fix */ }
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the registry. Never throws: a corrupt or absent file is "no targets",
|
|
111
|
+
* which the caller can recover from, and every field is re-validated so a
|
|
112
|
+
* hand-edited file cannot smuggle a name the rest of this module refuses.
|
|
113
|
+
*/
|
|
114
|
+
export function readTargets() {
|
|
115
|
+
let raw;
|
|
116
|
+
try { raw = JSON.parse(fs.readFileSync(targetsPath(), "utf8")); } catch { return {}; }
|
|
117
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
120
|
+
if (!validName(name) || !entry || typeof entry !== "object" || !entry.target) continue;
|
|
121
|
+
out[name] = normalizeEntry(entry);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeEntry(entry) {
|
|
127
|
+
const port = Number.parseInt(entry.port, 10);
|
|
128
|
+
const out = { target: String(entry.target) };
|
|
129
|
+
if (Number.isInteger(port) && port > 0 && port < 65536) out.port = port;
|
|
130
|
+
if (entry.cwd) out.cwd = String(entry.cwd);
|
|
131
|
+
if (entry.persist) out.persist = String(entry.persist);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Write the registry: to a sibling temp file, then rename over the real one,
|
|
137
|
+
* both at 0600 (R67). Two pits saving at once cannot leave a half-written file
|
|
138
|
+
* behind, and `mode` on the temp file means the finished file never spends a
|
|
139
|
+
* moment world-readable. Nothing in it is secret today; the contract is that
|
|
140
|
+
* nothing ever will be, and the permissions say so anyway.
|
|
141
|
+
*/
|
|
142
|
+
export function writeTargets(targets) {
|
|
143
|
+
const dir = ensureDir(sshDir());
|
|
144
|
+
const file = targetsPath();
|
|
145
|
+
const tmp = path.join(dir, `.targets.${process.pid}.${Date.now()}.tmp`);
|
|
146
|
+
fs.writeFileSync(tmp, `${JSON.stringify(targets, null, 2)}\n`, { mode: 0o600 });
|
|
147
|
+
fs.renameSync(tmp, file);
|
|
148
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Fields the registry is allowed to hold. Anything else is dropped on write (R4). */
|
|
153
|
+
const ALLOWED_FIELDS = ["target", "port", "cwd", "persist"];
|
|
154
|
+
|
|
155
|
+
/** Names that read as a secret, refused as target fields no matter the value. */
|
|
156
|
+
const SECRET_FIELDS = /pass|secret|token|key|identity|phrase/i;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add or replace a target. `target` is whatever ssh accepts after its options —
|
|
160
|
+
* `user@host`, a bare host, or an alias from ~/.ssh/config (R5). It is not
|
|
161
|
+
* parsed here on purpose: ssh_config is the authority on what it means.
|
|
162
|
+
*/
|
|
163
|
+
export function addTarget(name, target, { port, cwd, persist } = {}) {
|
|
164
|
+
if (!validName(name)) {
|
|
165
|
+
throw new Error(`ssh: ${JSON.stringify(String(name))} is not a target name — lowercase letters, digits, - and _ only`);
|
|
166
|
+
}
|
|
167
|
+
const host = String(target || "").trim();
|
|
168
|
+
if (!host) throw new Error("ssh: a target needs a host — moshcode ssh add <name> <user@host | ssh-config alias>");
|
|
169
|
+
if (host.startsWith("-")) throw new Error(`ssh: ${JSON.stringify(host)} looks like a flag, not a host`);
|
|
170
|
+
const entry = { target: host };
|
|
171
|
+
if (port !== undefined && port !== null && port !== "") {
|
|
172
|
+
const n = Number.parseInt(port, 10);
|
|
173
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`ssh: --port ${JSON.stringify(String(port))} is not a port`);
|
|
174
|
+
entry.port = n;
|
|
175
|
+
}
|
|
176
|
+
if (cwd) entry.cwd = String(cwd);
|
|
177
|
+
if (persist) entry.persist = String(parsePersist(persist).text);
|
|
178
|
+
for (const field of Object.keys(entry)) {
|
|
179
|
+
if (!ALLOWED_FIELDS.includes(field) || SECRET_FIELDS.test(field)) delete entry[field];
|
|
180
|
+
}
|
|
181
|
+
const targets = readTargets();
|
|
182
|
+
const replaced = Boolean(targets[name]);
|
|
183
|
+
targets[name] = entry;
|
|
184
|
+
writeTargets(targets);
|
|
185
|
+
return { name, ...entry, replaced };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function removeTarget(name) {
|
|
189
|
+
const targets = readTargets();
|
|
190
|
+
if (!targets[name]) return false;
|
|
191
|
+
delete targets[name];
|
|
192
|
+
writeTargets(targets);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One target, or null. */
|
|
197
|
+
export function getTarget(name) {
|
|
198
|
+
const entry = readTargets()[String(name)];
|
|
199
|
+
return entry ? { name: String(name), ...entry } : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every target, name first, in registry order. */
|
|
203
|
+
export function listTargets() {
|
|
204
|
+
return Object.entries(readTargets()).map(([name, entry]) => ({ name, ...entry }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* --------------------------------------------------------- durations */
|
|
208
|
+
|
|
209
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* "10m" → seconds. Bare numbers are seconds, which is also what ssh's own
|
|
213
|
+
* ControlPersist takes, so the value can be handed straight through.
|
|
214
|
+
*/
|
|
215
|
+
export function parsePersist(text) {
|
|
216
|
+
const m = DURATION_RE.exec(String(text ?? "").trim());
|
|
217
|
+
if (!m) throw new Error(`ssh: ${JSON.stringify(String(text))} is not a duration — try 10m, 90s, 2h`);
|
|
218
|
+
const unit = (m[2] || "s").toLowerCase();
|
|
219
|
+
const mult = { ms: 1 / 1000, s: 1, m: 60, h: 3600, d: 86400 }[unit];
|
|
220
|
+
const seconds = Math.round(Number(m[1]) * mult);
|
|
221
|
+
if (seconds < 1) throw new Error(`ssh: a persist window under a second (${text}) would close the master before it is used`);
|
|
222
|
+
return { seconds, text: String(text).trim() };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** "2m" → milliseconds, for --timeout. Bare numbers are seconds. */
|
|
226
|
+
export function parseTimeout(text) {
|
|
227
|
+
if (text === undefined || text === null || text === "") return undefined;
|
|
228
|
+
const m = DURATION_RE.exec(String(text).trim());
|
|
229
|
+
if (!m) throw new Error(`ssh: ${JSON.stringify(String(text))} is not a duration — try 30s, 2m, 1h`);
|
|
230
|
+
const unit = (m[2] || "s").toLowerCase();
|
|
231
|
+
const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
|
|
232
|
+
const ms = Math.round(Number(m[1]) * mult);
|
|
233
|
+
if (ms < 1) throw new Error(`ssh: --timeout ${text} is not a usable timeout`);
|
|
234
|
+
return ms;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The persist window in effect: flag, then target, then env, then default. */
|
|
238
|
+
export function persistFor(entry, flag, env = process.env) {
|
|
239
|
+
return parsePersist(flag || entry?.persist || env[PERSIST_ENV] || DEFAULT_PERSIST);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* --------------------------------------------------------- control socket */
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Where the sockets go. ~/.moshcode/ssh/control unless the home directory is
|
|
246
|
+
* long enough to push the socket past sun_path (R16) — a NFS home like
|
|
247
|
+
* /net/filers/home/dept/anthony gets there — in which case a per-user
|
|
248
|
+
* directory under the OS temp dir. Overridable for tests and odd setups.
|
|
249
|
+
*/
|
|
250
|
+
export function controlDir() {
|
|
251
|
+
if (process.env.MOSHCODE_SSH_CONTROL_DIR) return process.env.MOSHCODE_SSH_CONTROL_DIR;
|
|
252
|
+
const preferred = path.join(sshDir(), "control");
|
|
253
|
+
if (path.join(preferred, "x".repeat(12)).length <= MAX_SOCKET_PATH) return preferred;
|
|
254
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "u";
|
|
255
|
+
return path.join(os.tmpdir(), `moshcode-ssh-${uid}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The socket for a target: a short hash of the name and where it points, so
|
|
260
|
+
* `ssh add dev` pointing somewhere new never reuses the master for where it
|
|
261
|
+
* used to point, and the path never carries `user@host:/srv/app` (R16).
|
|
262
|
+
*/
|
|
263
|
+
export function controlPath(entry) {
|
|
264
|
+
const key = [entry.name, entry.target, entry.port || ""].join("\0");
|
|
265
|
+
const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
266
|
+
return path.join(controlDir(), hash);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ensureControlDir() {
|
|
270
|
+
return ensureDir(controlDir());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ------------------------------------------------------------- quoting */
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* POSIX single-quoting: the only escape is `'` → `'\''`, and everything else
|
|
277
|
+
* is literal — including `$`, backticks, newlines, and the glob characters
|
|
278
|
+
* a model puts into a `sed` expression. Exactly what a remote command built
|
|
279
|
+
* from argv needs (R68).
|
|
280
|
+
*/
|
|
281
|
+
export function shellQuote(arg) {
|
|
282
|
+
const s = String(arg);
|
|
283
|
+
if (s === "") return "''";
|
|
284
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s;
|
|
285
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `cd` to a path that may start with `~`, which must stay outside the quotes to expand. */
|
|
289
|
+
export function cdCommand(cwd) {
|
|
290
|
+
const p = String(cwd);
|
|
291
|
+
if (p === "~") return "cd";
|
|
292
|
+
if (p.startsWith("~/")) return `cd -- ~/${shellQuote(p.slice(2))}`;
|
|
293
|
+
return `cd -- ${shellQuote(p)}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The string ssh hands the remote login shell. Built as
|
|
298
|
+
*
|
|
299
|
+
* cd -- '/srv/app' && K='v' exec 'git' 'apply' '-'
|
|
300
|
+
*
|
|
301
|
+
* so the cwd, the environment, and the argv each arrive exactly as given.
|
|
302
|
+
* `exec` keeps the shell from lingering as a parent — signals reach the
|
|
303
|
+
* command, and the exit status is the command's, not sh's opinion of it. With
|
|
304
|
+
* `sh: true` the single argument is a shell snippet and is passed verbatim,
|
|
305
|
+
* which is the one place a caller can mean `a | b`; it is a flag, never a
|
|
306
|
+
* guess about whether the argv "looks like" a pipeline.
|
|
307
|
+
*/
|
|
308
|
+
export function remoteCommand(argv, { cwd, env = {}, sh = false } = {}) {
|
|
309
|
+
const parts = [];
|
|
310
|
+
if (cwd) parts.push(cdCommand(cwd));
|
|
311
|
+
const assignments = Object.entries(env).map(([k, v]) => {
|
|
312
|
+
if (!ENV_KEY_RE.test(k)) throw new Error(`ssh: ${JSON.stringify(k)} is not an environment variable name`);
|
|
313
|
+
return `${k}=${shellQuote(v)}`;
|
|
314
|
+
});
|
|
315
|
+
let command;
|
|
316
|
+
if (sh) {
|
|
317
|
+
if (argv.length !== 1) throw new Error("ssh: --sh takes exactly one argument, the shell snippet");
|
|
318
|
+
command = assignments.length ? `export ${assignments.join(" ")} && ${argv[0]}` : String(argv[0]);
|
|
319
|
+
} else {
|
|
320
|
+
if (!argv.length) throw new Error("ssh: nothing to run — moshcode ssh exec <name> -- <command> [args…]");
|
|
321
|
+
command = [...assignments, "exec", ...argv.map(shellQuote)].join(" ");
|
|
322
|
+
}
|
|
323
|
+
parts.push(command);
|
|
324
|
+
return parts.join(" && ");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `--env K=V` pairs → object. */
|
|
328
|
+
export function parseEnvPairs(pairs = []) {
|
|
329
|
+
const env = {};
|
|
330
|
+
for (const pair of pairs) {
|
|
331
|
+
const i = String(pair).indexOf("=");
|
|
332
|
+
if (i < 1) throw new Error(`ssh: --env wants KEY=VALUE, got ${JSON.stringify(String(pair))}`);
|
|
333
|
+
const key = String(pair).slice(0, i);
|
|
334
|
+
if (!ENV_KEY_RE.test(key)) throw new Error(`ssh: ${JSON.stringify(key)} is not an environment variable name`);
|
|
335
|
+
env[key] = String(pair).slice(i + 1);
|
|
336
|
+
}
|
|
337
|
+
return env;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ---------------------------------------------------------- invocations */
|
|
341
|
+
|
|
342
|
+
/** Options every ssh we spawn carries: which socket, and which config. */
|
|
343
|
+
function baseOptions(entry, { env = process.env } = {}) {
|
|
344
|
+
const args = [];
|
|
345
|
+
if (env[CONFIG_ENV]) args.push("-F", env[CONFIG_ENV]);
|
|
346
|
+
args.push("-o", `ControlPath=${controlPath(entry)}`);
|
|
347
|
+
return args;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** `-p N` only when the registry says so; otherwise ssh_config decides. */
|
|
351
|
+
function portArgs(entry) {
|
|
352
|
+
return entry.port ? ["-p", String(entry.port)] : [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Keepalive options, unless the user's own config already sets an interval
|
|
357
|
+
* (R14). `ssh -G` prints the effective configuration for a host without
|
|
358
|
+
* connecting, so this is a config parse, not a round trip.
|
|
359
|
+
*/
|
|
360
|
+
export function keepaliveArgs(entry, { runner = spawnSync, env = process.env } = {}) {
|
|
361
|
+
const probe = runner("ssh", [...(env[CONFIG_ENV] ? ["-F", env[CONFIG_ENV]] : []), "-G", ...portArgs(entry), entry.target], {
|
|
362
|
+
encoding: "utf8", env, timeout: 5000,
|
|
363
|
+
});
|
|
364
|
+
const configured = /^serveraliveinterval\s+([1-9]\d*)/mi.test(String(probe?.stdout || ""));
|
|
365
|
+
if (configured) return [];
|
|
366
|
+
return Object.entries(KEEPALIVE).flatMap(([k, v]) => ["-o", `${k}=${v}`]);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** argv for `ssh -O <op>` against the target's socket. */
|
|
370
|
+
export function controlArgs(entry, op, { env = process.env } = {}) {
|
|
371
|
+
return [...baseOptions(entry, { env }), "-O", op, ...portArgs(entry), entry.target];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* argv for the master. `-N` (no command) and `-f` (background after auth), a
|
|
376
|
+
* finite ControlPersist, keepalives, and a connect timeout so a black-holed
|
|
377
|
+
* host answers in seconds rather than the kernel's minutes. `BatchMode=yes`
|
|
378
|
+
* only when nobody is at a terminal: it turns a password or passphrase prompt
|
|
379
|
+
* into a clean failure, which is right for an agent and wrong for a person
|
|
380
|
+
* who was about to type it (R70; see the PRD's open question).
|
|
381
|
+
*/
|
|
382
|
+
export function masterArgs(entry, { persist, batch, keepalive = [], env = process.env } = {}) {
|
|
383
|
+
const window = persistFor(entry, persist, env);
|
|
384
|
+
return [
|
|
385
|
+
...baseOptions(entry, { env }),
|
|
386
|
+
"-o", "ControlMaster=yes",
|
|
387
|
+
"-o", `ControlPersist=${window.seconds}`,
|
|
388
|
+
"-o", `ConnectTimeout=${CONNECT_TIMEOUT}`,
|
|
389
|
+
...(batch ? ["-o", "BatchMode=yes"] : []),
|
|
390
|
+
...keepalive,
|
|
391
|
+
...portArgs(entry),
|
|
392
|
+
"-N", "-f",
|
|
393
|
+
entry.target,
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* argv for one command over the master. `-T` — no PTY — is the default and
|
|
399
|
+
* the point: stdout and stderr stay separate, stdin stays binary, and nothing
|
|
400
|
+
* on the remote side thinks a person is watching (R20). `ControlMaster=auto`
|
|
401
|
+
* is the stale-socket fallback described at the top of the file.
|
|
402
|
+
*/
|
|
403
|
+
export function execArgs(entry, command, { tty = false, batch = true, persist, keepalive = [], env = process.env } = {}) {
|
|
404
|
+
const window = persistFor(entry, persist, env);
|
|
405
|
+
return [
|
|
406
|
+
...baseOptions(entry, { env }),
|
|
407
|
+
"-o", "ControlMaster=auto",
|
|
408
|
+
"-o", `ControlPersist=${window.seconds}`,
|
|
409
|
+
"-o", `ConnectTimeout=${CONNECT_TIMEOUT}`,
|
|
410
|
+
...(batch ? ["-o", "BatchMode=yes"] : []),
|
|
411
|
+
...keepalive,
|
|
412
|
+
...portArgs(entry),
|
|
413
|
+
tty ? "-t" : "-T",
|
|
414
|
+
entry.target,
|
|
415
|
+
"--",
|
|
416
|
+
command,
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* argv for an interactive session (R36–R40). ssh gets the terminal whole; the
|
|
422
|
+
* only thing added is a `cd` to the target's cwd, so `/ssh dev` lands where
|
|
423
|
+
* the work is. The login shell is the remote user's own — `$SHELL` there, not
|
|
424
|
+
* anything this side has an opinion about.
|
|
425
|
+
*/
|
|
426
|
+
export function attachArgs(entry, { persist, keepalive = [], env = process.env } = {}) {
|
|
427
|
+
const window = persistFor(entry, persist, env);
|
|
428
|
+
const args = [
|
|
429
|
+
...baseOptions(entry, { env }),
|
|
430
|
+
"-o", "ControlMaster=auto",
|
|
431
|
+
"-o", `ControlPersist=${window.seconds}`,
|
|
432
|
+
...keepalive,
|
|
433
|
+
...portArgs(entry),
|
|
434
|
+
];
|
|
435
|
+
if (entry.cwd) {
|
|
436
|
+
args.push("-t", entry.target, "--", `${cdCommand(entry.cwd)} && exec "\${SHELL:-sh}" -l`);
|
|
437
|
+
} else {
|
|
438
|
+
args.push(entry.target);
|
|
439
|
+
}
|
|
440
|
+
return args;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* argv for scp over the same socket. `-p` on scp is "preserve times", so the
|
|
445
|
+
* port is spelled `-P`; everything else rides on ControlPath and the config.
|
|
446
|
+
*/
|
|
447
|
+
export function scpArgs(entry, from, to, { env = process.env } = {}) {
|
|
448
|
+
const args = [];
|
|
449
|
+
if (env[CONFIG_ENV]) args.push("-F", env[CONFIG_ENV]);
|
|
450
|
+
args.push("-o", `ControlPath=${controlPath(entry)}`, "-o", "ControlMaster=auto", "-q");
|
|
451
|
+
if (entry.port) args.push("-P", String(entry.port));
|
|
452
|
+
return [...args, from, to];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* --------------------------------------------------------------- results */
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* What a spawn result means, in the two words an agent needs: did the
|
|
459
|
+
* *transport* work, and what did the *command* say (R24, R25). ssh reserves
|
|
460
|
+
* exit 255 for its own failures — connect, host key, auth — and everything
|
|
461
|
+
* else is the remote command's own status. The cases that are neither
|
|
462
|
+
* (ssh not installed, our timeout) are named as such.
|
|
463
|
+
*/
|
|
464
|
+
export function classify(res) {
|
|
465
|
+
if (!res) return { transportOk: false, code: null, signal: null, error: "ssh did not run" };
|
|
466
|
+
if (res.error?.code === "ENOENT") {
|
|
467
|
+
return { transportOk: false, code: null, signal: null, error: "ssh not found — install an OpenSSH client", missing: true };
|
|
468
|
+
}
|
|
469
|
+
if (res.error?.code === "ETIMEDOUT") {
|
|
470
|
+
return { transportOk: true, code: null, signal: res.signal || "SIGTERM", error: "timed out", timedOut: true };
|
|
471
|
+
}
|
|
472
|
+
if (res.error) {
|
|
473
|
+
return { transportOk: false, code: res.status ?? null, signal: res.signal || null, error: String(res.error.message || res.error) };
|
|
474
|
+
}
|
|
475
|
+
if (res.status === 255) {
|
|
476
|
+
return { transportOk: false, code: 255, signal: null, error: transportError(res.stderr) };
|
|
477
|
+
}
|
|
478
|
+
if (res.status === null && res.signal) {
|
|
479
|
+
return { transportOk: true, code: null, signal: res.signal, error: `killed by ${res.signal}` };
|
|
480
|
+
}
|
|
481
|
+
return { transportOk: true, code: res.status ?? 0, signal: null, error: null };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** ssh's last line of complaint, or a fallback, as a one-line reason. */
|
|
485
|
+
function transportError(stderr) {
|
|
486
|
+
const lines = String(stderr || "").split("\n").map((l) => l.trim()).filter(Boolean)
|
|
487
|
+
.filter((l) => !/^Warning: Permanently added/.test(l));
|
|
488
|
+
const last = lines.at(-1) || "";
|
|
489
|
+
if (/Permission denied|no supported authentication|Too many authentication/i.test(last)) return `ssh authentication failed: ${last}`;
|
|
490
|
+
if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(String(stderr))) return "ssh host key verification failed";
|
|
491
|
+
if (/Connection timed out|Operation timed out|Connection refused|Could not resolve|No route to host|Network is unreachable/i.test(last)) return `ssh could not connect: ${last}`;
|
|
492
|
+
return last ? `ssh failed: ${last}` : "ssh failed (exit 255)";
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* ---------------------------------------------------------------- debug */
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* One line per spawn to stderr when MOSHCODE_SSH_DEBUG is set. The remote
|
|
499
|
+
* command is summarised by length, not printed: it can carry `--env` values,
|
|
500
|
+
* and a debug log is exactly where a secret would otherwise end up (R63, R64,
|
|
501
|
+
* R69). stdin is never logged at all.
|
|
502
|
+
*/
|
|
503
|
+
export function debugLine(bin, args) {
|
|
504
|
+
const shown = [];
|
|
505
|
+
for (let i = 0; i < args.length; i++) {
|
|
506
|
+
if (args[i] === "--") { shown.push("--", `<remote command: ${Buffer.byteLength(String(args[i + 1] ?? ""))} bytes>`); break; }
|
|
507
|
+
shown.push(args[i]);
|
|
508
|
+
}
|
|
509
|
+
return `ssh▸ ${bin} ${shown.join(" ")}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function debug(env, bin, args) {
|
|
513
|
+
if (env[DEBUG_ENV] && env[DEBUG_ENV] !== "0") process.stderr.write(`${debugLine(bin, args)}\n`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* -------------------------------------------------------------- transport */
|
|
517
|
+
|
|
518
|
+
/** Resolve a target name, or explain why not. */
|
|
519
|
+
export function resolveTarget(name) {
|
|
520
|
+
if (!validName(name)) return { error: `ssh: ${JSON.stringify(String(name))} is not a target name` };
|
|
521
|
+
const entry = getTarget(name);
|
|
522
|
+
if (!entry) return { error: `ssh: no target named ${JSON.stringify(String(name))} — moshcode ssh add ${name} user@host` };
|
|
523
|
+
return { entry };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Is the master alive? `ssh -O check` asks the socket, locally, and answers
|
|
528
|
+
* in a millisecond. A socket that exists but nobody answers on is stale — the
|
|
529
|
+
* master died, the box rebooted — and is unlinked here, because it is ours
|
|
530
|
+
* and because ssh's own fallback would otherwise print "already exists,
|
|
531
|
+
* disabling multiplexing" and quietly reconnect for every command (R15).
|
|
532
|
+
*/
|
|
533
|
+
export function checkMaster(entry, { runner = spawnSync, env = process.env } = {}) {
|
|
534
|
+
const socket = controlPath(entry);
|
|
535
|
+
const exists = fs.existsSync(socket);
|
|
536
|
+
if (!exists) return { connected: false, socket, stale: false };
|
|
537
|
+
const args = controlArgs(entry, "check", { env });
|
|
538
|
+
debug(env, "ssh", args);
|
|
539
|
+
const res = runner("ssh", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 });
|
|
540
|
+
if (res.status === 0) {
|
|
541
|
+
const pid = Number.parseInt(/pid=(\d+)/.exec(String(res.stderr || ""))?.[1], 10);
|
|
542
|
+
return { connected: true, socket, pid: Number.isInteger(pid) ? pid : null, stale: false };
|
|
543
|
+
}
|
|
544
|
+
try { fs.unlinkSync(socket); } catch { /* gone already, or not ours */ }
|
|
545
|
+
return { connected: false, socket, stale: true };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Establish the master (R8–R10). Idempotent: a live master is reported as
|
|
550
|
+
* `alreadyOpen` and left alone. Prompts — a passphrase, a host key — reach
|
|
551
|
+
* the user through /dev/tty when there is one, which is why stdin is
|
|
552
|
+
* inherited and only the pipes are captured.
|
|
553
|
+
*/
|
|
554
|
+
export function openMaster(entry, { runner = spawnSync, env = process.env, persist, batch, stdin = process.stdin } = {}) {
|
|
555
|
+
const started = Date.now();
|
|
556
|
+
const status = checkMaster(entry, { runner, env });
|
|
557
|
+
if (status.connected) return { ok: true, target: entry.name, connected: true, alreadyOpen: true, pid: status.pid, socket: status.socket, durationMs: Date.now() - started };
|
|
558
|
+
ensureControlDir();
|
|
559
|
+
const headless = batch ?? !stdin?.isTTY;
|
|
560
|
+
const keepalive = keepaliveArgs(entry, { runner, env });
|
|
561
|
+
const args = masterArgs(entry, { persist, batch: headless, keepalive, env });
|
|
562
|
+
debug(env, "ssh", args);
|
|
563
|
+
const res = runner("ssh", args, { encoding: "utf8", env, stdio: [headless ? "ignore" : "inherit", "pipe", "pipe"], timeout: 120_000 });
|
|
564
|
+
const verdict = classify(res);
|
|
565
|
+
if (!verdict.transportOk || (res.status ?? 0) !== 0) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false, target: entry.name, connected: false, alreadyOpen: false,
|
|
568
|
+
error: verdict.error || transportError(res.stderr), stderr: String(res.stderr || ""), durationMs: Date.now() - started,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const after = checkMaster(entry, { runner, env });
|
|
572
|
+
return {
|
|
573
|
+
ok: after.connected, target: entry.name, connected: after.connected, alreadyOpen: false,
|
|
574
|
+
pid: after.pid ?? null, socket: after.socket, durationMs: Date.now() - started,
|
|
575
|
+
...(after.connected ? {} : { error: "ssh returned but no master is answering on the control socket" }),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Close the master with `ssh -O exit` (R12). Nothing here knows or kills a
|
|
581
|
+
* PID: the master is told to leave, and it takes its socket with it.
|
|
582
|
+
*/
|
|
583
|
+
export function closeMaster(entry, { runner = spawnSync, env = process.env } = {}) {
|
|
584
|
+
const status = checkMaster(entry, { runner, env });
|
|
585
|
+
if (!status.connected) return { ok: true, target: entry.name, closed: false, wasOpen: false, stale: status.stale };
|
|
586
|
+
const args = controlArgs(entry, "exit", { env });
|
|
587
|
+
debug(env, "ssh", args);
|
|
588
|
+
const res = runner("ssh", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 });
|
|
589
|
+
const gone = !checkMaster(entry, { runner, env }).connected;
|
|
590
|
+
return {
|
|
591
|
+
ok: res.status === 0 && gone, target: entry.name, closed: gone, wasOpen: true,
|
|
592
|
+
...(res.status === 0 && gone ? {} : { error: transportError(res.stderr) }),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Run one command over the target's master (R18–R32).
|
|
598
|
+
*
|
|
599
|
+
* The shape of the answer is the whole feature: `ok` is the command's verdict,
|
|
600
|
+
* `transportOk` is ssh's, `code`/`signal` are the remote exit, stdout and
|
|
601
|
+
* stderr are separate strings, and `durationMs` is wall time. A `grep` that
|
|
602
|
+
* found nothing is `ok: false, transportOk: true, code: 1` — a fact about the
|
|
603
|
+
* files, not about the network — and an agent branching on the difference is
|
|
604
|
+
* why the two fields exist.
|
|
605
|
+
*
|
|
606
|
+
* Recovery (R15): a master that is not answering is reopened before the
|
|
607
|
+
* command runs; a transport failure on a master that *was* answering gets the
|
|
608
|
+
* socket re-checked and the command retried once. No retry on a command that
|
|
609
|
+
* merely failed, and none on a timeout — the remote side may have done the
|
|
610
|
+
* work, and doing it twice is worse than reporting it once.
|
|
611
|
+
*/
|
|
612
|
+
export function exec(entry, argv, {
|
|
613
|
+
cwd, remoteEnv = {}, stdin, tty = false, sh = false, timeoutMs, persist, batch,
|
|
614
|
+
runner = spawnSync, env = process.env, retry = true,
|
|
615
|
+
} = {}) {
|
|
616
|
+
const started = Date.now();
|
|
617
|
+
const finish = (fields) => ({ target: entry.name, ...fields, durationMs: Date.now() - started });
|
|
618
|
+
|
|
619
|
+
// `cwd: null` means "no cd at all" — for the tmux verbs and put/get's
|
|
620
|
+
// rename, which address absolute things and must not fail because the
|
|
621
|
+
// target's cwd happens not to exist yet. Undefined means the target's cwd.
|
|
622
|
+
const where = cwd === null ? undefined : (cwd ?? entry.cwd);
|
|
623
|
+
let command;
|
|
624
|
+
try { command = remoteCommand(argv, { cwd: where, env: remoteEnv, sh }); }
|
|
625
|
+
catch (e) { return finish({ ok: false, transportOk: false, connected: false, code: null, signal: null, stdout: "", stderr: "", error: e.message }); }
|
|
626
|
+
|
|
627
|
+
let status = checkMaster(entry, { runner, env });
|
|
628
|
+
let opened = false;
|
|
629
|
+
if (!status.connected) {
|
|
630
|
+
const open = openMaster(entry, { runner, env, persist, batch });
|
|
631
|
+
if (!open.ok) {
|
|
632
|
+
return finish({ ok: false, transportOk: false, connected: false, code: 255, signal: null, stdout: "", stderr: open.stderr || "", error: open.error, opened: false });
|
|
633
|
+
}
|
|
634
|
+
opened = true;
|
|
635
|
+
status = { connected: true };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const headless = batch ?? (tty ? !process.stdin?.isTTY : true);
|
|
639
|
+
const args = execArgs(entry, command, { tty, batch: headless, persist, env });
|
|
640
|
+
const input = stdin === undefined || stdin === null ? undefined : (Buffer.isBuffer(stdin) ? stdin : Buffer.from(String(stdin)));
|
|
641
|
+
const run = () => {
|
|
642
|
+
debug(env, "ssh", args);
|
|
643
|
+
const options = { env, maxBuffer: MAX_OUTPUT, killSignal: "SIGTERM" };
|
|
644
|
+
if (timeoutMs) options.timeout = timeoutMs;
|
|
645
|
+
if (tty) {
|
|
646
|
+
// A terminal command owns the terminal; there is nothing to capture.
|
|
647
|
+
options.stdio = "inherit";
|
|
648
|
+
} else if (input !== undefined) {
|
|
649
|
+
options.input = input;
|
|
650
|
+
} else {
|
|
651
|
+
options.stdio = ["ignore", "pipe", "pipe"];
|
|
652
|
+
}
|
|
653
|
+
const res = runner("ssh", args, options);
|
|
654
|
+
return { res, verdict: classify(res) };
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
let { res, verdict } = run();
|
|
658
|
+
let retried = false;
|
|
659
|
+
if (!verdict.transportOk && !verdict.missing && retry && !opened) {
|
|
660
|
+
// The master answered a moment ago and the command still failed at the
|
|
661
|
+
// transport: it died in between. Re-check (which unlinks a stale socket),
|
|
662
|
+
// reopen, and try the command once more.
|
|
663
|
+
const again = checkMaster(entry, { runner, env });
|
|
664
|
+
const open = again.connected ? { ok: true } : openMaster(entry, { runner, env, persist, batch });
|
|
665
|
+
if (open.ok) { ({ res, verdict } = run()); retried = true; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const stdout = tty ? "" : bufferToString(res.stdout);
|
|
669
|
+
const stderr = tty ? "" : bufferToString(res.stderr);
|
|
670
|
+
return finish({
|
|
671
|
+
ok: verdict.transportOk && verdict.code === 0,
|
|
672
|
+
transportOk: verdict.transportOk,
|
|
673
|
+
connected: verdict.transportOk || status.connected,
|
|
674
|
+
code: verdict.code,
|
|
675
|
+
signal: verdict.signal,
|
|
676
|
+
stdout,
|
|
677
|
+
stderr,
|
|
678
|
+
...(verdict.error ? { error: verdict.error } : {}),
|
|
679
|
+
...(verdict.timedOut ? { timedOut: true } : {}),
|
|
680
|
+
...(opened ? { opened: true } : {}),
|
|
681
|
+
...(retried ? { retried: true } : {}),
|
|
682
|
+
...(tty ? { tty: true } : {}),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const bufferToString = (b) => (b == null ? "" : Buffer.isBuffer(b) ? b.toString("utf8") : String(b));
|
|
687
|
+
|
|
688
|
+
/** Hand the terminal to ssh (R36–R40). Returns the exit code. */
|
|
689
|
+
export function attach(entry, { runner = spawnSync, env = process.env, persist } = {}) {
|
|
690
|
+
const status = checkMaster(entry, { runner, env });
|
|
691
|
+
if (!status.connected) ensureControlDir();
|
|
692
|
+
const keepalive = status.connected ? [] : keepaliveArgs(entry, { runner, env });
|
|
693
|
+
const args = attachArgs(entry, { persist, keepalive, env });
|
|
694
|
+
debug(env, "ssh", args);
|
|
695
|
+
const res = runner("ssh", args, { stdio: "inherit", env });
|
|
696
|
+
if (res.error?.code === "ENOENT") return { ok: false, code: 127, error: "ssh not found — install an OpenSSH client" };
|
|
697
|
+
return { ok: res.status === 0, code: res.status ?? 1, signal: res.signal || null };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* ------------------------------------------------------------- transfer */
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Copy a local file up, atomically (R33, R34): scp to a sibling temp path
|
|
704
|
+
* over the shared master, then `mv` it into place with one exec. A reader on
|
|
705
|
+
* the remote side sees the old file or the new one, never a half-written one.
|
|
706
|
+
*/
|
|
707
|
+
export function put(entry, local, remote, { runner = spawnSync, env = process.env, persist, batch } = {}) {
|
|
708
|
+
const started = Date.now();
|
|
709
|
+
if (!fs.existsSync(local)) return { ok: false, target: entry.name, error: `no such local file: ${local}`, durationMs: 0 };
|
|
710
|
+
const status = checkMaster(entry, { runner, env });
|
|
711
|
+
if (!status.connected) {
|
|
712
|
+
const open = openMaster(entry, { runner, env, persist, batch });
|
|
713
|
+
if (!open.ok) return { ok: false, target: entry.name, transportOk: false, error: open.error, durationMs: Date.now() - started };
|
|
714
|
+
}
|
|
715
|
+
const dest = remotePath(entry, remote);
|
|
716
|
+
const tmp = `${dest}.moshcode-${process.pid}-${Date.now()}.tmp`;
|
|
717
|
+
const args = scpArgs(entry, local, `${entry.target}:${tmp}`, { env });
|
|
718
|
+
debug(env, "scp", args);
|
|
719
|
+
const res = runner("scp", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_OUTPUT });
|
|
720
|
+
const verdict = classify(res);
|
|
721
|
+
if (!verdict.transportOk || verdict.code !== 0) {
|
|
722
|
+
return { ok: false, target: entry.name, transportOk: verdict.transportOk, code: verdict.code, error: verdict.error || transportError(res.stderr) || "scp failed", stderr: String(res.stderr || ""), durationMs: Date.now() - started };
|
|
723
|
+
}
|
|
724
|
+
const moved = exec(entry, ["mv", "-f", "--", tmp, dest], { runner, env, cwd: null, retry: false });
|
|
725
|
+
if (!moved.ok) {
|
|
726
|
+
exec(entry, ["rm", "-f", "--", tmp], { runner, env, cwd: null, retry: false });
|
|
727
|
+
return { ok: false, target: entry.name, transportOk: moved.transportOk, code: moved.code, error: moved.error || moved.stderr.trim() || "rename failed", durationMs: Date.now() - started };
|
|
728
|
+
}
|
|
729
|
+
return { ok: true, target: entry.name, transportOk: true, local, remote: dest, durationMs: Date.now() - started };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Copy a remote file down over the shared master. */
|
|
733
|
+
export function get(entry, remote, local, { runner = spawnSync, env = process.env, persist, batch } = {}) {
|
|
734
|
+
const started = Date.now();
|
|
735
|
+
const status = checkMaster(entry, { runner, env });
|
|
736
|
+
if (!status.connected) {
|
|
737
|
+
const open = openMaster(entry, { runner, env, persist, batch });
|
|
738
|
+
if (!open.ok) return { ok: false, target: entry.name, transportOk: false, error: open.error, durationMs: Date.now() - started };
|
|
739
|
+
}
|
|
740
|
+
const src = remotePath(entry, remote);
|
|
741
|
+
const args = scpArgs(entry, `${entry.target}:${src}`, local, { env });
|
|
742
|
+
debug(env, "scp", args);
|
|
743
|
+
const res = runner("scp", args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_OUTPUT });
|
|
744
|
+
const verdict = classify(res);
|
|
745
|
+
if (!verdict.transportOk || verdict.code !== 0) {
|
|
746
|
+
return { ok: false, target: entry.name, transportOk: verdict.transportOk, code: verdict.code, error: verdict.error || transportError(res.stderr) || "scp failed", stderr: String(res.stderr || ""), durationMs: Date.now() - started };
|
|
747
|
+
}
|
|
748
|
+
return { ok: true, target: entry.name, transportOk: true, remote: src, local, durationMs: Date.now() - started };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* A relative remote path is relative to the target's cwd, the way `exec` is.
|
|
753
|
+
* scp has no cwd of its own, so the join happens here; `~` is left for the
|
|
754
|
+
* remote shell, which scp hands paths to.
|
|
755
|
+
*/
|
|
756
|
+
export function remotePath(entry, p) {
|
|
757
|
+
const s = String(p);
|
|
758
|
+
if (s.startsWith("/") || s.startsWith("~") || !entry.cwd) return s;
|
|
759
|
+
return `${entry.cwd.replace(/\/+$/, "")}/${s}`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* ------------------------------------------------------- remote shells */
|
|
763
|
+
|
|
764
|
+
/** The remote tmux session name for `<target>/<session>` (R42). */
|
|
765
|
+
export function remoteSessionName(entry, session) {
|
|
766
|
+
return `moshcode-ssh-${entry.name}-${session}`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** `dev/app` → { name: "dev", session: "app" }, or an error. */
|
|
770
|
+
export function parseSessionRef(ref) {
|
|
771
|
+
const [name, session, ...rest] = String(ref || "").split("/");
|
|
772
|
+
if (!name || !session || rest.length) return { error: `ssh: a shell is named <target>/<session>, got ${JSON.stringify(String(ref))}` };
|
|
773
|
+
if (!validName(name)) return { error: `ssh: ${JSON.stringify(name)} is not a target name` };
|
|
774
|
+
if (!SESSION_RE.test(session)) return { error: `ssh: ${JSON.stringify(session)} is not a session name — lowercase letters, digits, - and _` };
|
|
775
|
+
return { name, session };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Is tmux on the remote box? One exec, cached nowhere — it is cheap over the master. */
|
|
779
|
+
export function remoteHasTmux(entry, opts = {}) {
|
|
780
|
+
const r = exec(entry, ["tmux", "-V"], { ...opts, cwd: null });
|
|
781
|
+
if (!r.transportOk) return { ok: false, has: false, error: r.error };
|
|
782
|
+
return { ok: true, has: r.ok, version: r.ok ? r.stdout.trim() : null };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const NO_TMUX = (entry) => `ssh: tmux is not installed on ${entry.name} — a persistent shell needs it; moshcode ssh exec still works, and so does moshcode ssh ${entry.name}`;
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Create-or-attach a persistent remote shell (R41–R43). `tmux new-session -A`
|
|
789
|
+
* attaches when the session exists and creates it when it does not, in one
|
|
790
|
+
* word; the session lives on the remote box under tmux's own server and
|
|
791
|
+
* survives this terminal, this master, and this laptop's lid.
|
|
792
|
+
*/
|
|
793
|
+
export function shellAttach(entry, session, { runner = spawnSync, env = process.env, persist } = {}) {
|
|
794
|
+
const probe = remoteHasTmux(entry, { runner, env, persist });
|
|
795
|
+
if (!probe.ok) return { ok: false, code: 255, error: probe.error };
|
|
796
|
+
if (!probe.has) return { ok: false, code: 1, error: NO_TMUX(entry) };
|
|
797
|
+
const name = remoteSessionName(entry, session);
|
|
798
|
+
const tmuxCmd = ["tmux", "new-session", "-A", "-s", name, ...(entry.cwd ? ["-c", entry.cwd] : [])];
|
|
799
|
+
const command = tmuxCmd.map((a, i) => (i === tmuxCmd.length - 1 && entry.cwd ? tildeQuote(a) : shellQuote(a))).join(" ");
|
|
800
|
+
const status = checkMaster(entry, { runner, env });
|
|
801
|
+
const keepalive = status.connected ? [] : keepaliveArgs(entry, { runner, env });
|
|
802
|
+
const args = execArgs(entry, command, { tty: true, batch: false, persist, keepalive, env });
|
|
803
|
+
debug(env, "ssh", args);
|
|
804
|
+
const res = runner("ssh", args, { stdio: "inherit", env });
|
|
805
|
+
return { ok: res.status === 0, code: res.status ?? 1, session: name };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Like shellQuote, but a leading `~/` stays outside the quotes so the remote shell expands it. */
|
|
809
|
+
function tildeQuote(p) {
|
|
810
|
+
const s = String(p);
|
|
811
|
+
if (s === "~") return "~";
|
|
812
|
+
if (s.startsWith("~/")) return `~/${shellQuote(s.slice(2))}`;
|
|
813
|
+
return shellQuote(s);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Type into a remote shell without attaching (R44, R45). Two send-keys: the
|
|
818
|
+
* text literally (`-l`, so `pnpm test` is five keystrokes and a space, not a
|
|
819
|
+
* key name), then Enter. The herd's model exactly, one hop further away.
|
|
820
|
+
*/
|
|
821
|
+
export function shellSend(entry, session, text, opts = {}) {
|
|
822
|
+
const name = remoteSessionName(entry, session);
|
|
823
|
+
const r = exec(entry, ["tmux", "send-keys", "-t", name, "-l", "--", String(text)], { ...opts, cwd: null });
|
|
824
|
+
if (!r.ok) return sessionFailure(entry, session, r);
|
|
825
|
+
const enter = exec(entry, ["tmux", "send-keys", "-t", name, "Enter"], { ...opts, cwd: null, retry: false });
|
|
826
|
+
if (!enter.ok) return sessionFailure(entry, session, enter);
|
|
827
|
+
return { ok: true, target: entry.name, session, sent: String(text) };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** The screen of a remote shell, as text (R46). */
|
|
831
|
+
export function shellRead(entry, session, { lines = 60, ...opts } = {}) {
|
|
832
|
+
const name = remoteSessionName(entry, session);
|
|
833
|
+
const n = Math.max(1, Number.parseInt(lines, 10) || 60);
|
|
834
|
+
const r = exec(entry, ["tmux", "capture-pane", "-p", "-t", name, "-S", `-${n}`], { ...opts, cwd: null });
|
|
835
|
+
if (!r.ok) return sessionFailure(entry, session, r);
|
|
836
|
+
return { ok: true, target: entry.name, session, screen: r.stdout.replace(/\s+$/, "") };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** End a remote shell and everything in it. */
|
|
840
|
+
export function shellKill(entry, session, opts = {}) {
|
|
841
|
+
const name = remoteSessionName(entry, session);
|
|
842
|
+
const r = exec(entry, ["tmux", "kill-session", "-t", name], { ...opts, cwd: null });
|
|
843
|
+
if (!r.ok) return sessionFailure(entry, session, r);
|
|
844
|
+
return { ok: true, target: entry.name, session, killed: true };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Every moshcode shell on the target. */
|
|
848
|
+
export function shellList(entry, opts = {}) {
|
|
849
|
+
const prefix = `moshcode-ssh-${entry.name}-`;
|
|
850
|
+
// Space-separated, not tab: a literal tab does not survive the trip through
|
|
851
|
+
// the remote login shell intact, and session names cannot contain a space.
|
|
852
|
+
const r = exec(entry, ["tmux", "list-sessions", "-F", "#{session_name} #{session_created} #{session_attached}"], { ...opts, cwd: null });
|
|
853
|
+
if (!r.transportOk) return { ok: false, target: entry.name, error: r.error, sessions: [] };
|
|
854
|
+
if (r.code === 127) return { ok: false, target: entry.name, error: NO_TMUX(entry), sessions: [] };
|
|
855
|
+
// "no server running" is tmux's way of saying zero sessions, and exits 1.
|
|
856
|
+
if (!r.ok && !/no server running|no sessions/i.test(r.stderr)) return { ok: false, target: entry.name, error: r.stderr.trim() || "tmux list-sessions failed", sessions: [] };
|
|
857
|
+
const sessions = r.stdout.split("\n").filter((l) => l.startsWith(prefix)).map((l) => {
|
|
858
|
+
const [name, created, attached] = l.split(" ");
|
|
859
|
+
return { session: name.slice(prefix.length), created: Number(created) * 1000 || null, attached: attached === "1" };
|
|
860
|
+
});
|
|
861
|
+
return { ok: true, target: entry.name, sessions };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function sessionFailure(entry, session, r) {
|
|
865
|
+
if (!r.transportOk) return { ok: false, target: entry.name, session, transportOk: false, error: r.error };
|
|
866
|
+
if (r.code === 127) return { ok: false, target: entry.name, session, transportOk: true, error: NO_TMUX(entry) };
|
|
867
|
+
// tmux's spellings for "that session is not there" vary with what else the
|
|
868
|
+
// server has: with other sessions it cannot find this one; with none it
|
|
869
|
+
// has no current target; with no server it says so.
|
|
870
|
+
if (/can't find (session|pane|window)|no current target|no server running|session not found/i.test(r.stderr)) {
|
|
871
|
+
return { ok: false, target: entry.name, session, transportOk: true, error: `ssh: no shell ${entry.name}/${session} — moshcode ssh shell ${entry.name} --name ${session} starts one` };
|
|
872
|
+
}
|
|
873
|
+
return { ok: false, target: entry.name, session, transportOk: true, code: r.code, error: r.stderr.trim() || r.error || "tmux failed" };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/* ---------------------------------------------------------------- bench */
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The number the feature exists for, measured rather than claimed: N fresh
|
|
880
|
+
* connections against N commands over one master, on this host, now.
|
|
881
|
+
*/
|
|
882
|
+
export function bench(entry, { n = 20, runner = spawnSync, env = process.env, persist, batch } = {}) {
|
|
883
|
+
const count = Math.max(1, Number.parseInt(n, 10) || 20);
|
|
884
|
+
const timings = (fn) => {
|
|
885
|
+
const samples = [];
|
|
886
|
+
let failures = 0;
|
|
887
|
+
for (let i = 0; i < count; i++) {
|
|
888
|
+
const t = process.hrtime.bigint();
|
|
889
|
+
if (!fn()) failures++;
|
|
890
|
+
samples.push(Number(process.hrtime.bigint() - t) / 1e6);
|
|
891
|
+
}
|
|
892
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
893
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
894
|
+
return { runs: count, failures, totalMs: Math.round(samples.reduce((a, b) => a + b, 0)), medianMs: round(at(0.5)), p95Ms: round(at(0.95)) };
|
|
895
|
+
};
|
|
896
|
+
const command = remoteCommand(["true"], {});
|
|
897
|
+
const fresh = () => {
|
|
898
|
+
const args = [
|
|
899
|
+
...(env[CONFIG_ENV] ? ["-F", env[CONFIG_ENV]] : []),
|
|
900
|
+
"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "BatchMode=yes", "-o", `ConnectTimeout=${CONNECT_TIMEOUT}`,
|
|
901
|
+
...portArgs(entry), "-T", entry.target, "--", command,
|
|
902
|
+
];
|
|
903
|
+
const res = runner("ssh", args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
904
|
+
return res.status === 0;
|
|
905
|
+
};
|
|
906
|
+
const open = openMaster(entry, { runner, env, persist, batch });
|
|
907
|
+
if (!open.ok) return { ok: false, target: entry.name, error: open.error };
|
|
908
|
+
const muxed = () => exec(entry, ["true"], { runner, env, cwd: null, retry: false }).ok;
|
|
909
|
+
const freshStats = timings(fresh);
|
|
910
|
+
const muxedStats = timings(muxed);
|
|
911
|
+
return {
|
|
912
|
+
ok: true, target: entry.name, fresh: freshStats, multiplexed: muxedStats,
|
|
913
|
+
speedup: muxedStats.medianMs > 0 ? round(freshStats.medianMs / muxedStats.medianMs) : null,
|
|
914
|
+
authentications: { fresh: count, multiplexed: open.alreadyOpen ? 0 : 1 },
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
919
|
+
|
|
920
|
+
/* ----------------------------------------------------------------- CLI */
|
|
921
|
+
|
|
922
|
+
const VERBS = ["list", "ls", "add", "remove", "rm", "show", "open", "check", "close", "exec", "put", "get", "shell", "bench", "help"];
|
|
923
|
+
|
|
924
|
+
/** Does this argv end up handing the terminal to ssh? The pit closes readline around those. */
|
|
925
|
+
export function takesTerminal(argv = []) {
|
|
926
|
+
const verb = String(argv[0] || "");
|
|
927
|
+
if (!verb || VERBS.includes(verb)) {
|
|
928
|
+
if (verb === "exec") return argv.includes("--tty");
|
|
929
|
+
if (verb === "shell") return !["send", "read", "kill", "list", "ls"].includes(String(argv[1] || ""));
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
return true; // `moshcode ssh <name>` attaches
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Split flags from positionals. `valued` flags take the next word (or
|
|
937
|
+
* `--flag=value`); `repeat` flags collect; everything else is a boolean. `--`
|
|
938
|
+
* ends flag parsing and the rest is the command — which is what makes
|
|
939
|
+
* `moshcode ssh exec dev -- git log --oneline` hand `--oneline` to git.
|
|
940
|
+
*/
|
|
941
|
+
export function parseArgs(argv, { valued = [], repeat = [] } = {}) {
|
|
942
|
+
const flags = {};
|
|
943
|
+
const positional = [];
|
|
944
|
+
let rest = null;
|
|
945
|
+
for (let i = 0; i < argv.length; i++) {
|
|
946
|
+
const a = String(argv[i]);
|
|
947
|
+
if (rest) { rest.push(a); continue; }
|
|
948
|
+
if (a === "--") { rest = []; continue; }
|
|
949
|
+
if (a.startsWith("--") && a.length > 2) {
|
|
950
|
+
const eq = a.indexOf("=");
|
|
951
|
+
const key = (eq > 0 ? a.slice(2, eq) : a.slice(2));
|
|
952
|
+
if (valued.includes(key) || repeat.includes(key)) {
|
|
953
|
+
const value = eq > 0 ? a.slice(eq + 1) : argv[++i];
|
|
954
|
+
if (value === undefined) throw new Error(`ssh: --${key} needs a value`);
|
|
955
|
+
if (repeat.includes(key)) (flags[key] ||= []).push(String(value));
|
|
956
|
+
else flags[key] = String(value);
|
|
957
|
+
} else {
|
|
958
|
+
flags[key] = true;
|
|
959
|
+
}
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
positional.push(a);
|
|
963
|
+
}
|
|
964
|
+
return { flags, positional, rest };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const USAGE = [
|
|
968
|
+
"usage:",
|
|
969
|
+
" moshcode ssh targets and whether each is connected",
|
|
970
|
+
" moshcode ssh add <name> <user@host|alias> [--port N] [--cwd PATH] [--persist 10m]",
|
|
971
|
+
" moshcode ssh remove <name> · show <name>",
|
|
972
|
+
" moshcode ssh open <name> [--persist 10m] · check <name> · close <name>",
|
|
973
|
+
" moshcode ssh <name> an interactive shell over the shared connection",
|
|
974
|
+
" moshcode ssh exec <name> [--cwd PATH] [--env K=V] [--stdin] [--tty] [--timeout 2m] [--sh] -- <command…>",
|
|
975
|
+
" moshcode ssh put <name> <local> <remote> · get <name> <remote> <local>",
|
|
976
|
+
" moshcode ssh shell <name> --name <session> · shell send|read|kill <name>/<session>",
|
|
977
|
+
" moshcode ssh bench <name> [--n 20]",
|
|
978
|
+
" --json on any of the above that does not take the terminal",
|
|
979
|
+
];
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The CLI: `moshcode ssh …` and the pit's `/ssh …`. Returns the exit code.
|
|
983
|
+
* Every verb that can answer in JSON does under --json, and the JSON is the
|
|
984
|
+
* same object the moshscript helpers return.
|
|
985
|
+
*/
|
|
986
|
+
export async function sshCommand(rawArgv = [], { write = console.log, writeErr = (l) => console.error(l), env = process.env, stdin = process.stdin, runner = spawnSync } = {}) {
|
|
987
|
+
// `--json` is global — `moshcode ssh --json` and `moshcode ssh list --json`
|
|
988
|
+
// are the same question — so it is lifted out before the verb is read.
|
|
989
|
+
// Only up to `--`: after that the words belong to the remote command.
|
|
990
|
+
const json = argv0Has(rawArgv, "--json");
|
|
991
|
+
const argv = stripGlobal(rawArgv, "--json");
|
|
992
|
+
const first = String(argv[0] ?? "");
|
|
993
|
+
const emit = (obj) => { write(JSON.stringify(obj, null, 2)); };
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
if (!first || first === "list" || first === "ls") return listCommand({ json, write, env, runner });
|
|
997
|
+
if (first === "help" || first === "--help" || first === "-h") { USAGE.forEach(write); return 0; }
|
|
998
|
+
|
|
999
|
+
if (first === "add") {
|
|
1000
|
+
const { flags, positional } = parseArgs(argv.slice(1), { valued: ["port", "cwd", "persist"] });
|
|
1001
|
+
const [name, target] = positional;
|
|
1002
|
+
if (!name || !target) { writeErr(err("moshcode ssh add <name> <user@host | ssh-config alias> [--port N] [--cwd PATH]")); return 2; }
|
|
1003
|
+
const added = addTarget(name, target, flags);
|
|
1004
|
+
if (json) emit({ ok: true, ...added });
|
|
1005
|
+
else write(ok(`${bone(added.name)} → ${added.target}${added.port ? `:${added.port}` : ""}${added.cwd ? ash(` ${added.cwd}`) : ""}${added.replaced ? ash(" (replaced)") : ""}`));
|
|
1006
|
+
return 0;
|
|
1007
|
+
}
|
|
1008
|
+
if (first === "remove" || first === "rm") {
|
|
1009
|
+
const { positional } = parseArgs(argv.slice(1));
|
|
1010
|
+
const name = positional[0];
|
|
1011
|
+
if (!name) { writeErr(err("moshcode ssh remove <name>")); return 2; }
|
|
1012
|
+
const entry = getTarget(name);
|
|
1013
|
+
if (entry) closeMaster(entry, { runner, env });
|
|
1014
|
+
const removed = removeTarget(name);
|
|
1015
|
+
if (json) emit({ ok: removed, name, removed });
|
|
1016
|
+
else write(removed ? ok(`forgot ${bone(name)}`) : warn(`no target named ${name}`));
|
|
1017
|
+
return removed ? 0 : 1;
|
|
1018
|
+
}
|
|
1019
|
+
if (first === "show") {
|
|
1020
|
+
const { positional } = parseArgs(argv.slice(1));
|
|
1021
|
+
const found = resolveTarget(positional[0]);
|
|
1022
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1023
|
+
const status = checkMaster(found.entry, { runner, env });
|
|
1024
|
+
const row = { ...found.entry, connected: status.connected, socket: status.socket, pid: status.pid ?? null };
|
|
1025
|
+
if (json) emit({ ok: true, ...row });
|
|
1026
|
+
else {
|
|
1027
|
+
write(`${bone(row.name)} ${row.target}${row.port ? `:${row.port}` : ""}`);
|
|
1028
|
+
write(ash(` cwd ${row.cwd || "(remote default)"}`));
|
|
1029
|
+
write(ash(` persist ${row.persist || env[PERSIST_ENV] || DEFAULT_PERSIST}`));
|
|
1030
|
+
write(ash(` state ${row.connected ? "connected" : "closed"}${row.pid ? ` (master pid ${row.pid})` : ""}`));
|
|
1031
|
+
write(ash(` socket ${row.socket}`));
|
|
1032
|
+
}
|
|
1033
|
+
return 0;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (first === "open" || first === "check" || first === "close") {
|
|
1037
|
+
const { flags, positional } = parseArgs(argv.slice(1), { valued: ["persist"] });
|
|
1038
|
+
const found = resolveTarget(positional[0]);
|
|
1039
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1040
|
+
const { entry } = found;
|
|
1041
|
+
if (first === "open") {
|
|
1042
|
+
const r = openMaster(entry, { runner, env, persist: flags.persist, batch: flags.batch ? true : undefined, stdin });
|
|
1043
|
+
if (json) emit(r);
|
|
1044
|
+
else if (r.ok) write(ok(`${bone(entry.name)} ${r.alreadyOpen ? "already connected" : "connected"}${r.pid ? ash(` (master pid ${r.pid})`) : ""}`));
|
|
1045
|
+
else { writeErr(err(`${entry.name}: ${r.error}`)); if (r.stderr?.trim() && !json) writeErr(ash(r.stderr.trim())); }
|
|
1046
|
+
return r.ok ? 0 : 1;
|
|
1047
|
+
}
|
|
1048
|
+
if (first === "check") {
|
|
1049
|
+
const status = checkMaster(entry, { runner, env });
|
|
1050
|
+
if (json) emit({ ok: true, target: entry.name, connected: status.connected, stale: status.stale, pid: status.pid ?? null, socket: status.socket });
|
|
1051
|
+
else write(status.connected ? ok(`${bone(entry.name)} connected${status.pid ? ash(` (master pid ${status.pid})`) : ""}`) : info(`${bone(entry.name)} closed${status.stale ? ash(" (a stale socket was cleaned up)") : ""}`));
|
|
1052
|
+
return status.connected ? 0 : 1;
|
|
1053
|
+
}
|
|
1054
|
+
const r = closeMaster(entry, { runner, env });
|
|
1055
|
+
if (json) emit(r);
|
|
1056
|
+
else if (!r.ok) writeErr(err(`${entry.name}: ${r.error}`));
|
|
1057
|
+
else write(r.wasOpen ? ok(`${bone(entry.name)} closed`) : info(`${bone(entry.name)} was not connected`));
|
|
1058
|
+
return r.ok ? 0 : 1;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (first === "exec") {
|
|
1062
|
+
const { flags, positional, rest } = parseArgs(argv.slice(1), { valued: ["cwd", "timeout", "persist"], repeat: ["env"] });
|
|
1063
|
+
const found = resolveTarget(positional[0]);
|
|
1064
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1065
|
+
// Without `--`, everything after the name is the command. `--` is still
|
|
1066
|
+
// the safe spelling: it is the only way to hand the command a flag this
|
|
1067
|
+
// parser would otherwise claim.
|
|
1068
|
+
const command = rest ?? positional.slice(1);
|
|
1069
|
+
if (!command.length) { writeErr(err("moshcode ssh exec <name> [flags] -- <command> [args…]")); return 2; }
|
|
1070
|
+
const input = flags.stdin ? readAllStdin(stdin) : undefined;
|
|
1071
|
+
const r = exec(found.entry, command, {
|
|
1072
|
+
cwd: flags.cwd, remoteEnv: parseEnvPairs(flags.env || []), stdin: input, tty: Boolean(flags.tty), sh: Boolean(flags.sh),
|
|
1073
|
+
timeoutMs: parseTimeout(flags.timeout), persist: flags.persist, batch: flags.batch ? true : undefined, runner, env,
|
|
1074
|
+
});
|
|
1075
|
+
if (json) { emit(r); return exitCodeFor(r); }
|
|
1076
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
1077
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
1078
|
+
if (r.error && !r.transportOk) writeErr(err(`${found.entry.name}: ${r.error}`));
|
|
1079
|
+
else if (r.timedOut) writeErr(err(`${found.entry.name}: timed out after ${flags.timeout}`));
|
|
1080
|
+
return exitCodeFor(r);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (first === "put" || first === "get") {
|
|
1084
|
+
const { flags, positional } = parseArgs(argv.slice(1), { valued: ["persist"] });
|
|
1085
|
+
const found = resolveTarget(positional[0]);
|
|
1086
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1087
|
+
const [, a, b] = positional;
|
|
1088
|
+
if (!a || !b) { writeErr(err(first === "put" ? "moshcode ssh put <name> <local> <remote>" : "moshcode ssh get <name> <remote> <local>")); return 2; }
|
|
1089
|
+
const r = first === "put"
|
|
1090
|
+
? put(found.entry, a, b, { runner, env, persist: flags.persist })
|
|
1091
|
+
: get(found.entry, a, b, { runner, env, persist: flags.persist });
|
|
1092
|
+
if (json) emit(r);
|
|
1093
|
+
else if (r.ok) write(ok(first === "put" ? `${a} → ${bone(found.entry.name)}:${r.remote}` : `${bone(found.entry.name)}:${r.remote} → ${r.local}`));
|
|
1094
|
+
else writeErr(err(`${found.entry.name}: ${r.error}`));
|
|
1095
|
+
return r.ok ? 0 : 1;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (first === "shell") return shellCommand(argv.slice(1), { json, write, writeErr, env, runner, emit });
|
|
1099
|
+
|
|
1100
|
+
if (first === "bench") {
|
|
1101
|
+
const { flags, positional } = parseArgs(argv.slice(1), { valued: ["n", "persist"] });
|
|
1102
|
+
const found = resolveTarget(positional[0]);
|
|
1103
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1104
|
+
const r = bench(found.entry, { n: flags.n, runner, env, persist: flags.persist });
|
|
1105
|
+
if (json) emit(r);
|
|
1106
|
+
else if (!r.ok) writeErr(err(`${found.entry.name}: ${r.error}`));
|
|
1107
|
+
else {
|
|
1108
|
+
write(table([
|
|
1109
|
+
["fresh connection", r.fresh.runs, r.fresh.totalMs, r.fresh.medianMs, r.fresh.p95Ms, r.authentications.fresh, r.fresh.failures],
|
|
1110
|
+
["over one master", r.multiplexed.runs, r.multiplexed.totalMs, r.multiplexed.medianMs, r.multiplexed.p95Ms, r.authentications.multiplexed, r.multiplexed.failures],
|
|
1111
|
+
], { columns: ["", "runs", "total ms", "median ms", "p95 ms", "auths", "failed"] }));
|
|
1112
|
+
if (r.speedup) write(ash(` median is ${r.speedup}× faster over the master, on ${found.entry.target} from here`));
|
|
1113
|
+
}
|
|
1114
|
+
return r.ok ? 0 : 1;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Anything else is a target name: attach.
|
|
1118
|
+
if (first.startsWith("-")) { writeErr(err(`ssh: unknown flag ${first}`)); USAGE.forEach(writeErr); return 2; }
|
|
1119
|
+
const found = resolveTarget(first);
|
|
1120
|
+
if (found.error) {
|
|
1121
|
+
writeErr(err(found.error));
|
|
1122
|
+
if (!VERBS.includes(first)) USAGE.forEach(writeErr);
|
|
1123
|
+
return 1;
|
|
1124
|
+
}
|
|
1125
|
+
const { flags } = parseArgs(argv.slice(1), { valued: ["persist"] });
|
|
1126
|
+
const r = attach(found.entry, { runner, env, persist: flags.persist });
|
|
1127
|
+
if (r.error) writeErr(err(r.error));
|
|
1128
|
+
return r.code;
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
if (json) { emit({ ok: false, error: e.message }); return 1; }
|
|
1131
|
+
writeErr(err(e.message));
|
|
1132
|
+
return 1;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Is `flag` among the words before `--`? */
|
|
1137
|
+
function argv0Has(argv, flag) {
|
|
1138
|
+
for (const a of argv) {
|
|
1139
|
+
if (a === "--") return false;
|
|
1140
|
+
if (a === flag) return true;
|
|
1141
|
+
}
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** The argv without `flag`, leaving everything after `--` untouched. */
|
|
1146
|
+
function stripGlobal(argv, flag) {
|
|
1147
|
+
const out = [];
|
|
1148
|
+
let passthrough = false;
|
|
1149
|
+
for (const a of argv) {
|
|
1150
|
+
if (passthrough) { out.push(a); continue; }
|
|
1151
|
+
if (a === "--") { passthrough = true; out.push(a); continue; }
|
|
1152
|
+
if (a !== flag) out.push(a);
|
|
1153
|
+
}
|
|
1154
|
+
return out;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function exitCodeFor(r) {
|
|
1158
|
+
if (r.ok) return 0;
|
|
1159
|
+
if (!r.transportOk) return 255;
|
|
1160
|
+
if (r.timedOut) return 124;
|
|
1161
|
+
if (typeof r.code === "number") return r.code;
|
|
1162
|
+
return 1;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function readAllStdin(stdin) {
|
|
1166
|
+
try { return fs.readFileSync(stdin?.fd ?? 0); } catch { return Buffer.alloc(0); }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function listCommand({ json, write, env, runner }) {
|
|
1170
|
+
const targets = listTargets();
|
|
1171
|
+
const rows = targets.map((entry) => {
|
|
1172
|
+
const status = checkMaster(entry, { runner, env });
|
|
1173
|
+
return { name: entry.name, target: entry.target, port: entry.port ?? null, cwd: entry.cwd ?? null, connected: status.connected };
|
|
1174
|
+
});
|
|
1175
|
+
if (json) { write(JSON.stringify({ targets: rows }, null, 2)); return 0; }
|
|
1176
|
+
if (!rows.length) {
|
|
1177
|
+
write(info("no ssh targets yet — moshcode ssh add <name> user@host [--cwd /srv/app]"));
|
|
1178
|
+
return 0;
|
|
1179
|
+
}
|
|
1180
|
+
write(table(rows.map((r) => [
|
|
1181
|
+
bone(r.name), r.target + (r.port ? `:${r.port}` : ""), r.connected ? ok("connected") : ash("closed"), r.cwd || ash("—"),
|
|
1182
|
+
]), { columns: ["name", "target", "state", "cwd"] }));
|
|
1183
|
+
return 0;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function shellCommand(argv, { json, write, writeErr, env, runner, emit }) {
|
|
1187
|
+
const sub = String(argv[0] || "");
|
|
1188
|
+
if (["send", "read", "kill", "list", "ls"].includes(sub)) {
|
|
1189
|
+
const { flags, positional } = parseArgs(argv.slice(1), { valued: ["lines"] });
|
|
1190
|
+
if (sub === "list" || sub === "ls") {
|
|
1191
|
+
const found = resolveTarget(positional[0]);
|
|
1192
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1193
|
+
const r = shellList(found.entry, { runner, env });
|
|
1194
|
+
if (json) emit(r);
|
|
1195
|
+
else if (!r.ok) writeErr(err(r.error));
|
|
1196
|
+
else if (!r.sessions.length) write(info(`no shells on ${found.entry.name} — moshcode ssh shell ${found.entry.name} --name app starts one`));
|
|
1197
|
+
else write(table(r.sessions.map((s) => [`${found.entry.name}/${s.session}`, s.attached ? "attached" : "detached"]), { columns: ["shell", "state"] }));
|
|
1198
|
+
return r.ok ? 0 : 1;
|
|
1199
|
+
}
|
|
1200
|
+
const ref = parseSessionRef(positional[0]);
|
|
1201
|
+
if (ref.error) { writeErr(err(ref.error)); return 2; }
|
|
1202
|
+
const found = resolveTarget(ref.name);
|
|
1203
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1204
|
+
let r;
|
|
1205
|
+
if (sub === "send") {
|
|
1206
|
+
const text = positional.slice(1).join(" ");
|
|
1207
|
+
if (!text) { writeErr(err("moshcode ssh shell send <name>/<session> <text>")); return 2; }
|
|
1208
|
+
r = shellSend(found.entry, ref.session, text, { runner, env });
|
|
1209
|
+
} else if (sub === "read") {
|
|
1210
|
+
r = shellRead(found.entry, ref.session, { lines: flags.lines, runner, env });
|
|
1211
|
+
} else {
|
|
1212
|
+
r = shellKill(found.entry, ref.session, { runner, env });
|
|
1213
|
+
}
|
|
1214
|
+
if (json) emit(r);
|
|
1215
|
+
else if (!r.ok) writeErr(err(r.error));
|
|
1216
|
+
else if (sub === "read") write(r.screen);
|
|
1217
|
+
else write(ok(sub === "send" ? `sent to ${bone(`${ref.name}/${ref.session}`)}` : `killed ${bone(`${ref.name}/${ref.session}`)}`));
|
|
1218
|
+
return r.ok ? 0 : 1;
|
|
1219
|
+
}
|
|
1220
|
+
const { flags, positional } = parseArgs(argv, { valued: ["name", "persist"] });
|
|
1221
|
+
const found = resolveTarget(positional[0]);
|
|
1222
|
+
if (found.error) { writeErr(err(found.error)); return 1; }
|
|
1223
|
+
const session = String(flags.name || positional[1] || "main");
|
|
1224
|
+
if (!SESSION_RE.test(session)) { writeErr(err(`ssh: ${JSON.stringify(session)} is not a session name`)); return 2; }
|
|
1225
|
+
const r = shellAttach(found.entry, session, { runner, env, persist: flags.persist });
|
|
1226
|
+
if (!r.ok && r.error) writeErr(err(r.error));
|
|
1227
|
+
return r.code;
|
|
1228
|
+
}
|