mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-25 22:38:36 +00:00
cli: add an ssh-wrapping +ssh action (#12582)
Add a drop-in `ssh` wrapper that sets up the remote environment for Ghostty. Anything not consumed as one of our own flags is forwarded to the real, wrapped `ssh` binary. It can be used directly (`ghostty +ssh user@host`), aliased (`alias ssh='ghostty +ssh --'`), or invoked through Ghostty's shell integration. Before exec'ing ssh, `+ssh`: - Forwards Ghostty environment to the remote (`--forward-env`): sets TERM=xterm-256color and requests SendEnv forwarding of COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. - Installs Ghostty's terminfo on the remote (`--terminfo`), informed by our existing `ssh-cache` system and using our internal xterm-ghostty terminfo representation. A third flag, `--cache`, controls cache use; `--cache=false` bypasses both read and write, which is useful for scripting and for debugging install failures without polluting the cache. For shell integration, this replaces the per-shell logic (which made up roughly a third of our shell integration scripts) with a simple wrapper function that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line.
This commit is contained in:
@@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig");
|
||||
const list_themes = @import("list_themes.zig");
|
||||
const list_colors = @import("list_colors.zig");
|
||||
const list_actions = @import("list_actions.zig");
|
||||
const ssh = @import("ssh.zig");
|
||||
const ssh_cache = @import("ssh_cache.zig");
|
||||
const edit_config = @import("edit_config.zig");
|
||||
const show_config = @import("show_config.zig");
|
||||
@@ -47,6 +48,9 @@ pub const Action = enum {
|
||||
/// List keybind actions
|
||||
@"list-actions",
|
||||
|
||||
/// Wrap `ssh` to configure Ghostty terminal integration on remote hosts
|
||||
ssh,
|
||||
|
||||
/// Manage SSH terminfo cache for automatic remote host setup
|
||||
@"ssh-cache",
|
||||
|
||||
@@ -148,6 +152,7 @@ pub const Action = enum {
|
||||
.@"list-colors" => try list_colors.run(alloc),
|
||||
.@"list-actions" => try list_actions.run(alloc),
|
||||
.@"ssh-cache" => try ssh_cache.run(alloc),
|
||||
.ssh => try ssh.run(alloc),
|
||||
.@"edit-config" => try edit_config.run(alloc),
|
||||
.@"show-config" => try show_config.run(alloc),
|
||||
.@"explain-config" => try explain_config.run(alloc),
|
||||
@@ -189,6 +194,7 @@ pub const Action = enum {
|
||||
.@"list-colors" => list_colors.Options,
|
||||
.@"list-actions" => list_actions.Options,
|
||||
.@"ssh-cache" => ssh_cache.Options,
|
||||
.ssh => ssh.Options,
|
||||
.@"edit-config" => edit_config.Options,
|
||||
.@"show-config" => show_config.Options,
|
||||
.@"explain-config" => explain_config.Options,
|
||||
|
||||
635
src/cli/ssh.zig
Normal file
635
src/cli/ssh.zig
Normal file
@@ -0,0 +1,635 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const cli_args = @import("args.zig");
|
||||
const diagnostics = @import("diagnostics.zig");
|
||||
const Action = @import("ghostty.zig").Action;
|
||||
const DiskCache = @import("ssh_cache.zig").DiskCache;
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
|
||||
|
||||
const log = std.log.scoped(.ssh);
|
||||
|
||||
const usage =
|
||||
\\Usage: ghostty +ssh [flags] [--] <ssh args...>
|
||||
\\
|
||||
\\Flags:
|
||||
\\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true.
|
||||
\\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true.
|
||||
\\ --cache[=bool] Use the terminfo install cache. Default: true.
|
||||
\\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH.
|
||||
\\ --verbose Print +ssh status lines to stderr.
|
||||
\\ --help Show full help.
|
||||
\\
|
||||
\\ssh flags and the destination go after +ssh's own flags (or after `--`).
|
||||
\\
|
||||
;
|
||||
|
||||
pub const Options = struct {
|
||||
/// Set by the CLI parser for deinit.
|
||||
_arena: ?ArenaAllocator = null,
|
||||
|
||||
/// Maps to the `ssh-env` shell integration feature.
|
||||
@"forward-env": bool = true,
|
||||
|
||||
/// Maps to the `ssh-terminfo` shell integration feature.
|
||||
terminfo: bool = true,
|
||||
|
||||
/// When false, both cache read and write are bypassed.
|
||||
cache: bool = true,
|
||||
|
||||
/// The wrapped `ssh` binary.
|
||||
/// `/`-containing values are treated as paths; otherwise resolved via PATH.
|
||||
ssh: []const u8 = "ssh",
|
||||
|
||||
/// When true, print verbose output to stderr.
|
||||
verbose: bool = false,
|
||||
|
||||
/// Arguments passed through to `ssh` verbatim. Populated by
|
||||
/// `parseManuallyHook` when we reach the first non-flag argument (or
|
||||
/// an explicit `--`).
|
||||
_ssh_args: std.ArrayList([]const u8) = .empty,
|
||||
|
||||
/// Enables arg parsing diagnostics so unknown flags become
|
||||
/// diagnostics rather than fatal errors.
|
||||
_diagnostics: diagnostics.DiagnosticList = .{},
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
if (self._arena) |arena| arena.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Enables `-h` and `--help` to work.
|
||||
pub fn help(_: Options) !void {
|
||||
return Action.help_error;
|
||||
}
|
||||
|
||||
/// Manual parse hook. For each argument:
|
||||
/// - If it's a literal `--`, consume everything after it as ssh
|
||||
/// args and stop parsing.
|
||||
/// - If it doesn't start with `--`, this is the start of the ssh
|
||||
/// argv. Consume this arg and everything after as ssh args and
|
||||
/// stop parsing.
|
||||
/// - Otherwise (a `--foo` arg), return true so the generic parser
|
||||
/// handles it as one of our own flags.
|
||||
pub fn parseManuallyHook(
|
||||
self: *Options,
|
||||
alloc: Allocator,
|
||||
arg: []const u8,
|
||||
iter: anytype,
|
||||
) Allocator.Error!bool {
|
||||
if (std.mem.eql(u8, arg, "--")) {
|
||||
while (iter.next()) |rest| {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!std.mem.startsWith(u8, arg, "--")) {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, arg));
|
||||
while (iter.next()) |rest| {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Wrap `ssh` to automatically configure Ghostty terminal integration on
|
||||
/// remote hosts.
|
||||
///
|
||||
/// Any arguments that aren't recognized as `+ssh` flags are passed to
|
||||
/// the real `ssh` binary unchanged. You can use `--` as an explicit
|
||||
/// disambiguator if needed, though it's almost never required: `ssh`
|
||||
/// has no long flags, and `+ssh` defines no short flags, so there's
|
||||
/// nothing to collide.
|
||||
///
|
||||
/// This is typically called via Ghostty's shell integration. When
|
||||
/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,
|
||||
/// each shell defines an `ssh` function that runs:
|
||||
///
|
||||
/// ghostty +ssh <flags> -- "$@"
|
||||
///
|
||||
/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g.
|
||||
/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell
|
||||
/// integration.
|
||||
///
|
||||
/// `+ssh` performs up to two pieces of setup before launching `ssh`:
|
||||
///
|
||||
/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to
|
||||
/// `xterm-256color` and requests `SendEnv` forwarding of
|
||||
/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the
|
||||
/// remote shell can still detect that it's running inside Ghostty.
|
||||
/// The remote `sshd_config` must list these in `AcceptEnv` for
|
||||
/// forwarding to succeed.
|
||||
///
|
||||
/// 2. **Terminfo install** (`--terminfo`). On the first connection to a
|
||||
/// given destination, installs Ghostty's terminfo entry on the remote
|
||||
/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a
|
||||
/// shared `ControlMaster` connection. Successful installs are cached
|
||||
/// (see `ghostty +ssh-cache`) so subsequent connections skip this
|
||||
/// step. When terminfo is successfully installed or already cached,
|
||||
/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.
|
||||
///
|
||||
/// If `--terminfo` install fails (e.g. `tic` not available on the
|
||||
/// remote, filesystem permissions), a warning is logged and the
|
||||
/// connection continues with `TERM=xterm-256color`.
|
||||
///
|
||||
/// Flags:
|
||||
///
|
||||
/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment
|
||||
/// forwarding. Default: `true`.
|
||||
///
|
||||
/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
|
||||
/// connection. Default: `true`.
|
||||
///
|
||||
/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`.
|
||||
/// When `false`, both the cache read (skip-if-installed) and the
|
||||
/// cache write (record-on-success) are bypassed, and every
|
||||
/// connection performs the install. To one-shot reinstall a single
|
||||
/// host while keeping the cache in use, prefer `ghostty +ssh-cache
|
||||
/// --remove=<host>` followed by a normal connection.
|
||||
///
|
||||
/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the
|
||||
/// first `ssh` found on `PATH`.
|
||||
///
|
||||
/// * `--verbose`: Print +ssh status lines to stderr, and surface
|
||||
/// remote stderr during the terminfo install.
|
||||
///
|
||||
/// Examples:
|
||||
///
|
||||
/// # Basic invocation using defaults:
|
||||
/// ghostty +ssh user@example.com
|
||||
///
|
||||
/// # Forward Ghostty env vars but skip the terminfo install:
|
||||
/// ghostty +ssh --terminfo=false user@example.com
|
||||
///
|
||||
/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:
|
||||
/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com
|
||||
///
|
||||
/// # Use `--` explicitly if your ssh args might collide with our flags:
|
||||
/// ghostty +ssh -- --some-rare-ssh-arg user@example.com
|
||||
///
|
||||
/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection
|
||||
/// and management, see `ghostty +ssh-cache`.
|
||||
///
|
||||
/// Available since: 1.4.0
|
||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
|
||||
{
|
||||
var iter = try cli_args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
try cli_args.parse(Options, alloc_gpa, &opts, &iter);
|
||||
}
|
||||
|
||||
var stderr_buffer: [1024]u8 = undefined;
|
||||
var stderr_file: std.fs.File = .stderr();
|
||||
var stderr_writer = stderr_file.writer(&stderr_buffer);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
// Any diagnostic from the arg parser is an unknown flag or bad
|
||||
// value. Reject loudly — silently forwarding `--typo` to ssh would
|
||||
// produce confusing downstream errors.
|
||||
if (!opts._diagnostics.empty()) {
|
||||
for (opts._diagnostics.items()) |diag| {
|
||||
if (diag.key.len > 0) {
|
||||
stderr.print(
|
||||
"Error: unknown flag `--{s}`.\n",
|
||||
.{diag.key},
|
||||
) catch {};
|
||||
} else {
|
||||
stderr.print("Error: {s}\n", .{diag.message}) catch {};
|
||||
}
|
||||
}
|
||||
stderr.print("\n{s}", .{usage}) catch {};
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
}
|
||||
|
||||
const result = runInner(alloc_gpa, &opts, stderr);
|
||||
|
||||
stderr.flush() catch {};
|
||||
return result;
|
||||
}
|
||||
|
||||
fn runInner(
|
||||
gpa: Allocator,
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
) !u8 {
|
||||
var arena = ArenaAllocator.init(gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
if (opts._ssh_args.items.len == 0) {
|
||||
try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage});
|
||||
return 2;
|
||||
}
|
||||
|
||||
const session: struct {
|
||||
term: []const u8,
|
||||
to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null,
|
||||
} = session: {
|
||||
if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
|
||||
|
||||
const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse {
|
||||
warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
|
||||
const cache: ?DiskCache = if (opts.cache) cache: {
|
||||
const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
|
||||
warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
break :cache .{ .path = path };
|
||||
} else null;
|
||||
|
||||
if (cache) |c| {
|
||||
if (c.contains(alloc, dest) catch false) {
|
||||
verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest});
|
||||
break :session .{ .term = "xterm-ghostty" };
|
||||
} else {
|
||||
verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest});
|
||||
}
|
||||
} else {
|
||||
verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest});
|
||||
}
|
||||
|
||||
stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {};
|
||||
stderr.flush() catch {};
|
||||
|
||||
installRemoteTerminfo(alloc, opts, stderr) catch |err| {
|
||||
warnPrint(stderr, "failed to install terminfo: {}", .{err});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
break :session .{
|
||||
.term = "xterm-ghostty",
|
||||
.to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null,
|
||||
};
|
||||
};
|
||||
|
||||
// Build the full argv: [ssh, ...our opts, ...user args]
|
||||
const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: {
|
||||
const set_term = try std.fmt.allocPrint(
|
||||
alloc,
|
||||
"SetEnv=TERM={s}",
|
||||
.{session.term},
|
||||
);
|
||||
break :env_opts &.{
|
||||
"-o", set_term,
|
||||
"-o", "SendEnv=COLORTERM",
|
||||
"-o", "SendEnv=TERM_PROGRAM",
|
||||
"-o", "SendEnv=TERM_PROGRAM_VERSION",
|
||||
};
|
||||
} else &.{};
|
||||
const argv = try std.mem.concat(alloc, []const u8, &.{
|
||||
&.{opts.ssh},
|
||||
env_opts,
|
||||
opts._ssh_args.items,
|
||||
});
|
||||
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
|
||||
|
||||
const exit_code = childExec(alloc, argv) catch |err| {
|
||||
try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err });
|
||||
return 1;
|
||||
};
|
||||
verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
|
||||
|
||||
// Attempt to cache (if needed) on a successful ssh execution.
|
||||
if (exit_code == 0) if (session.to_cache) |entry| {
|
||||
if (entry.cache.add(alloc, entry.dest)) |_| {
|
||||
verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
|
||||
} else |err| {
|
||||
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
|
||||
}
|
||||
};
|
||||
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
/// Log to `.ssh` and, if `--verbose`, also print to stderr.
|
||||
fn verbosePrint(
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
comptime fmt: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
log.debug(fmt, args);
|
||||
if (!opts.verbose) return;
|
||||
stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return;
|
||||
stderr.flush() catch return;
|
||||
}
|
||||
|
||||
/// Log a warning and also print a `Warning: <msg>` line to stderr.
|
||||
fn warnPrint(
|
||||
stderr: *std.Io.Writer,
|
||||
comptime fmt: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
log.warn(fmt, args);
|
||||
stderr.print("Warning: " ++ fmt ++ "\n", args) catch return;
|
||||
stderr.flush() catch return;
|
||||
}
|
||||
|
||||
/// Space-joined items, formattable as `{f}`.
|
||||
const Joined = struct {
|
||||
items: []const []const u8,
|
||||
|
||||
pub fn format(self: Joined, writer: *std.Io.Writer) !void {
|
||||
for (self.items, 0..) |a, i| {
|
||||
if (i > 0) try writer.writeByte(' ');
|
||||
try writer.writeAll(a);
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
const testing = std.testing;
|
||||
var buf: [128]u8 = undefined;
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{} }});
|
||||
try testing.expectEqualStrings("", buf[0..w.end]);
|
||||
}
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{"only"} }});
|
||||
try testing.expectEqualStrings("only", buf[0..w.end]);
|
||||
}
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }});
|
||||
try testing.expectEqualStrings("a b c", buf[0..w.end]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void {
|
||||
switch (term) {
|
||||
.Exited => |rc| if (rc != 0) {
|
||||
log.warn("{s} exited with non-zero status: {d}", .{ label, rc });
|
||||
return error.ChildFailed;
|
||||
},
|
||||
else => {
|
||||
log.warn("{s} terminated abnormally: {}", .{ label, term });
|
||||
return error.ChildFailed;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `ssh -G <args>` and parse the output for `user` and `hostname`.
|
||||
/// Returns the resolved `user@hostname`, or null if the destination
|
||||
/// could not be resolved.
|
||||
fn resolveDestination(
|
||||
alloc: Allocator,
|
||||
ssh: []const u8,
|
||||
args: []const []const u8,
|
||||
) ?[]const u8 {
|
||||
const argv = std.mem.concat(alloc, []const u8, &.{
|
||||
&.{ ssh, "-G" },
|
||||
args,
|
||||
}) catch return null;
|
||||
const result = std.process.Child.run(.{
|
||||
.allocator = alloc,
|
||||
.argv = argv,
|
||||
}) catch |err| {
|
||||
log.warn("ssh -G spawn failed: {}", .{err});
|
||||
return null;
|
||||
};
|
||||
checkExit(result.term, "ssh -G") catch return null;
|
||||
return parseDestination(alloc, result.stdout);
|
||||
}
|
||||
|
||||
/// Parse `ssh -G` output for `user` and `hostname` and return the
|
||||
/// formatted `user@hostname`. Returns null if either key is missing
|
||||
/// or formatting fails.
|
||||
fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 {
|
||||
var user: []const u8 = "";
|
||||
var host: []const u8 = "";
|
||||
var it = std.mem.tokenizeScalar(u8, stdout, '\n');
|
||||
while (it.next()) |line| {
|
||||
const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
|
||||
const key = line[0..space];
|
||||
const value = line[space + 1 ..];
|
||||
if (std.mem.eql(u8, key, "user")) {
|
||||
user = value;
|
||||
} else if (std.mem.eql(u8, key, "hostname")) {
|
||||
host = value;
|
||||
}
|
||||
if (user.len > 0 and host.len > 0) break;
|
||||
}
|
||||
|
||||
if (user.len == 0) {
|
||||
log.warn("ssh -G output missing user", .{});
|
||||
return null;
|
||||
}
|
||||
if (host.len == 0) {
|
||||
log.warn("ssh -G output missing hostname", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;
|
||||
}
|
||||
|
||||
/// Install Ghostty's terminfo on the remote host over a short-lived SSH
|
||||
/// ControlMaster connection. The master tears down with the client
|
||||
/// (`ControlPersist=no`) so no socket lingers.
|
||||
fn installRemoteTerminfo(
|
||||
alloc: Allocator,
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
) !void {
|
||||
var buf: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer buf.deinit();
|
||||
try ghostty_terminfo.encode(&buf.writer);
|
||||
const terminfo = buf.written();
|
||||
|
||||
// ControlPath is in TMPDIR with a short, random basename. ssh uses
|
||||
// ControlPath as the bind address for a Unix domain socket; macOS
|
||||
// limits sockaddr_un.sun_path to ~104 bytes, so keeping the path
|
||||
// short leaves margin.
|
||||
const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-");
|
||||
const control_path_opt = try std.fmt.allocPrint(
|
||||
alloc,
|
||||
"ControlPath={s}",
|
||||
.{control_path},
|
||||
);
|
||||
|
||||
// Under --verbose, let remote stderr through (the `tic` step is
|
||||
// the most common failure source) and inherit ssh's stderr so it
|
||||
// reaches the user's terminal. Other steps stay quiet either way.
|
||||
const remote_script = if (opts.verbose)
|
||||
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
\\command -v tic >/dev/null 2>&1 || exit 1
|
||||
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0
|
||||
\\exit 1
|
||||
else
|
||||
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
\\command -v tic >/dev/null 2>&1 || exit 1
|
||||
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
\\exit 1
|
||||
;
|
||||
|
||||
// Set up an SSH ControlMaster scoped to this single install:
|
||||
// - ControlMaster=yes makes our client also act as the master,
|
||||
// so `infocmp | ssh tic` runs over a single connection.
|
||||
// - ControlPersist=no tears the master down when our client
|
||||
// exits; no socket lingers on the remote side.
|
||||
const argv = try std.mem.concat(alloc, []const u8, &.{
|
||||
&.{opts.ssh},
|
||||
&.{
|
||||
"-o", "ControlMaster=yes",
|
||||
"-o", "ControlPersist=no",
|
||||
"-o", control_path_opt,
|
||||
},
|
||||
opts._ssh_args.items,
|
||||
&.{remote_script},
|
||||
});
|
||||
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
|
||||
|
||||
var child: std.process.Child = .init(argv, alloc);
|
||||
child.stdin_behavior = .Pipe;
|
||||
child.stdout_behavior = .Ignore;
|
||||
child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
|
||||
|
||||
child.spawn() catch |err| {
|
||||
log.warn("terminfo install spawn failed: {}", .{err});
|
||||
return error.InstallFailed;
|
||||
};
|
||||
|
||||
if (child.stdin) |stdin| {
|
||||
stdin.writeAll(terminfo) catch {};
|
||||
stdin.close();
|
||||
child.stdin = null;
|
||||
}
|
||||
|
||||
const term = child.wait() catch |err| {
|
||||
log.warn("terminfo install wait failed: {}", .{err});
|
||||
return error.InstallFailed;
|
||||
};
|
||||
checkExit(term, "terminfo install") catch return error.InstallFailed;
|
||||
}
|
||||
|
||||
/// Returns `128 + signum` for signal-killed children, matching shell convention.
|
||||
fn childExec(alloc: Allocator, argv: []const []const u8) !u8 {
|
||||
var child: std.process.Child = .init(argv, alloc);
|
||||
child.stdin_behavior = .Inherit;
|
||||
child.stdout_behavior = .Inherit;
|
||||
child.stderr_behavior = .Inherit;
|
||||
|
||||
try child.spawn();
|
||||
const term = try child.wait();
|
||||
return switch (term) {
|
||||
.Exited => |rc| rc,
|
||||
.Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))),
|
||||
.Stopped, .Unknown => 1,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void {
|
||||
var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line);
|
||||
defer iter.deinit();
|
||||
try cli_args.parse(Options, alloc, opts, &iter);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: bare destination starts ssh args" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com");
|
||||
try testing.expectEqual(false, opts.terminfo);
|
||||
try testing.expectEqual(true, opts.@"forward-env");
|
||||
try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: short ssh flags pass through verbatim" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com");
|
||||
try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("-p", opts._ssh_args.items[0]);
|
||||
try testing.expectEqualStrings("2222", opts._ssh_args.items[1]);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: explicit -- separator" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(
|
||||
testing.allocator,
|
||||
&opts,
|
||||
"--verbose -- --some-rare-ssh-arg user@example.com",
|
||||
);
|
||||
try testing.expectEqual(true, opts.verbose);
|
||||
try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);
|
||||
}
|
||||
|
||||
test "parseDestination: typical ssh -G output" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout =
|
||||
\\user alice
|
||||
\\hostname example.com
|
||||
\\port 22
|
||||
\\identityfile ~/.ssh/id_ed25519
|
||||
\\
|
||||
;
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@example.com", result.?);
|
||||
}
|
||||
|
||||
test "parseDestination: hostname before user" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout =
|
||||
\\hostname example.com
|
||||
\\port 22
|
||||
\\user alice
|
||||
\\
|
||||
;
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@example.com", result.?);
|
||||
}
|
||||
|
||||
test "parseDestination: missing hostname returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "user alice\nport 22\n";
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
|
||||
}
|
||||
|
||||
test "parseDestination: missing user returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "hostname example.com\nport 22\n";
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
|
||||
}
|
||||
|
||||
test "parseDestination: empty input returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));
|
||||
}
|
||||
|
||||
test "parseDestination: IPv6 hostname" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "user alice\nhostname ::1\n";
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@::1", result.?);
|
||||
}
|
||||
Reference in New Issue
Block a user