@xynogen/pix-ssh 0.2.6 → 0.3.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 CHANGED
@@ -7,7 +7,12 @@ Pi tool — `ssh_run`: run a command through a remote host's configured SSH shel
7
7
 
8
8
  ## What it does
9
9
 
10
- Registers the `ssh_run` tool, which executes a command through the configured SSH shell on a remote machine behind a permission dialog (the shared overlay from `@xynogen/pix-pretty`, the same one pix-sudo uses). Initial and privileged calls require Allow/Deny approval in the UI, with a 60-second auto-deny timeout. Non-privileged calls may be auto-approved during the 15-minute per-host approval window or in YOLO mode when no password is missing; each host-window auto-approval emits a notification. Output is truncated to 50 KB / 2000 lines. In non-interactive (RPC/JSON) mode the tool is blocked immediately.
10
+ Registers the `ssh_run` tool — run a command through the configured SSH shell on a remote machine, behind the same permission dialog pix-sudo uses (shared overlay from `@xynogen/pix-pretty`).
11
+
12
+ - **Initial + privileged calls** → Allow/Deny approval, 60-second auto-deny timeout.
13
+ - **Non-privileged calls** → may auto-approve during the 15-minute per-host window, or in YOLO mode when no password is missing; each auto-approval emits a notification.
14
+
15
+ Output is truncated to 50 KB / 2000 lines. Non-interactive (RPC/JSON) mode blocks the tool immediately.
11
16
 
12
17
  **Parameters:** `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run through POSIX `sudo` as root), optional `reason`.
13
18
 
@@ -37,6 +42,8 @@ Completed calls collapse after the configured Pix delay into a status row such a
37
42
  pi install npm:@xynogen/pix-ssh
38
43
  ```
39
44
 
45
+ > Standalone/opt-in — **not** bundled by [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Remote execution over SSH is a networked, privileged capability, so you install it deliberately.
46
+
40
47
  ## Full distro
41
48
 
42
49
  Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * pix-ssh — Pi extension
3
3
  *
4
- * Registers an `ssh_run` tool: run a shell command on a remote host over SSH,
5
- * optionally as root (remote sudo). One overlay handles confirm + any needed
6
- * password entry, mirroring pix-sudo.
4
+ * Registers an `ssh_run` tool: run a command or transfer files/directories over
5
+ * SSH. Commands may optionally run as root through remote sudo. One overlay
6
+ * handles confirmation and any needed password entry, mirroring pix-sudo.
7
7
  *
8
8
  * Auth:
9
9
  * - SSH: key/agent/existing-master first (BatchMode probe). If that fails,
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Security notes:
22
22
  * - Passwords never leave JS memory; never written to disk; never in argv.
23
- * - Every command still requires explicit per-call confirmation in the UI.
23
+ * - File transfers are warning-level and show their overwrite risk in the UI.
24
24
  * - No UI (RPC / JSON mode) = blocked immediately.
25
25
  * - Output truncated to 50 KB / 2000 lines.
26
26
  */
@@ -44,6 +44,7 @@ import {
44
44
  sectionRule,
45
45
  termW,
46
46
  } from "@xynogen/pix-pretty/utils";
47
+ import { SPINNER } from "@xynogen/pix-pretty/widget-format";
47
48
  import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
48
49
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
49
50
  import { Type } from "typebox";
@@ -62,11 +63,15 @@ import {
62
63
  probeKeyAuth,
63
64
  resolveSshHost,
64
65
  runSsh,
66
+ runTransfer,
67
+ type TransferDirection,
68
+ transferApprovalDecision,
65
69
  truncate,
66
70
  } from "./lib.ts";
67
71
 
68
72
  const PROMPT_TIMEOUT_MS = 60_000;
69
73
  const MAX_PASSWORD_ATTEMPTS = 3;
74
+ const SPINNER_INTERVAL_MS = 120;
70
75
 
71
76
  // In-memory per-host credential cache (session-scoped, never persisted).
72
77
  // Key = canonical "user@host:port". Cleared on process exit.
@@ -115,6 +120,78 @@ export interface SshResultDetails {
115
120
  _render?: string;
116
121
  }
117
122
 
123
+ type SshParams =
124
+ | {
125
+ action?: "command";
126
+ host: string;
127
+ command: string;
128
+ sudo?: boolean;
129
+ reason?: string;
130
+ }
131
+ | {
132
+ action: "file";
133
+ host: string;
134
+ direction: TransferDirection;
135
+ source: string;
136
+ destination: string;
137
+ recursive?: boolean;
138
+ reason?: string;
139
+ };
140
+
141
+ interface SshOperation {
142
+ action: "command" | "file";
143
+ command: string;
144
+ sudo: boolean;
145
+ reason?: string;
146
+ direction?: TransferDirection;
147
+ source: string;
148
+ destination: string;
149
+ recursive: boolean;
150
+ }
151
+
152
+ function normalizeOperation(params: SshParams): SshOperation {
153
+ if (params.action === "file") {
154
+ const source = params.source.trim();
155
+ const destination = params.destination.trim();
156
+ return {
157
+ action: "file",
158
+ command: `${params.direction} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
159
+ sudo: false,
160
+ reason: params.reason,
161
+ direction: params.direction,
162
+ source,
163
+ destination,
164
+ recursive: params.recursive === true,
165
+ };
166
+ }
167
+ return {
168
+ action: "command",
169
+ command: params.command,
170
+ sudo: params.sudo === true,
171
+ reason: params.reason,
172
+ source: "",
173
+ destination: "",
174
+ recursive: false,
175
+ };
176
+ }
177
+
178
+ function approvalBody(operation: SshOperation, host: string, port?: number): string[] {
179
+ const { action, command, destination, direction, reason, recursive, source, sudo } = operation;
180
+ return [
181
+ reason?.trim() ? `Intent: ${reason.trim()}` : "No reason provided by AI",
182
+ `Host: ${host}${port ? ` (port ${port})` : ""}`,
183
+ ...(action === "command"
184
+ ? [`Command: ${sudo ? "sudo " : ""}${command}`]
185
+ : [
186
+ `Direction: ${direction === "download" ? "Download" : "Upload"}`,
187
+ `From: ${source}`,
188
+ `To: ${destination}`,
189
+ `Mode: ${recursive ? "Recursive copy" : "Single item"}`,
190
+ "Warning: existing destination may be overwritten",
191
+ ]),
192
+ ];
193
+ }
194
+
118
195
  function safeOneLine(value: string): string {
119
196
  return value
120
197
  .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
@@ -151,12 +228,15 @@ function updatePresentation(
151
228
  sudo: boolean,
152
229
  reason: string | undefined,
153
230
  outcome: "awaiting-approval" | "running",
231
+ message?: string,
154
232
  ): void {
155
233
  onUpdate?.({
156
234
  content: [
157
235
  {
158
236
  type: "text",
159
- text: outcome === "awaiting-approval" ? "Awaiting approval…" : `Running on ${host}…`,
237
+ text:
238
+ message ??
239
+ (outcome === "awaiting-approval" ? "Awaiting approval…" : `Running on ${host}…`),
160
240
  },
161
241
  ],
162
242
  details: makeDetails(command, host, sudo, reason, { outcome }),
@@ -218,7 +298,7 @@ export default function (pi: ExtensionAPI): void {
218
298
  name: "ssh_run",
219
299
  label: "Run over SSH",
220
300
  description:
221
- "Run a command through the remote host's configured SSH shell, optionally through POSIX sudo. " +
301
+ "Run a command or transfer files/directories through SSH. Commands may optionally use POSIX sudo. " +
222
302
  "Basic cmd/PowerShell/pwsh commands may work, but Windows shells are best-effort: shell selection, " +
223
303
  "quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows " +
224
304
  "administrator/UAC elevation are not supported. Back away and tell the user when correctness " +
@@ -226,40 +306,80 @@ export default function (pi: ExtensionAPI): void {
226
306
  "A configured approval window or YOLO mode may auto-approve non-privileged commands when no password is missing. " +
227
307
  "SSH auth tries key/agent first, then prompts for a login password if needed. " +
228
308
  "Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
229
- "remote sudo password). Always provide a clear `reason`.",
230
- promptSnippet: "Run a remote SSH command (Windows shells best-effort; POSIX sudo only)",
309
+ 'remote sudo password). For transfer, set `action: "file"`, `direction`, `source`, ' +
310
+ "`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
311
+ promptSnippet: "Run a remote command or transfer files over SSH",
231
312
  promptGuidelines: [
232
- "ssh_run sends commands to the remote host's configured SSH shell. Basic cmd, PowerShell, or " +
313
+ "ssh_run sends commands or transfers files/directories over SSH. Basic cmd, PowerShell, or " +
233
314
  "pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
234
315
  "when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
235
316
  "semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
236
- "only. Provide `host` as `[user@]host[:port]` and always explain the intent in `reason`.",
317
+ 'only. Provide `host` as `[user@]host[:port]`. For file transfer, use `action: "file"`, ' +
318
+ '`direction: "upload" | "download"`, `source`, `destination`, and optional `recursive`. ' +
319
+ "Transfers may overwrite the destination and are warning-level: AFK/YOLO auto-approve when " +
320
+ "no login password is missing. Always explain the intent in `reason`.",
237
321
  ],
238
322
 
239
323
  renderShell: "self",
240
324
 
241
- parameters: Type.Object({
242
- host: Type.String({
243
- description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
244
- }),
245
- command: Type.String({
246
- description: "Command sent to the remote host's configured SSH shell.",
325
+ parameters: Type.Union([
326
+ Type.Object({
327
+ action: Type.Optional(Type.Literal("command")),
328
+ host: Type.String({
329
+ description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
330
+ }),
331
+ command: Type.String({
332
+ description: "Command sent to the remote host's configured SSH shell.",
333
+ }),
334
+ sudo: Type.Optional(
335
+ Type.Boolean({
336
+ description: "Run the command as root on the remote host via sudo. Default false.",
337
+ }),
338
+ ),
339
+ reason: Type.Optional(
340
+ Type.String({
341
+ description: "Short plain-English explanation of intent, shown to the user.",
342
+ }),
343
+ ),
247
344
  }),
248
- sudo: Type.Optional(
249
- Type.Boolean({
250
- description: "Run the command as root on the remote host via sudo. Default false.",
345
+ Type.Object({
346
+ action: Type.Literal("file"),
347
+ host: Type.String({
348
+ description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
349
+ }),
350
+ direction: Type.Union([Type.Literal("upload"), Type.Literal("download")]),
351
+ source: Type.String({
352
+ description: "Source path. Local for upload; remote for download.",
251
353
  }),
252
- ),
253
- reason: Type.Optional(
254
- Type.String({
255
- description: "Short plain-English explanation of intent, shown to the user.",
354
+ destination: Type.String({
355
+ description: "Destination path. Remote for upload; local for download.",
256
356
  }),
257
- ),
258
- }),
357
+ recursive: Type.Optional(
358
+ Type.Boolean({ description: "Copy a directory recursively. Default false." }),
359
+ ),
360
+ reason: Type.Optional(
361
+ Type.String({
362
+ description: "Short plain-English explanation of intent, shown to the user.",
363
+ }),
364
+ ),
365
+ }),
366
+ ]),
259
367
 
260
368
  async execute(_toolCallId, params, sig, onUpdate, ctx) {
261
- const { command, reason } = params;
262
- const sudo = params.sudo === true;
369
+ const operation = normalizeOperation(params);
370
+ const { action, command, destination, direction, reason, recursive, source, sudo } =
371
+ operation;
372
+
373
+ if (action === "file" && (!source || !destination)) {
374
+ return {
375
+ content: [{ type: "text", text: "ssh_run failed: source and destination are required" }],
376
+ details: makeDetails(command, params.host, false, reason, {
377
+ outcome: "error",
378
+ errorKind: "execution",
379
+ }),
380
+ isError: true,
381
+ };
382
+ }
263
383
 
264
384
  let spec: HostSpec;
265
385
  try {
@@ -287,7 +407,7 @@ export default function (pi: ExtensionAPI): void {
287
407
 
288
408
  const mode = getUnattendedMode(pi.events);
289
409
  const yolo = mode === "yolo";
290
- if (mode === "afk") {
410
+ if (action === "command" && mode === "afk") {
291
411
  return {
292
412
  content: [{ type: "text", text: "ssh_run denied immediately — AFK mode is active." }],
293
413
  details: makeDetails(command, host, sudo, reason, {
@@ -327,12 +447,26 @@ export default function (pi: ExtensionAPI): void {
327
447
  ...(needLogin ? (["login"] as const) : []),
328
448
  ...(needSudo ? (["sudo"] as const) : []),
329
449
  ];
450
+ const transferDecision =
451
+ action === "file" ? transferApprovalDecision(mode, needLogin) : "ask";
452
+ if (transferDecision === "deny") {
453
+ return {
454
+ content: [
455
+ {
456
+ type: "text",
457
+ text: "ssh_run file transfer denied — unattended mode cannot enter a missing SSH login password.",
458
+ },
459
+ ],
460
+ details: makeDetails(command, host, false, reason, {
461
+ outcome: "denied",
462
+ cancellationKind: "denied",
463
+ }),
464
+ };
465
+ }
330
466
 
331
467
  const body = [
332
- reason?.trim() ? `Intent: ${reason.trim()}` : "No reason provided by AI",
333
- `Host: ${host}${spec.port ? ` (port ${spec.port})` : ""}`,
334
- `Command: ${sudo ? "sudo " : ""}${command}`,
335
- ...(keyOk && !creds.loginPassword ? ["(key-based auth — no login password needed)"] : []),
468
+ ...approvalBody(operation, host, spec.port),
469
+ ...(keyOk && !creds.loginPassword ? ["Auth: SSH key (no password)"] : []),
336
470
  ];
337
471
 
338
472
  const collected: { login?: string; sudo?: string } = {};
@@ -345,29 +479,49 @@ export default function (pi: ExtensionAPI): void {
345
479
  // the confirm overlay entirely (visible notify below). Privileged commands
346
480
  // (sudo:true, or a sudo/su/doas/pkexec token in the command text) are never
347
481
  // auto-approved — they always re-confirm.
348
- const privileged = sudo || commandEscalatesPrivilege(command);
482
+ const privileged = action === "command" && (sudo || commandEscalatesPrivilege(command));
349
483
  const alreadyApproved =
350
- !privileged && hostApproved(approvedHosts, key) && promptFor.length === 0;
484
+ action === "command" &&
485
+ !privileged &&
486
+ hostApproved(approvedHosts, key) &&
487
+ promptFor.length === 0;
351
488
  if (alreadyApproved) {
352
489
  ctx.ui.notify(`🔐 ssh_run auto-approved — ${host} allowed this session`, "warning");
490
+ } else if (transferDecision === "allow") {
491
+ ctx.ui.notify(
492
+ `⚠ ssh_run file transfer auto-approved — ${mode.toUpperCase()} warning policy`,
493
+ "warning",
494
+ );
353
495
  }
354
496
 
355
497
  const runOverlay = (): Promise<OverlayResult> =>
356
498
  withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
357
- if ((yolo || alreadyApproved) && promptFor.length === 0) {
499
+ if ((transferDecision === "allow" || yolo || alreadyApproved) && promptFor.length === 0) {
358
500
  return { action: "approved", password: "" } as OverlayResult;
359
501
  }
360
502
  // Confirm-only when no password is missing.
361
503
  if (promptFor.length === 0) {
362
504
  return showOverlay(ctx.ui, {
363
505
  mode: "confirm",
364
- title: "🔐 SSH COMMAND REQUEST",
506
+ title:
507
+ action === "file"
508
+ ? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
509
+ : "🔐 SSH COMMAND REQUEST",
365
510
  body,
366
- accent: sudo ? "error" : "accent",
511
+ accent: sudo ? "error" : action === "file" ? "warning" : "accent",
367
512
  timeoutMs: PROMPT_TIMEOUT_MS,
368
513
  choices: [
369
- { value: "yes", label: "Allow", description: "Run the command" },
370
- { value: "no", label: "Deny", description: "Block the command" },
514
+ {
515
+ value: "yes",
516
+ label: "Allow",
517
+ description:
518
+ action === "file" ? "Copy to destination (may overwrite)" : "Run the command",
519
+ },
520
+ {
521
+ value: "no",
522
+ label: "Deny",
523
+ description: action === "file" ? "Cancel transfer" : "Block the command",
524
+ },
371
525
  ],
372
526
  });
373
527
  }
@@ -377,9 +531,12 @@ export default function (pi: ExtensionAPI): void {
377
531
  const label = stage === "login" ? "SSH login password" : "Remote sudo password";
378
532
  last = await showOverlay(ctx.ui, {
379
533
  mode: "sudo",
380
- title: "🔐 SSH COMMAND REQUEST",
534
+ title:
535
+ action === "file"
536
+ ? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
537
+ : "🔐 SSH COMMAND REQUEST",
381
538
  body: [...body, `Enter: ${label}`],
382
- accent: sudo ? "error" : "accent",
539
+ accent: sudo ? "error" : action === "file" ? "warning" : "accent",
383
540
  timeoutMs: PROMPT_TIMEOUT_MS,
384
541
  maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
385
542
  passwordLabel: `${label}:`,
@@ -410,8 +567,9 @@ export default function (pi: ExtensionAPI): void {
410
567
  return r;
411
568
  }
412
569
 
413
- // Remember this host's approval until the TTL lapses (refreshed each call).
414
- approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
570
+ // File transfers remain warning-level: normal mode asks every time,
571
+ // AFK denies above, and YOLO may approve when no password is missing.
572
+ if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
415
573
 
416
574
  // Persist newly-entered passwords in the session cache.
417
575
  const loginPassword = creds.loginPassword ?? collected.login;
@@ -421,17 +579,47 @@ export default function (pi: ExtensionAPI): void {
421
579
  ...(sudoPassword ? { sudoPassword } : {}),
422
580
  });
423
581
 
424
- updatePresentation(onUpdate, command, host, sudo, reason, "running");
582
+ let spinnerFrame = 0;
583
+ const updateTransferPresentation = () => {
584
+ const verb = direction === "download" ? "Downloading from" : "Uploading to";
585
+ updatePresentation(
586
+ onUpdate,
587
+ command,
588
+ host,
589
+ sudo,
590
+ reason,
591
+ "running",
592
+ `${SPINNER[spinnerFrame] ?? ""} ${verb} ${host}…`,
593
+ );
594
+ };
595
+ if (action === "file") updateTransferPresentation();
596
+ else updatePresentation(onUpdate, command, host, sudo, reason, "running");
597
+ // ponytail: spinner shows liveness only; SCP has no stable byte-progress API.
598
+ // Use an SFTP client with byte callbacks if percentage progress is needed.
599
+ const spinnerTimer =
600
+ action === "file" && onUpdate
601
+ ? setInterval(() => {
602
+ spinnerFrame = (spinnerFrame + 1) % SPINNER.length;
603
+ updateTransferPresentation();
604
+ }, SPINNER_INTERVAL_MS)
605
+ : undefined;
425
606
 
426
607
  let result: { stdout: string; stderr: string; code: number } | undefined;
427
608
  try {
428
- result = await runSsh(spec, command, {
429
- controlPath,
430
- ...(loginPassword ? { loginPassword } : {}),
431
- sudo,
432
- ...(sudo ? { sudoPassword: sudoPassword ?? "" } : {}),
433
- ...(sig ? { signal: sig } : {}),
434
- });
609
+ result =
610
+ action === "file"
611
+ ? await runTransfer(spec, direction ?? "upload", source, destination, recursive, {
612
+ controlPath,
613
+ ...(loginPassword ? { loginPassword } : {}),
614
+ ...(sig ? { signal: sig } : {}),
615
+ })
616
+ : await runSsh(spec, command, {
617
+ controlPath,
618
+ ...(loginPassword ? { loginPassword } : {}),
619
+ sudo,
620
+ ...(sudo ? { sudoPassword: sudoPassword ?? "" } : {}),
621
+ ...(sig ? { signal: sig } : {}),
622
+ });
435
623
  } catch (err) {
436
624
  const msg = err instanceof Error ? err.message : String(err);
437
625
  return {
@@ -447,6 +635,8 @@ export default function (pi: ExtensionAPI): void {
447
635
  ),
448
636
  isError: sig?.aborted !== true,
449
637
  };
638
+ } finally {
639
+ if (spinnerTimer) clearInterval(spinnerTimer);
450
640
  }
451
641
 
452
642
  if (!result) {
@@ -518,11 +708,7 @@ export default function (pi: ExtensionAPI): void {
518
708
  };
519
709
  },
520
710
 
521
- renderCall: ((
522
- args: { command: string; host: string; sudo?: boolean; reason?: string },
523
- theme: ThemeLike,
524
- renderCtx: RenderContextLike,
525
- ) => {
711
+ renderCall: ((args: SshParams, theme: ThemeLike, renderCtx: RenderContextLike) => {
526
712
  resolveBaseBackground(theme);
527
713
  const text = renderCtx.lastComponent ?? new Text("", 0, 0);
528
714
  if (
@@ -532,12 +718,13 @@ export default function (pi: ExtensionAPI): void {
532
718
  )
533
719
  return text;
534
720
 
535
- const command = safeOneLine(args.command) || "(empty command)";
721
+ const operation = normalizeOperation(args);
722
+ const command = safeOneLine(operation.command) || "(empty command)";
536
723
  const host = safeOneLine(args.host);
537
- const prefix = args.sudo ? "sudo " : "";
724
+ const prefix = operation.sudo ? "sudo " : "";
538
725
  text.setText(
539
726
  fillToolBackground(
540
- `${theme.fg("toolTitle", theme.bold("ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
727
+ `${theme.fg("toolTitle", theme.bold(operation.action === "file" ? "ssh file" : "ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
541
728
  ),
542
729
  );
543
730
  return text;
package/src/lib.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ baseScpArgs,
3
4
  baseSshArgs,
4
5
  commandEscalatesPrivilege,
5
6
  controlPathFor,
@@ -12,7 +13,10 @@ import {
12
13
  parseHost,
13
14
  parseSshConfig,
14
15
  remoteCommand,
16
+ remoteTransferPath,
15
17
  shellQuote,
18
+ transferApprovalDecision,
19
+ transferArgs,
16
20
  truncate,
17
21
  } from "./lib.ts";
18
22
 
@@ -94,6 +98,53 @@ describe("baseSshArgs", () => {
94
98
  });
95
99
  });
96
100
 
101
+ describe("transfer unattended approval", () => {
102
+ it("treats transfer as warning-level in AFK and YOLO", () => {
103
+ expect(transferApprovalDecision("off", false)).toBe("ask");
104
+ expect(transferApprovalDecision("afk", false)).toBe("allow");
105
+ expect(transferApprovalDecision("yolo", false)).toBe("allow");
106
+ });
107
+
108
+ it("denies unattended transfer when a login password is missing", () => {
109
+ expect(transferApprovalDecision("afk", true)).toBe("deny");
110
+ expect(transferApprovalDecision("yolo", true)).toBe("deny");
111
+ });
112
+ });
113
+
114
+ describe("SCP transfer arguments", () => {
115
+ it("builds upload and download endpoints", () => {
116
+ const spec = { user: "deploy", host: "example.com", port: 2222 };
117
+ expect(remoteTransferPath(spec, "/srv/app file")).toBe("deploy@example.com:/srv/app file");
118
+ expect(transferArgs(spec, "upload", "./build", "/srv/app")).toEqual([
119
+ "./build",
120
+ "deploy@example.com:/srv/app",
121
+ ]);
122
+ expect(transferArgs(spec, "download", "/var/log/app.log", "./app.log")).toEqual([
123
+ "deploy@example.com:/var/log/app.log",
124
+ "./app.log",
125
+ ]);
126
+ expect(baseScpArgs(spec, "/tmp/control.sock", true)).toEqual([
127
+ "-o",
128
+ "ControlMaster=auto",
129
+ "-o",
130
+ "ControlPath=/tmp/control.sock",
131
+ "-o",
132
+ "ControlPersist=120",
133
+ "-o",
134
+ "ConnectTimeout=10",
135
+ "-o",
136
+ "StrictHostKeyChecking=accept-new",
137
+ "-P",
138
+ "2222",
139
+ "-r",
140
+ ]);
141
+ });
142
+
143
+ it("brackets IPv6 hosts for SCP remote-path syntax", () => {
144
+ expect(remoteTransferPath({ user: "root", host: "::1" }, "/tmp/x")).toBe("root@[::1]:/tmp/x");
145
+ });
146
+ });
147
+
97
148
  describe("remoteCommand + shellQuote", () => {
98
149
  it("returns the command unchanged without sudo", () => {
99
150
  expect(remoteCommand("whoami", false)).toBe("whoami");
package/src/lib.ts CHANGED
@@ -65,6 +65,18 @@ export interface SshResult {
65
65
  code: number;
66
66
  }
67
67
 
68
+ export type TransferDirection = "upload" | "download";
69
+ export type UnattendedMode = "off" | "afk" | "yolo";
70
+
71
+ /** Warning-level transfer policy. Password prompts cannot run unattended. */
72
+ export function transferApprovalDecision(
73
+ mode: UnattendedMode,
74
+ loginPasswordMissing: boolean,
75
+ ): "ask" | "allow" | "deny" {
76
+ if (mode === "off") return "ask";
77
+ return loginPasswordMissing ? "deny" : "allow";
78
+ }
79
+
68
80
  // ── Host parsing ─────────────────────────────────────────────────────────────
69
81
 
70
82
  /** Parse `[user@]host[:port]` into parts. Throws on empty host. */
@@ -158,7 +170,7 @@ export function controlPathFor(spec: HostSpec): string {
158
170
 
159
171
  /** Base ssh options shared by every invocation: multiplexing + timeouts +
160
172
  * non-interactive prompts (BatchMode is toggled by the caller). */
161
- export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
173
+ function connectionArgs(spec: HostSpec, controlPath: string, portFlag: "-p" | "-P"): string[] {
162
174
  const args = [
163
175
  "-o",
164
176
  "ControlMaster=auto",
@@ -171,10 +183,36 @@ export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
171
183
  "-o",
172
184
  "StrictHostKeyChecking=accept-new",
173
185
  ];
174
- if (spec.port !== undefined) args.push("-p", String(spec.port));
186
+ if (spec.port !== undefined) args.push(portFlag, String(spec.port));
175
187
  return args;
176
188
  }
177
189
 
190
+ export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
191
+ return connectionArgs(spec, controlPath, "-p");
192
+ }
193
+
194
+ /** SCP shares SSH connection options but uses uppercase `-P` for its port. */
195
+ export function baseScpArgs(spec: HostSpec, controlPath: string, recursive: boolean): string[] {
196
+ return [...connectionArgs(spec, controlPath, "-P"), ...(recursive ? ["-r"] : [])];
197
+ }
198
+
199
+ /** Format SCP's remote endpoint, bracketing IPv6 literals. */
200
+ export function remoteTransferPath(spec: HostSpec, path: string): string {
201
+ const host = spec.host.includes(":") ? `[${spec.host}]` : spec.host;
202
+ return `${spec.user ? `${spec.user}@` : ""}${host}:${path}`;
203
+ }
204
+
205
+ export function transferArgs(
206
+ spec: HostSpec,
207
+ direction: TransferDirection,
208
+ source: string,
209
+ destination: string,
210
+ ): string[] {
211
+ return direction === "upload"
212
+ ? [source, remoteTransferPath(spec, destination)]
213
+ : [remoteTransferPath(spec, source), destination];
214
+ }
215
+
178
216
  /** Wrap a command for optional remote sudo. `sudo -S -p ''` reads the sudo
179
217
  * password from stdin with no prompt echo; the command runs under `sh -c`. */
180
218
  export function remoteCommand(command: string, sudo: boolean): string {
@@ -238,7 +276,7 @@ export function truncate(
238
276
  const kept = lines.slice(0, maxLines);
239
277
  let result = kept.join("\n");
240
278
  if (Buffer.byteLength(result, "utf8") > maxBytes) {
241
- result = Buffer.from(result, "utf8").slice(0, maxBytes).toString("utf8");
279
+ result = Buffer.from(result, "utf8").subarray(0, maxBytes).toString("utf8");
242
280
  }
243
281
  return { text: result, truncated: true };
244
282
  }
@@ -308,6 +346,25 @@ export interface RunOptions {
308
346
  * in `sshpass -e` (password via env, not argv). When `sudo` is set, the command
309
347
  * is wrapped in `sudo -S` and `sudoPassword` is written to the remote stdin.
310
348
  */
349
+ export function runTransfer(
350
+ spec: HostSpec,
351
+ direction: TransferDirection,
352
+ source: string,
353
+ destination: string,
354
+ recursive: boolean,
355
+ opts: Pick<RunOptions, "controlPath" | "loginPassword" | "signal">,
356
+ ): Promise<SshResult> {
357
+ const scpArgs = [
358
+ ...baseScpArgs(spec, opts.controlPath, recursive),
359
+ "--",
360
+ ...transferArgs(spec, direction, source, destination),
361
+ ];
362
+ const bin = opts.loginPassword ? "sshpass" : "scp";
363
+ const args = opts.loginPassword ? ["-e", "scp", ...scpArgs] : scpArgs;
364
+ const env = opts.loginPassword ? { ...process.env, SSHPASS: opts.loginPassword } : process.env;
365
+ return spawnResult(bin, args, env, opts.signal);
366
+ }
367
+
311
368
  export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promise<SshResult> {
312
369
  const remote = remoteCommand(command, opts.sudo === true);
313
370
  const sshArgs = [...baseSshArgs(spec, opts.controlPath), hostTarget(spec), remote];
@@ -321,30 +378,36 @@ export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promi
321
378
  env = { ...process.env, SSHPASS: opts.loginPassword };
322
379
  }
323
380
 
381
+ // Remote sudo reads its password from stdin (first line); anything else
382
+ // closes stdin so the remote command sees EOF.
383
+ const stdin = opts.sudo && opts.sudoPassword !== undefined ? `${opts.sudoPassword}\n` : undefined;
384
+ return spawnResult(bin, args, env, opts.signal, opts.sudo ? filterSudoPrompt : undefined, stdin);
385
+ }
386
+
387
+ function spawnResult(
388
+ bin: string,
389
+ args: string[],
390
+ env: NodeJS.ProcessEnv,
391
+ sig?: AbortSignal,
392
+ filterStderr?: (value: string) => string,
393
+ stdin?: string,
394
+ ): Promise<SshResult> {
324
395
  return new Promise((resolve, reject) => {
325
396
  const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
326
397
  let stdout = "";
327
398
  let stderr = "";
328
-
329
399
  proc.stdout.on("data", (c: Buffer) => {
330
400
  stdout += c.toString();
331
401
  });
332
402
  proc.stderr.on("data", (c: Buffer) => {
333
- const filtered = opts.sudo ? filterSudoPrompt(c.toString()) : c.toString();
334
- if (filtered) stderr += filtered;
403
+ const value = filterStderr ? filterStderr(c.toString()) : c.toString();
404
+ if (value) stderr += value;
335
405
  });
336
-
337
406
  proc.on("error", reject);
338
407
  proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
339
-
340
- // Remote sudo reads its password from stdin (first line); anything else
341
- // closes stdin so the remote command sees EOF.
342
- if (opts.sudo && opts.sudoPassword !== undefined) {
343
- proc.stdin.write(`${opts.sudoPassword}\n`);
344
- }
408
+ if (stdin) proc.stdin.write(stdin);
345
409
  proc.stdin.end();
346
-
347
- signal(opts.signal, proc, reject);
410
+ signal(sig, proc, reject);
348
411
  });
349
412
  }
350
413