@xynogen/pix-ssh 0.5.2 → 0.5.6

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
@@ -49,16 +49,22 @@ Completed calls collapse after the configured Pix delay into a status row such a
49
49
  pi install npm:@xynogen/pix-ssh
50
50
  ```
51
51
 
52
- > 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.
52
+ > Standalone and opt-in. [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core) does not bundle it. Remote execution over SSH is a networked, privileged capability.
53
53
 
54
54
  ## Full distro
55
55
 
56
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
56
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
57
57
 
58
58
  ```bash
59
+ # Linux / macOS
59
60
  curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
60
61
  ```
61
62
 
63
+ ```powershell
64
+ # Windows
65
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
66
+ ```
67
+
62
68
  ## License
63
69
 
64
- MIT
70
+ MIT. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.5.2",
4
- "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
3
+ "version": "0.5.6",
4
+ "description": "Pi tool \u2014 ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
@@ -37,11 +37,11 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "typebox": "^1.1.38",
41
- "@xynogen/pix-pretty": "^1.25.0",
42
- "@xynogen/pix-runtime": "^0.8.0"
40
+ "@xynogen/pix-pretty": "^1.30.0",
41
+ "@xynogen/pix-runtime": "^0.13.0"
43
42
  },
44
43
  "peerDependencies": {
44
+ "typebox": "*",
45
45
  "@earendil-works/pi-coding-agent": "*",
46
46
  "@earendil-works/pi-tui": "*"
47
47
  }
package/src/index.ts CHANGED
@@ -27,28 +27,34 @@
27
27
 
28
28
  import type { AgentToolUpdateCallback, ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
29
  import { Text } from "@earendil-works/pi-tui";
30
- import { FG_DIM, RST, resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
30
+ import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
31
+ import { collapsedCommandRow, commandPreview } from "@xynogen/pix-pretty/command-preview";
31
32
  import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
32
33
  import { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
33
34
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
34
35
  import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
35
36
  import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
36
37
  import {
38
+ BODY_PAD,
39
+ bodyLine,
37
40
  dotJoin,
38
41
  fillToolBackground,
42
+ formatCollapsedToolRow,
43
+ formatToolCallTitle,
39
44
  frameToolResult,
45
+ getErrorMessage,
40
46
  getTextContent,
41
47
  hideCollapsedToolCall,
48
+ moreLines,
42
49
  normalizeLineEndings,
43
- renderCollapsedToolRow,
44
50
  renderToolError,
45
51
  ruleFrame,
46
- sectionRule,
47
52
  termW,
48
53
  unframeToolResult,
49
54
  } from "@xynogen/pix-pretty/utils";
50
55
  import { SPINNER } from "@xynogen/pix-pretty/widget-format";
51
56
  import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
57
+ import { requireTool } from "@xynogen/pix-runtime/binaries";
52
58
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
53
59
  import { Type } from "typebox";
54
60
  import {
@@ -351,6 +357,22 @@ function formatAliasList(aliases: HostAlias[]): string {
351
357
  * given, otherwise the alias inventory from ~/.ssh/config. Read-only.
352
358
  * Details carry `_type: "sshInfo"` so renderResult falls to its generic
353
359
  * plain-text branch (no collapse, no exit-code framing). */
360
+ /**
361
+ * The pix-runtime lookup message for the first missing binary this action
362
+ * needs (ssh, plus scp for file transfers), or undefined when all resolve.
363
+ * sshpass is checked lazily: it only matters when a login password is used.
364
+ */
365
+ export function missingSshTools(action: "command" | "file"): string | undefined {
366
+ for (const name of action === "file" ? ["ssh", "scp"] : ["ssh"]) {
367
+ try {
368
+ requireTool(name);
369
+ } catch (err) {
370
+ return getErrorMessage(err);
371
+ }
372
+ }
373
+ return undefined;
374
+ }
375
+
354
376
  async function infoResult(host: string | undefined, sig?: AbortSignal) {
355
377
  const details = { _type: "sshInfo" as const };
356
378
  if (host?.trim()) {
@@ -358,7 +380,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
358
380
  try {
359
381
  spec = parseHost(host);
360
382
  } catch (err) {
361
- const msg = err instanceof Error ? err.message : String(err);
383
+ const msg = getErrorMessage(err);
362
384
  return {
363
385
  content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
364
386
  details,
@@ -379,6 +401,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
379
401
  export default function (pi: ExtensionAPI): void {
380
402
  pi.registerTool({
381
403
  name: "ssh_run",
404
+ ...({ exposure: "deferred" } as const),
382
405
  label: "Run over SSH",
383
406
  description:
384
407
  "Run a command or transfer files/directories on a REMOTE host over SSH. " +
@@ -496,11 +519,25 @@ export default function (pi: ExtensionAPI): void {
496
519
  };
497
520
  }
498
521
 
522
+ // Fail up front with pix's install hint: the probes below treat a spawn
523
+ // error as "unreachable", which would hide a missing ssh/scp.
524
+ const missingTool = missingSshTools(action);
525
+ if (missingTool) {
526
+ return {
527
+ content: [{ type: "text", text: `ssh_run failed: ${missingTool}` }],
528
+ details: makeDetails(command, params.host, sudo, reason, {
529
+ outcome: "error",
530
+ errorKind: "execution",
531
+ }),
532
+ isError: true,
533
+ };
534
+ }
535
+
499
536
  let spec: HostSpec;
500
537
  try {
501
538
  spec = parseHost(params.host);
502
539
  } catch (err) {
503
- const msg = err instanceof Error ? err.message : String(err);
540
+ const msg = getErrorMessage(err);
504
541
  return {
505
542
  content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
506
543
  details: makeDetails(command, params.host, sudo, reason, {
@@ -750,7 +787,7 @@ export default function (pi: ExtensionAPI): void {
750
787
  ...(sig ? { signal: sig } : {}),
751
788
  });
752
789
  } catch (err) {
753
- const msg = err instanceof Error ? err.message : String(err);
790
+ const msg = getErrorMessage(err);
754
791
  return {
755
792
  content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
756
793
  details: makeDetails(
@@ -851,20 +888,23 @@ export default function (pi: ExtensionAPI): void {
851
888
  if (args.action === "info") {
852
889
  text.setText(
853
890
  fillToolBackground(
854
- `${theme.fg("toolTitle", theme.bold("ssh info"))} ${theme.fg("dim", host || "list aliases")}`,
891
+ `${formatToolCallTitle(theme, "ssh info", renderCtx)} ${theme.fg("dim", host || "list aliases")}`,
855
892
  ),
856
893
  );
857
894
  return text;
858
895
  }
859
896
  const operation = normalizeOperation(args);
860
- const command = safeOneLine(operation.command) || "(empty command)";
897
+ const command = operation.command || "(empty command)";
861
898
  const prefix = operation.sudo ? "sudo " : "";
862
- text.setText(
863
- fillToolBackground(
864
- `${theme.fg("toolTitle", theme.bold(operation.action === "file" ? "ssh file" : "ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
865
- ),
899
+ return commandPreview(
900
+ `${formatToolCallTitle(theme, operation.action === "file" ? "ssh file" : "ssh", renderCtx)} ${theme.fg("dim", host)}`,
901
+ prefix + command,
902
+ "bash",
903
+ theme,
904
+ renderCtx.state,
905
+ renderCtx.invalidate,
906
+ renderCtx.expanded,
866
907
  );
867
- return text;
868
908
  }) as never,
869
909
 
870
910
  renderResult: ((
@@ -884,7 +924,7 @@ export default function (pi: ExtensionAPI): void {
884
924
  text.setText(renderToolError(getTextContent(result) || "Error", theme));
885
925
  } else {
886
926
  text.setText(
887
- fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "done")}`),
927
+ fillToolBackground(`${BODY_PAD}${theme.fg("muted", getTextContent(result) || "done")}`),
888
928
  );
889
929
  }
890
930
  return isPartial ? text : completed(renderCtx.isError);
@@ -901,21 +941,24 @@ export default function (pi: ExtensionAPI): void {
901
941
  )
902
942
  ) {
903
943
  const status = details.outcome === "success" ? "success" : "error";
904
- text.setText(
905
- renderCollapsedToolRow(
944
+ return collapsedCommandRow(
945
+ formatCollapsedToolRow(
906
946
  theme,
907
947
  "ssh",
908
- `${details.host} ${safeOneLine(details.command)}`,
948
+ details.host,
909
949
  terminalMeta(details),
910
950
  status,
911
- ),
951
+ ).trimEnd(),
952
+ safeOneLine(details.command),
953
+ theme,
912
954
  );
913
- return text;
914
955
  }
915
956
 
916
957
  if (details.outcome === "awaiting-approval" || details.outcome === "running") {
917
958
  text.setText(
918
- fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "working")}`),
959
+ fillToolBackground(
960
+ `${BODY_PAD}${theme.fg("muted", getTextContent(result) || "working")}`,
961
+ ),
919
962
  );
920
963
  return text;
921
964
  }
@@ -925,7 +968,7 @@ export default function (pi: ExtensionAPI): void {
925
968
  text.setText(
926
969
  details.outcome === "error"
927
970
  ? renderToolError(diagnostic, theme)
928
- : fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
971
+ : fillToolBackground(`${BODY_PAD}${theme.fg("warning", diagnostic)}`),
929
972
  );
930
973
  return isPartial ? text : completed(true);
931
974
  }
@@ -937,18 +980,16 @@ export default function (pi: ExtensionAPI): void {
937
980
  const lineCount = lines.length;
938
981
 
939
982
  if (!rendered) {
940
- text.setText(fillToolBackground(` ${summary}`));
983
+ text.setText(fillToolBackground(`${BODY_PAD}${summary}`));
941
984
  return isPartial ? text : completed(details.outcome !== "success");
942
985
  }
943
986
 
944
987
  const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
945
988
  const show = lines.slice(0, maxShow);
946
- const footer =
947
- lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
989
+ const footer = lineCount > maxShow ? [moreLines(lineCount - maxShow, theme)] : [];
948
990
  const statusKey = details.outcome === "success" ? "success" : "error";
949
991
  const paint = (s: string) => theme.fg(statusKey, s);
950
- const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
951
- const body = show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`);
992
+ const body = show.map((line) => bodyLine(line, theme));
952
993
  const out = isPartial ? [...body, ...footer] : ruleFrame(body, footer, termW(), paint);
953
994
  text.setText(fillToolBackground(out.join("\n")));
954
995
  return text;
package/src/lib.ts CHANGED
@@ -13,14 +13,21 @@
13
13
  * entered once per session per host.
14
14
  */
15
15
 
16
- import { spawn } from "node:child_process";
16
+ import type { spawn } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
18
  import { globSync, readFileSync } from "node:fs";
19
- import { homedir, tmpdir } from "node:os";
20
19
  import { isAbsolute, join, resolve as resolvePath } from "node:path";
21
-
22
- export const MAX_OUTPUT_BYTES = 50 * 1024;
23
- export const MAX_OUTPUT_LINES = 2000;
20
+ import {
21
+ DEFAULT_MAX_BYTES,
22
+ DEFAULT_MAX_LINES,
23
+ truncateHead,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import { spawnTool } from "@xynogen/pix-runtime/exec";
26
+ import { expandHome, homeDir, tempDir } from "@xynogen/pix-runtime/paths";
27
+
28
+ // Pi's own tool-output limits (50KB / 2000 lines), so every tool caps the same way.
29
+ export const MAX_OUTPUT_BYTES = DEFAULT_MAX_BYTES;
30
+ export const MAX_OUTPUT_LINES = DEFAULT_MAX_LINES;
24
31
 
25
32
  /** Per-host approval TTL (ms) — mirrors sudo's PAM ticket window (~15 min). */
26
33
  export const APPROVAL_TTL_MS = 15 * 60_000;
@@ -144,7 +151,7 @@ export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<Ho
144
151
 
145
152
  return new Promise((resolve) => {
146
153
  let stdout = "";
147
- const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
154
+ const proc = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
148
155
  proc.stdout.on("data", (chunk: Buffer) => {
149
156
  stdout += chunk.toString();
150
157
  });
@@ -216,9 +223,7 @@ export function parseHostAliases(text: string): HostAlias[] {
216
223
  /** Expand a Path with a leading `~` and resolve relative Includes against
217
224
  * the containing config's directory (OpenSSH semantics). */
218
225
  function expandConfigPath(pattern: string, baseDir: string): string {
219
- let p = pattern;
220
- if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
221
- else if (p === "~") p = homedir();
226
+ const p = expandHome(pattern);
222
227
  return isAbsolute(p) ? p : resolvePath(baseDir, p);
223
228
  }
224
229
 
@@ -228,7 +233,7 @@ function expandConfigPath(pattern: string, baseDir: string): string {
228
233
  * (OpenSSH tolerates them). `seen` guards against Include cycles.
229
234
  */
230
235
  export function readSshConfigAliases(
231
- path = join(homedir(), ".ssh", "config"),
236
+ path = join(homeDir(), ".ssh", "config"),
232
237
  seen = new Set<string>(),
233
238
  ): HostAlias[] {
234
239
  if (seen.has(path)) return [];
@@ -239,7 +244,7 @@ export function readSshConfigAliases(
239
244
  } catch {
240
245
  return [];
241
246
  }
242
- const baseDir = join(homedir(), ".ssh");
247
+ const baseDir = join(homeDir(), ".ssh");
243
248
  const out: HostAlias[] = [];
244
249
  for (const raw of text.split("\n")) {
245
250
  const line = raw.replace(/#.*$/, "").trim();
@@ -280,7 +285,7 @@ export function resolveHostInfo(spec: HostSpec, signal?: AbortSignal): Promise<H
280
285
  args.push(hostTarget(spec));
281
286
  return new Promise((resolve) => {
282
287
  let stdout = "";
283
- const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
288
+ const proc = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
284
289
  proc.stdout.on("data", (c: Buffer) => {
285
290
  stdout += c.toString();
286
291
  });
@@ -300,7 +305,7 @@ export function hostTarget(spec: HostSpec): string {
300
305
  export function controlPathFor(spec: HostSpec): string {
301
306
  const key = `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
302
307
  const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
303
- return join(tmpdir(), `pix-ssh-${hash}.sock`);
308
+ return join(tempDir(), `pix-ssh-${hash}.sock`);
304
309
  }
305
310
 
306
311
  // ── ssh argv construction ────────────────────────────────────────────────────
@@ -405,17 +410,12 @@ export function truncate(
405
410
  maxLines = MAX_OUTPUT_LINES,
406
411
  maxBytes = MAX_OUTPUT_BYTES,
407
412
  ): { text: string; truncated: boolean } {
408
- const lines = text.split("\n");
409
- const byteLen = Buffer.byteLength(text, "utf8");
410
- if (lines.length <= maxLines && byteLen <= maxBytes) {
411
- return { text, truncated: false };
412
- }
413
- const kept = lines.slice(0, maxLines);
414
- let result = kept.join("\n");
415
- if (Buffer.byteLength(result, "utf8") > maxBytes) {
416
- result = Buffer.from(result, "utf8").subarray(0, maxBytes).toString("utf8");
417
- }
418
- return { text: result, truncated: true };
413
+ const r = truncateHead(text, { maxLines, maxBytes });
414
+ if (!r.firstLineExceedsLimit) return { text: r.content, truncated: r.truncated };
415
+ // One line over the byte cap (minified JSON, a base64 blob): truncateHead keeps
416
+ // nothing. Keep its first maxBytes instead, cut on a UTF-8 boundary.
417
+ const head = Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8");
418
+ return { text: head.replace(/\uFFFD$/, ""), truncated: true };
419
419
  }
420
420
 
421
421
  // ── Runners ──────────────────────────────────────────────────────────────────
@@ -440,7 +440,7 @@ export function probeKeyAuth(
440
440
  const args = [...baseSshArgs(spec, controlPath), "-o", "BatchMode=yes", hostTarget(spec), "true"];
441
441
  return new Promise((resolve) => {
442
442
  let stderr = "";
443
- const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
443
+ const proc = spawnTool("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
444
444
  proc.stderr.on("data", (c: Buffer) => {
445
445
  stderr += c.toString();
446
446
  });
@@ -488,7 +488,7 @@ export function probePasswordAuth(
488
488
  ];
489
489
  return new Promise((resolve) => {
490
490
  let stderr = "";
491
- const proc = spawn("sshpass", args, {
491
+ const proc = spawnTool("sshpass", args, {
492
492
  stdio: ["ignore", "ignore", "pipe"],
493
493
  env: { ...process.env, SSHPASS: password },
494
494
  });
@@ -525,7 +525,7 @@ export function probeSudoNoPassword(
525
525
  "sudo -n true",
526
526
  ];
527
527
  return new Promise((resolve) => {
528
- const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
528
+ const proc = spawnTool("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
529
529
  proc.on("error", () => resolve(false));
530
530
  proc.on("close", (code) => resolve(code === 0));
531
531
  signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
@@ -621,7 +621,7 @@ function spawnResult(
621
621
  stdin?: string,
622
622
  ): Promise<SshResult> {
623
623
  return new Promise((resolve, reject) => {
624
- const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
624
+ const proc = spawnTool(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
625
625
  let stdout = "";
626
626
  let stderr = "";
627
627
  proc.stdout.on("data", (c: Buffer) => {