@yawlabs/ssh-mcp 0.14.0 → 0.15.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 +312 -291
- package/bin/ssh-mcp.mjs +237 -67
- package/dist/diagnose.d.ts +33 -0
- package/dist/env.d.ts +87 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +990 -673
- package/dist/ops.d.ts +54 -0
- package/dist/policy.d.ts +22 -0
- package/dist/pool.d.ts +34 -0
- package/dist/server.d.ts +17 -223
- package/dist/server.js +989 -672
- package/dist/ssh-config.d.ts +4 -0
- package/dist/ssh.d.ts +217 -0
- package/dist/tools.d.ts +3 -0
- package/package.json +3 -2
package/bin/ssh-mcp.mjs
CHANGED
|
@@ -63,11 +63,21 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
|
63
63
|
const isWin = process.platform === "win32";
|
|
64
64
|
const exe = isWin ? "oam.exe" : "oam";
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Locate an oam binary. Returns `{ path, shim }`:
|
|
68
|
+
* path -- an oam this launcher can actually execute, or null
|
|
69
|
+
* shim -- an oam-named `.cmd`/`.bat` seen on PATH and SKIPPED, or null
|
|
70
|
+
*
|
|
71
|
+
* The shim is reported rather than silently dropped: "no oam binary was found"
|
|
72
|
+
* is the wrong thing to tell someone who has one installed in a shape we cannot
|
|
73
|
+
* spawn. Every branch is a stat, never a subprocess.
|
|
74
|
+
*/
|
|
67
75
|
function findOam() {
|
|
68
|
-
// 1. Explicit override wins and is never second-guessed.
|
|
76
|
+
// 1. Explicit override wins and is never second-guessed -- including a .cmd.
|
|
77
|
+
// If it cannot be executed the version gate reports that specifically,
|
|
78
|
+
// which is better than second-guessing an explicit instruction here.
|
|
69
79
|
const override = process.env.OAM_BIN;
|
|
70
|
-
if (override) return existsSync(override) ? override : null;
|
|
80
|
+
if (override) return { path: existsSync(override) ? override : null, shim: null };
|
|
71
81
|
|
|
72
82
|
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself
|
|
73
83
|
// usually has oam/target/release on PATH, and a build directory is the
|
|
@@ -78,7 +88,7 @@ function findOam() {
|
|
|
78
88
|
// point deliberately at a dev build.
|
|
79
89
|
//
|
|
80
90
|
// Both forms are checked on Windows: the installer defaults to
|
|
81
|
-
// %LOCALAPPDATA
|
|
91
|
+
// %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
82
92
|
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
83
93
|
// install.
|
|
84
94
|
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
@@ -86,21 +96,65 @@ function findOam() {
|
|
|
86
96
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
87
97
|
}
|
|
88
98
|
for (const candidate of installed) {
|
|
89
|
-
if (existsSync(candidate)) return candidate;
|
|
99
|
+
if (existsSync(candidate)) return { path: candidate, shim: null };
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
93
103
|
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
94
|
-
|
|
104
|
+
//
|
|
105
|
+
// Windows: only `.exe` is RETURNED -- deliberately narrower than PATHEXT.
|
|
106
|
+
// Node refuses to run a `.cmd`/`.bat` through execFile/spawn without
|
|
107
|
+
// `shell: true` (EINVAL, and for spawn it throws SYNCHRONOUSLY rather than
|
|
108
|
+
// emitting 'error'), so returning one would hand back a path this launcher
|
|
109
|
+
// cannot execute -- discovery has to agree with execution. `exe` is also
|
|
110
|
+
// what the installed-location checks above look for, so both discovery
|
|
111
|
+
// paths accept exactly the same shapes.
|
|
112
|
+
//
|
|
113
|
+
// A shim is still NOTED, though. An npm-style install puts `oam.cmd` on
|
|
114
|
+
// PATH, and staying silent about it means auto mode degrades with no
|
|
115
|
+
// explanation and `SSH_MCP_RUNTIME=oam` claims nothing was found -- both
|
|
116
|
+
// of which send someone to reinstall an oam they already have.
|
|
117
|
+
let shim = null;
|
|
95
118
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
96
119
|
if (!dir) continue;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
const candidate = join(dir, exe);
|
|
121
|
+
if (existsSync(candidate)) return { path: candidate, shim: null };
|
|
122
|
+
if (isWin && shim === null) {
|
|
123
|
+
for (const ext of [".cmd", ".bat"]) {
|
|
124
|
+
const alt = join(dir, `oam${ext}`);
|
|
125
|
+
if (existsSync(alt)) {
|
|
126
|
+
shim = alt;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
100
130
|
}
|
|
101
131
|
}
|
|
102
132
|
|
|
103
|
-
return null;
|
|
133
|
+
return { path: null, shim };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Write a diagnostic to stderr synchronously, so a following process.exit
|
|
138
|
+
* cannot truncate it.
|
|
139
|
+
*
|
|
140
|
+
* Not a bare writeSync: that call can short-write (it returns a byte count) and
|
|
141
|
+
* on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
|
|
142
|
+
* there rather than blocking the write. Loop over the remaining bytes, and if
|
|
143
|
+
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
144
|
+
* diagnostic is not worth crashing a stdio server over.
|
|
145
|
+
*/
|
|
146
|
+
async function errSync(message) {
|
|
147
|
+
const { writeSync } = await import("node:fs");
|
|
148
|
+
const buf = Buffer.from(message);
|
|
149
|
+
let off = 0;
|
|
150
|
+
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
151
|
+
try {
|
|
152
|
+
off += writeSync(2, buf, off, buf.length - off);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err?.code !== "EAGAIN") return;
|
|
155
|
+
// Pipe is full and the reader has not drained yet -- retry.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
/**
|
|
@@ -152,85 +206,201 @@ const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
|
152
206
|
if (mode === "node") {
|
|
153
207
|
await runInProcess();
|
|
154
208
|
} else {
|
|
155
|
-
const oam = findOam();
|
|
209
|
+
const { path: oam, shim: oamShim } = findOam();
|
|
210
|
+
// Read the version ONCE, and only when discovery found something: the gate
|
|
211
|
+
// below has to tell "too old" apart from "could not be read at all", and
|
|
212
|
+
// re-probing inside the branch would cost a second subprocess.
|
|
213
|
+
//
|
|
214
|
+
// Discovery itself stays stat-only; this is the first subprocess. It is paid
|
|
215
|
+
// on every launch that finds an oam -- including the ones that go on to fall
|
|
216
|
+
// back to Node -- not only the ones that end up spawning it. Measured 26ms
|
|
217
|
+
// median (n=12, windows-arm64), once per MCP session.
|
|
218
|
+
const found = oam ? oamVersion(oam) : null;
|
|
156
219
|
|
|
157
220
|
if (!oam) {
|
|
221
|
+
// An oam-named .cmd/.bat on PATH is a real install in a shape this launcher
|
|
222
|
+
// cannot spawn. Naming it turns "no oam binary was found" -- which reads as
|
|
223
|
+
// "install oam", the one thing that will not help -- into something the user
|
|
224
|
+
// can act on.
|
|
225
|
+
const shimNote = oamShim
|
|
226
|
+
? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
|
|
227
|
+
"Install the native oam binary, or point OAM_BIN at one.\n"
|
|
228
|
+
: "";
|
|
158
229
|
if (mode === "oam") {
|
|
159
|
-
// Explicitly demanded, so this is a real misconfiguration.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const { writeSync } = await import("node:fs");
|
|
163
|
-
writeSync(
|
|
164
|
-
2,
|
|
165
|
-
"ssh-mcp: SSH_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
230
|
+
// Explicitly demanded, so this is a real misconfiguration.
|
|
231
|
+
await errSync(
|
|
232
|
+
`ssh-mcp: SSH_MCP_RUNTIME=oam but no runnable oam binary was found.\n${shimNote}` +
|
|
166
233
|
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
|
|
167
234
|
);
|
|
168
235
|
process.exit(1);
|
|
169
236
|
}
|
|
237
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
238
|
+
// their oam install is a shape this launcher skips. Only worth saying when
|
|
239
|
+
// there was actually something to skip.
|
|
240
|
+
if (oamShim) await errSync(`ssh-mcp: ${shimNote}Using Node instead.\n`);
|
|
170
241
|
await runInProcess();
|
|
171
|
-
} else if (!atLeast(
|
|
172
|
-
// Discovery itself stays stat-only; this is the first subprocess, and it
|
|
173
|
-
// runs only once we have already decided to spawn oam anyway. Measured 26ms
|
|
174
|
-
// median (n=12, windows-arm64), paid once per MCP session.
|
|
242
|
+
} else if (!atLeast(found, OAM_MIN)) {
|
|
175
243
|
const min = OAM_MIN.join(".");
|
|
244
|
+
// Two different causes reach this branch and they need different remedies.
|
|
245
|
+
// `found === null` is NOT "old": oamVersion returns null when the binary
|
|
246
|
+
// could not be run at all (not executable, wrong arch, a .cmd/.bat Node
|
|
247
|
+
// refuses, deleted between the stat and the probe) or when its --version
|
|
248
|
+
// output did not parse. Telling that user to `oam self-update` sends them
|
|
249
|
+
// after the one cause it definitely is not, so the wording splits here.
|
|
250
|
+
const detail = found
|
|
251
|
+
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
252
|
+
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
253
|
+
const remedy = found
|
|
254
|
+
? "Run `oam self-update`, or use SSH_MCP_RUNTIME=node.\n"
|
|
255
|
+
: "Check that it is an executable oam binary for this platform, or use SSH_MCP_RUNTIME=node.\n";
|
|
176
256
|
if (mode === "oam") {
|
|
177
|
-
|
|
178
|
-
writeSync(
|
|
179
|
-
2,
|
|
180
|
-
`ssh-mcp: SSH_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
|
|
181
|
-
`Run \`oam self-update\`, or use SSH_MCP_RUNTIME=node.\n`,
|
|
182
|
-
);
|
|
257
|
+
await errSync(`ssh-mcp: SSH_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
183
258
|
process.exit(1);
|
|
184
259
|
}
|
|
185
|
-
// auto:
|
|
260
|
+
// auto: neither cause is worth failing over -- prefer Node. Say so, because
|
|
186
261
|
// a silent downgrade is how someone keeps running an oam they meant to
|
|
187
|
-
// update
|
|
188
|
-
|
|
262
|
+
// update, or never learns their oam is unexecutable. stdout carries the MCP
|
|
263
|
+
// frames, so stderr is the only safe channel.
|
|
264
|
+
//
|
|
265
|
+
// errSync, not process.stderr.write: an exit DOES follow, just indirectly.
|
|
266
|
+
// runInProcess() imports dist/index.js, whose top level answers `--version`
|
|
267
|
+
// with console.log + process.exit(0) (src/index.ts) -- and that exit
|
|
268
|
+
// truncates a pending async stderr write on Windows TTYs and pipes.
|
|
269
|
+
await errSync(`ssh-mcp: ${detail}; using Node instead.\n`);
|
|
189
270
|
await runInProcess();
|
|
190
271
|
} else {
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
env: process.env,
|
|
199
|
-
windowsHide: true,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
203
|
-
// wrong arch, permission), fall back rather than failing the whole server.
|
|
204
|
-
// `spawned` prevents falling back AFTER the child started, which would
|
|
205
|
-
// double-start the server on the same stdio.
|
|
206
|
-
let spawned = false;
|
|
207
|
-
child.on("spawn", () => {
|
|
208
|
-
spawned = true;
|
|
209
|
-
});
|
|
210
|
-
child.on("error", (err) => {
|
|
211
|
-
if (spawned) return;
|
|
272
|
+
// Every "oam could not be executed" outcome lands here: the synchronous
|
|
273
|
+
// throw from spawn() and the async 'error' event both mean the same thing
|
|
274
|
+
// and must degrade the same way, so the handling lives in one place.
|
|
275
|
+
// errSync rather than process.stderr.write because stderr is async for
|
|
276
|
+
// TTYs and pipes on Windows and the process.exit below truncates pending
|
|
277
|
+
// writes -- the same reason the two branches above use it.
|
|
278
|
+
const launchFailed = async (err) => {
|
|
212
279
|
if (mode === "oam") {
|
|
213
|
-
|
|
280
|
+
await errSync(`ssh-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
214
281
|
process.exit(1);
|
|
215
282
|
}
|
|
216
|
-
|
|
217
|
-
}
|
|
283
|
+
await runInProcess();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// ONE reporter shared by both launchFailed call sites below, so the
|
|
287
|
+
// sync-throw path and the 'error'-event path cannot drift apart. Either can
|
|
288
|
+
// reject: in auto mode launchFailed awaits runInProcess(), a bare import()
|
|
289
|
+
// that rejects whenever dist/index.js is missing or throws at load. At ESM
|
|
290
|
+
// top level an unhandled rejection is an uncaught exception -- it kills the
|
|
291
|
+
// process and replaces this launcher's diagnostic with a raw stack trace,
|
|
292
|
+
// which is the exact failure this handling exists to prevent.
|
|
293
|
+
const fallbackFailed = (e) => {
|
|
294
|
+
process.stderr.write(`ssh-mcp: fallback to Node failed (${e?.message ?? e})\n`);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
};
|
|
218
297
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
298
|
+
// `--` separates oam's own flags from the script's argv, so `ssh-mcp
|
|
299
|
+
// --version` and any host-supplied flags survive the hop unchanged.
|
|
300
|
+
let child = null;
|
|
301
|
+
try {
|
|
302
|
+
child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
303
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
304
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
305
|
+
// server's shutdown path.
|
|
306
|
+
stdio: "inherit",
|
|
307
|
+
env: process.env,
|
|
308
|
+
windowsHide: true,
|
|
224
309
|
});
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
312
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
313
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
314
|
+
// instead of falling back to Node.
|
|
315
|
+
//
|
|
316
|
+
// Belt-and-braces, deliberately: reaching this line already means
|
|
317
|
+
// execFileSync ran this same binary and read a version from it, so the
|
|
318
|
+
// shapes that throw synchronously (a .cmd/.bat Node refuses with EINVAL)
|
|
319
|
+
// have been diverted by the version gate above, and the ones the comments
|
|
320
|
+
// below name -- deleted (ENOENT), permission (EACCES) -- are among the
|
|
321
|
+
// errnos Node routes to the async 'error' event instead. What is left is
|
|
322
|
+
// a genuine TOCTOU: the binary replaced between the probe and the spawn.
|
|
323
|
+
// Cheap to keep, and the alternative is a stack trace in a stdio server.
|
|
324
|
+
await launchFailed(err).catch(fallbackFailed);
|
|
225
325
|
}
|
|
226
326
|
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
327
|
+
if (child) {
|
|
328
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
329
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
330
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
331
|
+
// double-start the server on the same stdio.
|
|
332
|
+
let spawned = false;
|
|
333
|
+
child.on("spawn", () => {
|
|
334
|
+
spawned = true;
|
|
335
|
+
});
|
|
336
|
+
child.on("error", (err) => {
|
|
337
|
+
if (spawned) return;
|
|
338
|
+
// Handle the rejection instead of discarding the promise: a failing
|
|
339
|
+
// runInProcess() used to escape as an unhandled rejection, replacing
|
|
340
|
+
// this launcher's diagnostic with a raw stack trace.
|
|
341
|
+
launchFailed(err).catch(fallbackFailed);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
345
|
+
// rather than the child being orphaned.
|
|
346
|
+
//
|
|
347
|
+
// Registering ANY handler for these suppresses Node's default
|
|
348
|
+
// terminate-on-signal, so the parent's exit has to be arranged
|
|
349
|
+
// explicitly. `child.killed` only records that kill() was CALLED, never
|
|
350
|
+
// that the child is gone, so gating on it swallows every signal after the
|
|
351
|
+
// first and wedges the launcher with no escape hatch.
|
|
352
|
+
//
|
|
353
|
+
// Escalation is driven by a TIMER, not by counting signals, and not by
|
|
354
|
+
// comparing timestamps. Counting is ambiguous: a supervisor routinely
|
|
355
|
+
// sends SIGINT then SIGTERM milliseconds apart, and a terminal Ctrl-C
|
|
356
|
+
// reaches the whole process group, so the child usually gets its own copy
|
|
357
|
+
// alongside ours -- reading "a second signal" as impatience hard-kills a
|
|
358
|
+
// child that is already shutting down cleanly. A timer makes the count
|
|
359
|
+
// irrelevant: ONE press is enough, and a wedged child dies on schedule
|
|
360
|
+
// without the user having to guess how many times to press. It also
|
|
361
|
+
// sidesteps the wall clock -- setTimeout is monotonic, so a clock step
|
|
362
|
+
// cannot mis-gate the window in either direction.
|
|
363
|
+
//
|
|
364
|
+
// POSIX vs Windows, and why we do not forward on Windows.
|
|
365
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so
|
|
366
|
+
// forwarding is what lets the child run its shutdown. On Windows there
|
|
367
|
+
// are no POSIX signals: child.kill IGNORES the name and calls
|
|
368
|
+
// TerminateProcess -- an immediate hard kill (verified: a child with a
|
|
369
|
+
// SIGTERM handler never runs it and dies with code=null). Forwarding
|
|
370
|
+
// there would ABORT the graceful shutdown the console's own Ctrl-C just
|
|
371
|
+
// started, skipping the child's process.on("exit") backstop -- which is
|
|
372
|
+
// what reaps an ssh-agent this server spawned (killStartedAgent,
|
|
373
|
+
// src/env.ts) -- and leak the daemon. The console has already notified
|
|
374
|
+
// the child, so on Windows the timer below is the only kill we issue.
|
|
375
|
+
//
|
|
376
|
+
// The window comfortably exceeds the child's own shutdown budget
|
|
377
|
+
// (server.close -> pool.drain -> killStartedAgent -> ~100ms FIN grace).
|
|
378
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
379
|
+
let escalation = null;
|
|
380
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
381
|
+
process.on(sig, () => {
|
|
382
|
+
// No try/catch: kill() on an already-exited child returns false, it
|
|
383
|
+
// does not throw. It throws only for a signal the platform does not
|
|
384
|
+
// know, which SIGINT/SIGTERM/SIGKILL never are.
|
|
385
|
+
if (!isWin) child.kill(sig);
|
|
386
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
387
|
+
escalation = setTimeout(() => {
|
|
388
|
+
// Still here after its grace window. Stop waiting on it.
|
|
389
|
+
child.kill("SIGKILL");
|
|
390
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
391
|
+
}, ESCALATE_AFTER_MS);
|
|
392
|
+
});
|
|
232
393
|
}
|
|
233
|
-
|
|
234
|
-
|
|
394
|
+
|
|
395
|
+
child.on("exit", (code, signal) => {
|
|
396
|
+
if (escalation) clearTimeout(escalation);
|
|
397
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
398
|
+
// conventional shell exit status rather than a bare 0.
|
|
399
|
+
if (signal) {
|
|
400
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
401
|
+
}
|
|
402
|
+
process.exit(code ?? 0);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
235
405
|
}
|
|
236
406
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface DiagnosticResult {
|
|
2
|
+
status: "ok" | "warning" | "error";
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export interface DiagnosticReport {
|
|
6
|
+
overall: "ok" | "warning" | "error";
|
|
7
|
+
checks: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
} & DiagnosticResult>;
|
|
10
|
+
suggestions: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const SSH_NON_KEY_FILES: ReadonlySet<string>;
|
|
13
|
+
export declare function isValidHostname(host: string): boolean;
|
|
14
|
+
export declare function runArgs(cmd: string, args: string[]): {
|
|
15
|
+
stdout: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function checkSshAgent(): DiagnosticResult;
|
|
19
|
+
export declare function checkSshKeys(): DiagnosticResult;
|
|
20
|
+
export declare function checkKnownHosts(host: string): DiagnosticResult;
|
|
21
|
+
export type SshProbeOutcome = "ok" | "permission-denied" | "connection-refused" | "timed-out" | "host-key-mismatch" | "dns-failure" | "unknown";
|
|
22
|
+
export interface SshProbeResult {
|
|
23
|
+
outcome: SshProbeOutcome;
|
|
24
|
+
/** Combined stdout+stderr from ssh, for the fall-through "unknown" message. */
|
|
25
|
+
output: string;
|
|
26
|
+
/** Wall time of the ssh invocation. Only `testConnection` surfaces this. */
|
|
27
|
+
elapsedMs: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function classifySshProbe(ok: boolean, output: string): SshProbeOutcome;
|
|
30
|
+
export declare function probeSshConnection(host: string, port: number): SshProbeResult;
|
|
31
|
+
export declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
|
|
32
|
+
export declare function checkSshConfig(host: string): DiagnosticResult;
|
|
33
|
+
export declare function diagnose(host: string, port?: number): DiagnosticReport;
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export interface KeyInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
type: string;
|
|
5
|
+
fingerprint?: string;
|
|
6
|
+
loadedInAgent: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface AgentResult {
|
|
9
|
+
running: boolean;
|
|
10
|
+
reachable: boolean;
|
|
11
|
+
socket?: string;
|
|
12
|
+
keys: string[];
|
|
13
|
+
started: boolean;
|
|
14
|
+
env?: {
|
|
15
|
+
SSH_AUTH_SOCK?: string;
|
|
16
|
+
SSH_AGENT_PID?: string;
|
|
17
|
+
};
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function killStartedAgent(): void;
|
|
21
|
+
export declare function ensureAgent(): AgentResult;
|
|
22
|
+
/**
|
|
23
|
+
* Outcome of a ~/.ssh scan. An empty `keys` array on its own is ambiguous -- it is what
|
|
24
|
+
* "there is no ~/.ssh", "~/.ssh could not be read" and "~/.ssh holds no private keys" all
|
|
25
|
+
* produce -- and only the last of those is remediated by generating a key. `status` is the
|
|
26
|
+
* discriminator that tells them apart:
|
|
27
|
+
*
|
|
28
|
+
* "ok" the directory was read; `keys` is what it holds (possibly nothing)
|
|
29
|
+
* "no-dir" ~/.ssh does not exist (a fresh machine -- ssh-keygen will create it)
|
|
30
|
+
* "unreadable" readdir threw: permission denied, or a non-directory at that path.
|
|
31
|
+
* `reason` carries the underlying errno message.
|
|
32
|
+
*
|
|
33
|
+
* `keys` is always present and always empty for the two non-"ok" statuses, so a caller that
|
|
34
|
+
* only wants the keys can read it without narrowing first.
|
|
35
|
+
*/
|
|
36
|
+
export type SshKeyListing = {
|
|
37
|
+
status: "ok";
|
|
38
|
+
dir: string;
|
|
39
|
+
keys: KeyInfo[];
|
|
40
|
+
} | {
|
|
41
|
+
status: "no-dir";
|
|
42
|
+
dir: string;
|
|
43
|
+
keys: KeyInfo[];
|
|
44
|
+
} | {
|
|
45
|
+
status: "unreadable";
|
|
46
|
+
dir: string;
|
|
47
|
+
keys: KeyInfo[];
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Thin wrapper kept for every caller that only needs the keys: it discards the reason a
|
|
52
|
+
* scan came back empty. Prefer listSshKeysDetailed when an empty result has to be
|
|
53
|
+
* explained to a human or an agent -- see the type above for why.
|
|
54
|
+
*/
|
|
55
|
+
export declare function listSshKeys(): KeyInfo[];
|
|
56
|
+
export declare function listSshKeysDetailed(): SshKeyListing;
|
|
57
|
+
export declare function loadKey(keyPath: string): {
|
|
58
|
+
status: "ok" | "error";
|
|
59
|
+
message: string;
|
|
60
|
+
};
|
|
61
|
+
export interface ConfigLookupResult {
|
|
62
|
+
hostname: string;
|
|
63
|
+
user: string;
|
|
64
|
+
port: string;
|
|
65
|
+
identityFile: string[];
|
|
66
|
+
proxyJump?: string;
|
|
67
|
+
proxyCommand?: string;
|
|
68
|
+
all: Record<string, string>;
|
|
69
|
+
raw: string;
|
|
70
|
+
}
|
|
71
|
+
export declare function configLookup(host: string): ConfigLookupResult | {
|
|
72
|
+
error: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function fixKnownHosts(host: string, port?: number): {
|
|
75
|
+
status: "ok" | "error";
|
|
76
|
+
message: string;
|
|
77
|
+
actions: string[];
|
|
78
|
+
};
|
|
79
|
+
export declare function checkGitSsh(host?: string, user?: string): {
|
|
80
|
+
status: "ok" | "error";
|
|
81
|
+
message: string;
|
|
82
|
+
authenticatedAs?: string;
|
|
83
|
+
};
|
|
84
|
+
export declare function testConnection(host: string, port?: number): {
|
|
85
|
+
status: "ok" | "warning" | "error";
|
|
86
|
+
message: string;
|
|
87
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|