@patimweb/pi-ssh 1.0.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/.node-version +1 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/index.ts +99 -0
- package/package.json +52 -0
- package/release.config.cjs +16 -0
- package/skills/ssh-key-setup/SKILL.md +71 -0
- package/skills/ssh-remote-work/SKILL.md +81 -0
- package/src/authorize.ts +237 -0
- package/src/clients/ssh-client.ts +490 -0
- package/src/config.ts +189 -0
- package/src/doctor.ts +212 -0
- package/src/formatting/formatters.ts +133 -0
- package/src/keys.ts +191 -0
- package/src/known-hosts.ts +189 -0
- package/src/tools/ssh-authorize.ts +88 -0
- package/src/tools/ssh-doctor.ts +26 -0
- package/src/tools/ssh-download.ts +51 -0
- package/src/tools/ssh-exec.ts +76 -0
- package/src/tools/ssh-keygen.ts +102 -0
- package/src/tools/ssh-list.ts +49 -0
- package/src/tools/ssh-profile.ts +77 -0
- package/src/tools/ssh-setup.ts +96 -0
- package/src/tools/ssh-status.ts +70 -0
- package/src/tools/ssh-upload.ts +51 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH connection, command execution and SFTP.
|
|
3
|
+
*
|
|
4
|
+
* All I/O lives here. The connection lifecycle is owned by `withConnection`,
|
|
5
|
+
* which is the single place that opens, hands over, and always closes a
|
|
6
|
+
* connection -- an SSH session left open is a file descriptor and a server
|
|
7
|
+
* side process that nobody will clean up.
|
|
8
|
+
*
|
|
9
|
+
* ssh2 is a pure JavaScript implementation, so nothing here depends on an
|
|
10
|
+
* `ssh` binary being installed. That matters on Windows, where one often
|
|
11
|
+
* is not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
// ssh2 is CommonJS, so its exports come off the default object.
|
|
17
|
+
import ssh2 from "ssh2";
|
|
18
|
+
import type { ConnectConfig, SFTPWrapper } from "ssh2";
|
|
19
|
+
|
|
20
|
+
const { Client } = ssh2;
|
|
21
|
+
import type {
|
|
22
|
+
ExecResult,
|
|
23
|
+
RemoteEntry,
|
|
24
|
+
ServerIdentity,
|
|
25
|
+
SshProfile,
|
|
26
|
+
TransferResult,
|
|
27
|
+
} from "../types.ts";
|
|
28
|
+
import {
|
|
29
|
+
HostKeyChangedError,
|
|
30
|
+
RemoteCommandError,
|
|
31
|
+
SshAuthError,
|
|
32
|
+
UnknownHostKeyError,
|
|
33
|
+
} from "../types.ts";
|
|
34
|
+
import {
|
|
35
|
+
addKnownHost,
|
|
36
|
+
checkHostKey,
|
|
37
|
+
defaultKnownHostsPath,
|
|
38
|
+
describeChangedKey,
|
|
39
|
+
} from "../known-hosts.ts";
|
|
40
|
+
import { expandPath } from "../config.ts";
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
|
|
43
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
|
|
44
|
+
/** Enough to be useful, small enough not to swamp a context window. */
|
|
45
|
+
const MAX_OUTPUT_CHARS = 200_000;
|
|
46
|
+
|
|
47
|
+
export interface ConnectOptions {
|
|
48
|
+
/** Record an unknown host key instead of refusing. Trust on first use. */
|
|
49
|
+
readonly acceptNewHostKey?: boolean;
|
|
50
|
+
/** Restrict authentication to one method, e.g. to force a password login. */
|
|
51
|
+
readonly only?: "password" | "key";
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Connection {
|
|
56
|
+
readonly client: Client;
|
|
57
|
+
readonly identity: ServerIdentity;
|
|
58
|
+
readonly profile: SshProfile;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readPrivateKey(profile: SshProfile): Buffer | null {
|
|
62
|
+
if (!profile.privateKeyPath) return null;
|
|
63
|
+
const resolved = expandPath(profile.privateKeyPath);
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(resolved);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new SshAuthError(
|
|
68
|
+
`Private key not readable: ${resolved}. Check the path, or use ssh_authorize to create one.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The authentication methods to offer, in the order they should be tried. */
|
|
74
|
+
function buildAuthMethods(
|
|
75
|
+
profile: SshProfile,
|
|
76
|
+
only: ConnectOptions["only"],
|
|
77
|
+
): Array<{ type: string; label: string; key?: Buffer; passphrase?: string; password?: string }> {
|
|
78
|
+
const methods: Array<{
|
|
79
|
+
type: string;
|
|
80
|
+
label: string;
|
|
81
|
+
key?: Buffer;
|
|
82
|
+
passphrase?: string;
|
|
83
|
+
password?: string;
|
|
84
|
+
}> = [];
|
|
85
|
+
|
|
86
|
+
const key = only === "password" ? null : readPrivateKey(profile);
|
|
87
|
+
if (key) {
|
|
88
|
+
methods.push({
|
|
89
|
+
type: "publickey",
|
|
90
|
+
label: "key",
|
|
91
|
+
key,
|
|
92
|
+
passphrase: profile.passphrase,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (only !== "key" && profile.password) {
|
|
96
|
+
methods.push({ type: "password", label: "password", password: profile.password });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (methods.length === 0) {
|
|
100
|
+
throw new SshAuthError(
|
|
101
|
+
only === "key"
|
|
102
|
+
? "This profile has no private key configured."
|
|
103
|
+
: "This profile has neither a password nor a private key. Add one with ssh_setup.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return methods;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Open a connection, verifying the host key first.
|
|
111
|
+
*
|
|
112
|
+
* The host key check happens during the handshake, so its outcome is captured
|
|
113
|
+
* in a closure and turned into a proper error afterwards -- ssh2 itself only
|
|
114
|
+
* reports a generic handshake failure.
|
|
115
|
+
*/
|
|
116
|
+
export function connect(
|
|
117
|
+
profile: SshProfile,
|
|
118
|
+
options: ConnectOptions = {},
|
|
119
|
+
): Promise<Connection> {
|
|
120
|
+
const knownHostsFile = profile.knownHostsFile
|
|
121
|
+
? expandPath(profile.knownHostsFile)
|
|
122
|
+
: defaultKnownHostsPath();
|
|
123
|
+
const strict = profile.strictHostKey !== false;
|
|
124
|
+
const methods = buildAuthMethods(profile, options.only);
|
|
125
|
+
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const client = new Client();
|
|
128
|
+
let hostKeyError: Error | null = null;
|
|
129
|
+
let identity: Omit<ServerIdentity, "authMethod"> | null = null;
|
|
130
|
+
let usedMethod = methods[0]?.label ?? "unknown";
|
|
131
|
+
let settled = false;
|
|
132
|
+
|
|
133
|
+
const fail = (err: Error) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
client.end();
|
|
137
|
+
reject(err);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const queue = [...methods];
|
|
141
|
+
const config: ConnectConfig = {
|
|
142
|
+
host: profile.host,
|
|
143
|
+
port: profile.port,
|
|
144
|
+
username: profile.user,
|
|
145
|
+
readyTimeout: profile.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
146
|
+
|
|
147
|
+
hostVerifier: (key: Buffer, verify: (ok: boolean) => void) => {
|
|
148
|
+
const check = checkHostKey(profile.host, profile.port, key, knownHostsFile);
|
|
149
|
+
identity = {
|
|
150
|
+
host: profile.host,
|
|
151
|
+
port: profile.port,
|
|
152
|
+
user: profile.user,
|
|
153
|
+
fingerprint: check.fingerprint,
|
|
154
|
+
keyType: check.keyType,
|
|
155
|
+
hostKeyVerdict: check.verdict,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (check.verdict === "match") {
|
|
159
|
+
verify(true);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (check.verdict === "changed") {
|
|
163
|
+
hostKeyError = new HostKeyChangedError(
|
|
164
|
+
describeChangedKey(check, profile.host),
|
|
165
|
+
check.fingerprint,
|
|
166
|
+
);
|
|
167
|
+
verify(false);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (check.verdict === "revoked") {
|
|
171
|
+
hostKeyError = new HostKeyChangedError(
|
|
172
|
+
`The host key for ${profile.host} is marked as revoked in ${check.file}. Refusing to connect.`,
|
|
173
|
+
check.fingerprint,
|
|
174
|
+
);
|
|
175
|
+
verify(false);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Unknown host.
|
|
180
|
+
if (!strict || options.acceptNewHostKey) {
|
|
181
|
+
try {
|
|
182
|
+
addKnownHost(profile.host, profile.port, key, knownHostsFile);
|
|
183
|
+
} catch {
|
|
184
|
+
/* an unwritable known_hosts must not block the connection */
|
|
185
|
+
}
|
|
186
|
+
verify(true);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
hostKeyError = new UnknownHostKeyError(
|
|
191
|
+
[
|
|
192
|
+
`The host key of ${profile.host}:${profile.port} is not in ${check.file}.`,
|
|
193
|
+
"",
|
|
194
|
+
`Offered: ${check.keyType} ${check.fingerprint}`,
|
|
195
|
+
"",
|
|
196
|
+
"Check that fingerprint against the server, then re-run with acceptNewHostKey to record it.",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
check.fingerprint,
|
|
199
|
+
);
|
|
200
|
+
verify(false);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// Controlling the order also reveals which method succeeded: whatever
|
|
204
|
+
// was offered last is what the server accepted.
|
|
205
|
+
authHandler: (_authsLeft: unknown, _partial: unknown, next: (m: unknown) => void) => {
|
|
206
|
+
const method = queue.shift();
|
|
207
|
+
if (!method) {
|
|
208
|
+
next(false);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
usedMethod = method.label;
|
|
212
|
+
if (method.type === "publickey") {
|
|
213
|
+
next({
|
|
214
|
+
type: "publickey",
|
|
215
|
+
username: profile.user,
|
|
216
|
+
key: method.key,
|
|
217
|
+
...(method.passphrase ? { passphrase: method.passphrase } : {}),
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
next({ type: "password", username: profile.user, password: method.password });
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
client.on("ready", () => {
|
|
226
|
+
if (settled) return;
|
|
227
|
+
settled = true;
|
|
228
|
+
resolve({
|
|
229
|
+
client,
|
|
230
|
+
profile,
|
|
231
|
+
identity: { ...(identity as Omit<ServerIdentity, "authMethod">), authMethod: usedMethod },
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
client.on("error", (err: Error & { level?: string }) => {
|
|
236
|
+
// A rejected host key surfaces as a handshake error, so the specific
|
|
237
|
+
// reason captured above wins over ssh2's generic message.
|
|
238
|
+
if (hostKeyError) {
|
|
239
|
+
fail(hostKeyError);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (err.level === "client-authentication") {
|
|
243
|
+
const tried = methods.map((method) => method.label).join(" and ");
|
|
244
|
+
fail(
|
|
245
|
+
new SshAuthError(
|
|
246
|
+
`${profile.user}@${profile.host} rejected the credentials (tried ${tried}). Check the user name, password or key.`,
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
|
|
252
|
+
fail(new Error(`${profile.host}:${profile.port} refused the connection. Is sshd running and the port right?`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if ((err as NodeJS.ErrnoException).code === "ENOTFOUND") {
|
|
256
|
+
fail(new Error(`Host not found: ${profile.host}.`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
fail(err);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (options.signal) {
|
|
263
|
+
const abort = () => fail(new Error("Connection cancelled."));
|
|
264
|
+
if (options.signal.aborted) {
|
|
265
|
+
abort();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
client.connect(config);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
fail(err as Error);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Open a connection, run `fn`, and close it whatever happens. */
|
|
280
|
+
export async function withConnection<T>(
|
|
281
|
+
profile: SshProfile,
|
|
282
|
+
options: ConnectOptions,
|
|
283
|
+
fn: (connection: Connection) => Promise<T>,
|
|
284
|
+
): Promise<T> {
|
|
285
|
+
const connection = await connect(profile, options);
|
|
286
|
+
try {
|
|
287
|
+
return await fn(connection);
|
|
288
|
+
} finally {
|
|
289
|
+
connection.client.end();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Commands -------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function clamp(text: string): { text: string; truncated: boolean } {
|
|
296
|
+
if (text.length <= MAX_OUTPUT_CHARS) return { text, truncated: false };
|
|
297
|
+
return {
|
|
298
|
+
text: `${text.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated at ${MAX_OUTPUT_CHARS} characters]`,
|
|
299
|
+
truncated: true,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface ExecOptions {
|
|
304
|
+
readonly timeoutMs?: number;
|
|
305
|
+
readonly cwd?: string;
|
|
306
|
+
readonly signal?: AbortSignal;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Quote a path for a POSIX shell, so a space or quote cannot break out. */
|
|
310
|
+
export function shellQuote(value: string): string {
|
|
311
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function execCommand(
|
|
315
|
+
connection: Connection,
|
|
316
|
+
command: string,
|
|
317
|
+
options: ExecOptions = {},
|
|
318
|
+
): Promise<ExecResult> {
|
|
319
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
320
|
+
const full = options.cwd ? `cd ${shellQuote(options.cwd)} && ${command}` : command;
|
|
321
|
+
const started = Date.now();
|
|
322
|
+
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
connection.client.exec(full, (err, stream) => {
|
|
325
|
+
if (err) {
|
|
326
|
+
reject(err);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let stdout = "";
|
|
331
|
+
let stderr = "";
|
|
332
|
+
let code: number | null = null;
|
|
333
|
+
let signalName: string | undefined;
|
|
334
|
+
let settled = false;
|
|
335
|
+
|
|
336
|
+
const timer = setTimeout(() => {
|
|
337
|
+
if (settled) return;
|
|
338
|
+
settled = true;
|
|
339
|
+
stream.close();
|
|
340
|
+
reject(
|
|
341
|
+
new RemoteCommandError(
|
|
342
|
+
`Command timed out after ${Math.round(timeoutMs / 1000)}s: ${command}`,
|
|
343
|
+
{
|
|
344
|
+
command,
|
|
345
|
+
stdout: clamp(stdout).text,
|
|
346
|
+
stderr: clamp(stderr).text,
|
|
347
|
+
code: null,
|
|
348
|
+
durationMs: Date.now() - started,
|
|
349
|
+
truncated: false,
|
|
350
|
+
},
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
}, timeoutMs);
|
|
354
|
+
|
|
355
|
+
const onAbort = () => {
|
|
356
|
+
if (settled) return;
|
|
357
|
+
settled = true;
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
stream.close();
|
|
360
|
+
reject(new Error("Command cancelled."));
|
|
361
|
+
};
|
|
362
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
363
|
+
|
|
364
|
+
stream.on("data", (chunk: Buffer) => {
|
|
365
|
+
if (stdout.length < MAX_OUTPUT_CHARS * 2) stdout += chunk.toString("utf-8");
|
|
366
|
+
});
|
|
367
|
+
stream.stderr.on("data", (chunk: Buffer) => {
|
|
368
|
+
if (stderr.length < MAX_OUTPUT_CHARS * 2) stderr += chunk.toString("utf-8");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
stream.on("exit", (exitCode: number | null, exitSignal?: string) => {
|
|
372
|
+
code = exitCode;
|
|
373
|
+
signalName = exitSignal;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
stream.on("close", () => {
|
|
377
|
+
if (settled) return;
|
|
378
|
+
settled = true;
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
381
|
+
|
|
382
|
+
const out = clamp(stdout);
|
|
383
|
+
const errOut = clamp(stderr);
|
|
384
|
+
resolve({
|
|
385
|
+
command,
|
|
386
|
+
stdout: out.text,
|
|
387
|
+
stderr: errOut.text,
|
|
388
|
+
code,
|
|
389
|
+
signal: signalName,
|
|
390
|
+
durationMs: Date.now() - started,
|
|
391
|
+
truncated: out.truncated || errOut.truncated,
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- SFTP -----------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
export function openSftp(connection: Connection): Promise<SFTPWrapper> {
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
connection.client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function describeType(entry: { attrs: { isDirectory(): boolean; isSymbolicLink(): boolean; isFile(): boolean } }): RemoteEntry["type"] {
|
|
407
|
+
if (entry.attrs.isDirectory()) return "directory";
|
|
408
|
+
if (entry.attrs.isSymbolicLink()) return "symlink";
|
|
409
|
+
if (entry.attrs.isFile()) return "file";
|
|
410
|
+
return "other";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Render a mode as the rwx string people expect from ls. */
|
|
414
|
+
export function formatMode(mode: number): string {
|
|
415
|
+
const bits = "rwxrwxrwx";
|
|
416
|
+
let out = "";
|
|
417
|
+
for (let i = 0; i < 9; i += 1) {
|
|
418
|
+
out += mode & (1 << (8 - i)) ? bits[i] : "-";
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function listDirectory(
|
|
424
|
+
connection: Connection,
|
|
425
|
+
remotePath: string,
|
|
426
|
+
): Promise<RemoteEntry[]> {
|
|
427
|
+
const sftp = await openSftp(connection);
|
|
428
|
+
return new Promise((resolve, reject) => {
|
|
429
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
430
|
+
if (err) {
|
|
431
|
+
reject(new Error(`Cannot list ${remotePath}: ${err.message}`));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const entries = list.map((entry) => ({
|
|
435
|
+
name: entry.filename,
|
|
436
|
+
type: describeType(entry),
|
|
437
|
+
size: entry.attrs.size,
|
|
438
|
+
modified: entry.attrs.mtime
|
|
439
|
+
? new Date(entry.attrs.mtime * 1000).toISOString()
|
|
440
|
+
: undefined,
|
|
441
|
+
mode: formatMode(entry.attrs.mode),
|
|
442
|
+
}));
|
|
443
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
444
|
+
resolve(entries);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function uploadFile(
|
|
450
|
+
connection: Connection,
|
|
451
|
+
localPath: string,
|
|
452
|
+
remotePath: string,
|
|
453
|
+
): Promise<TransferResult> {
|
|
454
|
+
const local = expandPath(localPath);
|
|
455
|
+
const stat = fs.statSync(local);
|
|
456
|
+
if (!stat.isFile()) {
|
|
457
|
+
throw new Error(`Not a file: ${local}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const sftp = await openSftp(connection);
|
|
461
|
+
return new Promise((resolve, reject) => {
|
|
462
|
+
sftp.fastPut(local, remotePath, (err) => {
|
|
463
|
+
if (err) {
|
|
464
|
+
reject(new Error(`Upload to ${remotePath} failed: ${err.message}`));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
resolve({ localPath: local, remotePath, size: stat.size });
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function downloadFile(
|
|
473
|
+
connection: Connection,
|
|
474
|
+
remotePath: string,
|
|
475
|
+
localPath: string,
|
|
476
|
+
): Promise<TransferResult> {
|
|
477
|
+
const local = expandPath(localPath);
|
|
478
|
+
fs.mkdirSync(path.dirname(local), { recursive: true });
|
|
479
|
+
|
|
480
|
+
const sftp = await openSftp(connection);
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
sftp.fastGet(remotePath, local, (err) => {
|
|
483
|
+
if (err) {
|
|
484
|
+
reject(new Error(`Download of ${remotePath} failed: ${err.message}`));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
resolve({ localPath: local, remotePath, size: fs.statSync(local).size });
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration persistence and state.
|
|
3
|
+
*
|
|
4
|
+
* Stores named host profiles in ~/.pi/ssh-config.json.
|
|
5
|
+
* Supports multiple profiles with an active profile selector.
|
|
6
|
+
*
|
|
7
|
+
* File format:
|
|
8
|
+
* { "profiles": { "name": SshProfile, ... }, "activeProfile": "name" }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import type { SshProfile, SshProfiles } from "./types.ts";
|
|
15
|
+
import { SshNotConfiguredError } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
let profiles: Record<string, SshProfile> = {};
|
|
18
|
+
let activeProfile: string | null = null;
|
|
19
|
+
|
|
20
|
+
function configPath(): string {
|
|
21
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
22
|
+
return path.join(home, ".pi", "ssh-config.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Expand a leading ~ and resolve to an absolute path. */
|
|
26
|
+
export function expandPath(input: string): string {
|
|
27
|
+
const raw = String(input ?? "").trim();
|
|
28
|
+
if (!raw) return raw;
|
|
29
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
30
|
+
if (raw === "~") return home;
|
|
31
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
32
|
+
return path.join(home, raw.slice(2));
|
|
33
|
+
}
|
|
34
|
+
return path.resolve(raw);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write the profile store.
|
|
39
|
+
*
|
|
40
|
+
* The file can hold passwords and key passphrases, so it must never be group-
|
|
41
|
+
* or world-readable. writeFileSync's `mode` only applies when the file is
|
|
42
|
+
* created, so an existing file keeps its old permissions -- we therefore write
|
|
43
|
+
* to a private temp file and rename it into place, which is also atomic.
|
|
44
|
+
*/
|
|
45
|
+
function persistProfiles(): void {
|
|
46
|
+
const filePath = configPath();
|
|
47
|
+
const dir = path.dirname(filePath);
|
|
48
|
+
if (!fs.existsSync(dir)) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data: SshProfiles = { profiles, activeProfile };
|
|
53
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), {
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
mode: 0o600,
|
|
59
|
+
});
|
|
60
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
61
|
+
fs.renameSync(tmpPath, filePath);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(tmpPath);
|
|
65
|
+
} catch {
|
|
66
|
+
/* ignore */
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadConfig(): void {
|
|
73
|
+
try {
|
|
74
|
+
const filePath = configPath();
|
|
75
|
+
if (!fs.existsSync(filePath)) {
|
|
76
|
+
profiles = {};
|
|
77
|
+
activeProfile = null;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
82
|
+
|
|
83
|
+
// Tighten permissions on files written by older versions.
|
|
84
|
+
try {
|
|
85
|
+
const mode = fs.statSync(filePath).mode & 0o777;
|
|
86
|
+
if (mode !== 0o600) fs.chmodSync(filePath, 0o600);
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (raw && typeof raw === "object" && raw.profiles) {
|
|
92
|
+
profiles = raw.profiles;
|
|
93
|
+
if (raw.activeProfile && profiles[raw.activeProfile]) {
|
|
94
|
+
activeProfile = raw.activeProfile;
|
|
95
|
+
} else {
|
|
96
|
+
const names = Object.keys(profiles);
|
|
97
|
+
activeProfile = names.length > 0 ? names[0] : null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
profiles = {};
|
|
102
|
+
activeProfile = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getConfig(): SshProfile | null {
|
|
107
|
+
if (!activeProfile) return null;
|
|
108
|
+
return profiles[activeProfile] ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getProfile(name: string): SshProfile | null {
|
|
112
|
+
return profiles[name] ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a profile parameter: if a name is given, return that profile
|
|
117
|
+
* (or throw if not found). Otherwise, return the active profile or throw.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveProfile(name?: string): { name: string; profile: SshProfile } {
|
|
120
|
+
if (name) {
|
|
121
|
+
const profile = getProfile(name);
|
|
122
|
+
if (!profile) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Profile "${name}" not found. Available: ${Object.keys(profiles).join(", ") || "none"}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { name, profile };
|
|
128
|
+
}
|
|
129
|
+
const profile = getConfig();
|
|
130
|
+
if (!profile || !activeProfile) {
|
|
131
|
+
throw new SshNotConfiguredError();
|
|
132
|
+
}
|
|
133
|
+
return { name: activeProfile, profile };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getProfiles(): Record<string, SshProfile> {
|
|
137
|
+
return { ...profiles };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getActiveProfile(): string | null {
|
|
141
|
+
return activeProfile;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function saveProfile(name: string, profile: SshProfile): void {
|
|
145
|
+
profiles[name] = profile;
|
|
146
|
+
if (!activeProfile || Object.keys(profiles).length === 1) {
|
|
147
|
+
activeProfile = name;
|
|
148
|
+
}
|
|
149
|
+
persistProfiles();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Merge changes into an existing profile, keeping everything else. */
|
|
153
|
+
export function updateProfile(name: string, patch: Partial<SshProfile>): SshProfile {
|
|
154
|
+
const existing = profiles[name];
|
|
155
|
+
if (!existing) {
|
|
156
|
+
throw new Error(`Profile "${name}" does not exist.`);
|
|
157
|
+
}
|
|
158
|
+
const updated = { ...existing, ...patch };
|
|
159
|
+
profiles[name] = updated;
|
|
160
|
+
persistProfiles();
|
|
161
|
+
return updated;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setActiveProfile(name: string): void {
|
|
165
|
+
if (!profiles[name]) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Profile "${name}" does not exist. Available: ${Object.keys(profiles).join(", ") || "none"}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
activeProfile = name;
|
|
171
|
+
persistProfiles();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function deleteProfile(name: string): boolean {
|
|
175
|
+
if (!profiles[name]) return false;
|
|
176
|
+
delete profiles[name];
|
|
177
|
+
if (activeProfile === name) {
|
|
178
|
+
const names = Object.keys(profiles);
|
|
179
|
+
activeProfile = names.length > 0 ? names[0] : null;
|
|
180
|
+
}
|
|
181
|
+
persistProfiles();
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @internal Reset state -- for testing only */
|
|
186
|
+
export function _resetForTesting(): void {
|
|
187
|
+
profiles = {};
|
|
188
|
+
activeProfile = null;
|
|
189
|
+
}
|