@xynogen/pix-ssh 0.5.2 → 0.5.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.5.2",
3
+ "version": "0.5.5",
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",
@@ -37,11 +37,11 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "typebox": "^1.1.38",
41
40
  "@xynogen/pix-pretty": "^1.25.0",
42
- "@xynogen/pix-runtime": "^0.8.0"
41
+ "@xynogen/pix-runtime": "^0.12.2"
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
@@ -37,6 +37,7 @@ import {
37
37
  dotJoin,
38
38
  fillToolBackground,
39
39
  frameToolResult,
40
+ getErrorMessage,
40
41
  getTextContent,
41
42
  hideCollapsedToolCall,
42
43
  normalizeLineEndings,
@@ -49,6 +50,7 @@ import {
49
50
  } from "@xynogen/pix-pretty/utils";
50
51
  import { SPINNER } from "@xynogen/pix-pretty/widget-format";
51
52
  import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
53
+ import { requireTool } from "@xynogen/pix-runtime/binaries";
52
54
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
53
55
  import { Type } from "typebox";
54
56
  import {
@@ -351,6 +353,22 @@ function formatAliasList(aliases: HostAlias[]): string {
351
353
  * given, otherwise the alias inventory from ~/.ssh/config. Read-only.
352
354
  * Details carry `_type: "sshInfo"` so renderResult falls to its generic
353
355
  * plain-text branch (no collapse, no exit-code framing). */
356
+ /**
357
+ * The pix-runtime lookup message for the first missing binary this action
358
+ * needs (ssh, plus scp for file transfers), or undefined when all resolve.
359
+ * sshpass is checked lazily: it only matters when a login password is used.
360
+ */
361
+ export function missingSshTools(action: "command" | "file"): string | undefined {
362
+ for (const name of action === "file" ? ["ssh", "scp"] : ["ssh"]) {
363
+ try {
364
+ requireTool(name);
365
+ } catch (err) {
366
+ return getErrorMessage(err);
367
+ }
368
+ }
369
+ return undefined;
370
+ }
371
+
354
372
  async function infoResult(host: string | undefined, sig?: AbortSignal) {
355
373
  const details = { _type: "sshInfo" as const };
356
374
  if (host?.trim()) {
@@ -358,7 +376,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
358
376
  try {
359
377
  spec = parseHost(host);
360
378
  } catch (err) {
361
- const msg = err instanceof Error ? err.message : String(err);
379
+ const msg = getErrorMessage(err);
362
380
  return {
363
381
  content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
364
382
  details,
@@ -379,6 +397,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
379
397
  export default function (pi: ExtensionAPI): void {
380
398
  pi.registerTool({
381
399
  name: "ssh_run",
400
+ ...({ exposure: "deferred" } as const),
382
401
  label: "Run over SSH",
383
402
  description:
384
403
  "Run a command or transfer files/directories on a REMOTE host over SSH. " +
@@ -496,11 +515,25 @@ export default function (pi: ExtensionAPI): void {
496
515
  };
497
516
  }
498
517
 
518
+ // Fail up front with pix's install hint: the probes below treat a spawn
519
+ // error as "unreachable", which would hide a missing ssh/scp.
520
+ const missingTool = missingSshTools(action);
521
+ if (missingTool) {
522
+ return {
523
+ content: [{ type: "text", text: `ssh_run failed: ${missingTool}` }],
524
+ details: makeDetails(command, params.host, sudo, reason, {
525
+ outcome: "error",
526
+ errorKind: "execution",
527
+ }),
528
+ isError: true,
529
+ };
530
+ }
531
+
499
532
  let spec: HostSpec;
500
533
  try {
501
534
  spec = parseHost(params.host);
502
535
  } catch (err) {
503
- const msg = err instanceof Error ? err.message : String(err);
536
+ const msg = getErrorMessage(err);
504
537
  return {
505
538
  content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
506
539
  details: makeDetails(command, params.host, sudo, reason, {
@@ -750,7 +783,7 @@ export default function (pi: ExtensionAPI): void {
750
783
  ...(sig ? { signal: sig } : {}),
751
784
  });
752
785
  } catch (err) {
753
- const msg = err instanceof Error ? err.message : String(err);
786
+ const msg = getErrorMessage(err);
754
787
  return {
755
788
  content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
756
789
  details: makeDetails(
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) => {