mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
Add SSH Integration Configuration Option (#7608)
Addresses #4156 and #5892, specifically by implementing @mitchellh's [request](https://github.com/ghostty-org/ghostty/discussions/5892#discussioncomment-12283628) for "opt-in shell integration". ## Problem Ghostty's custom `xterm-ghostty` TERM value breaks terminal functionality when SSHing to remote systems that lack the corresponding terminfo entry. This affects enterprise environments, legacy servers, and ephemeral instances. ## Solution Adds two independent SSH integration flags within the existing `shell-integration-features` configuration: ``` shell-integration-features = ssh-env,ssh-terminfo ``` - **`ssh-env`**: TERM compatibility fix (xterm-ghostty → xterm-256color) + environment variable propagation (COLORTERM, TERM_PROGRAM, TERM_PROGRAM_VERSION) - **`ssh-terminfo`**: Automatic terminfo installation on remote hosts with caching and utility commands Flags work independently and harmoniously when combined, allowing users granular control over SSH integration behavior. ## Implementation Adds SSH wrapper functions across bash, zsh, fish, and elvish that handle TERM compatibility, environment propagation, and terminfo installation. Follows the same pattern as existing shell integration features - client-side only with graceful fallback behavior. The flag-based approach allows users to choose exactly the features they need: - Environment compatibility only: `ssh-env` - Terminfo installation only: `ssh-terminfo` - Optimal experience: `ssh-env,ssh-terminfo` - No SSH integration: omit both flags ## Evolution Note Based on maintainer feedback, this evolved from a progressive enhancement approach (`ssh-integration = basic | full`) to independent flags within `shell-integration-features`. See discussion below for the complete architectural evolution and rationale.
This commit is contained in:
@ -9,6 +9,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_cache = @import("ssh_cache.zig");
|
||||
const edit_config = @import("edit_config.zig");
|
||||
const show_config = @import("show_config.zig");
|
||||
const validate_config = @import("validate_config.zig");
|
||||
@ -41,6 +42,9 @@ pub const Action = enum {
|
||||
/// List keybind actions
|
||||
@"list-actions",
|
||||
|
||||
/// Manage SSH terminfo cache for automatic remote host setup
|
||||
@"ssh-cache",
|
||||
|
||||
/// Edit the config file in the configured terminal editor.
|
||||
@"edit-config",
|
||||
|
||||
@ -155,6 +159,7 @@ pub const Action = enum {
|
||||
.@"list-themes" => try list_themes.run(alloc),
|
||||
.@"list-colors" => try list_colors.run(alloc),
|
||||
.@"list-actions" => try list_actions.run(alloc),
|
||||
.@"ssh-cache" => try ssh_cache.run(alloc),
|
||||
.@"edit-config" => try edit_config.run(alloc),
|
||||
.@"show-config" => try show_config.run(alloc),
|
||||
.@"validate-config" => try validate_config.run(alloc),
|
||||
@ -192,6 +197,7 @@ pub const Action = enum {
|
||||
.@"list-themes" => list_themes.Options,
|
||||
.@"list-colors" => list_colors.Options,
|
||||
.@"list-actions" => list_actions.Options,
|
||||
.@"ssh-cache" => ssh_cache.Options,
|
||||
.@"edit-config" => edit_config.Options,
|
||||
.@"show-config" => show_config.Options,
|
||||
.@"validate-config" => validate_config.Options,
|
||||
|
549
src/cli/ssh-cache/DiskCache.zig
Normal file
549
src/cli/ssh-cache/DiskCache.zig
Normal file
@ -0,0 +1,549 @@
|
||||
/// An SSH terminfo entry cache that stores its cache data on
|
||||
/// disk. The cache only stores metadata (hostname, terminfo value,
|
||||
/// etc.) and does not store any sensitive data.
|
||||
const DiskCache = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const xdg = @import("../../os/main.zig").xdg;
|
||||
const TempDir = @import("../../os/main.zig").TempDir;
|
||||
const Entry = @import("Entry.zig");
|
||||
|
||||
// 512KB - sufficient for approximately 10k entries
|
||||
const MAX_CACHE_SIZE = 512 * 1024;
|
||||
|
||||
/// Path to a file where the cache is stored.
|
||||
path: []const u8,
|
||||
|
||||
pub const DefaultPathError = Allocator.Error || error{
|
||||
/// The general error that is returned for any filesystem error
|
||||
/// that may have resulted in the XDG lookup failing.
|
||||
XdgLookupFailed,
|
||||
};
|
||||
|
||||
pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
||||
|
||||
/// Returns the default path for the cache for a given program.
|
||||
///
|
||||
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||
///
|
||||
/// The returned value is allocated and must be freed by the caller.
|
||||
pub fn defaultPath(
|
||||
alloc: Allocator,
|
||||
program: []const u8,
|
||||
) DefaultPathError![]const u8 {
|
||||
const state_dir: []const u8 = xdg.state(
|
||||
alloc,
|
||||
.{ .subdir = program },
|
||||
) catch |err| return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
else => error.XdgLookupFailed,
|
||||
};
|
||||
defer alloc.free(state_dir);
|
||||
return try std.fs.path.join(alloc, &.{ state_dir, "ssh_cache" });
|
||||
}
|
||||
|
||||
/// Clear all cache data stored in the disk cache.
|
||||
/// This removes the cache file from disk, effectively clearing all cached
|
||||
/// SSH terminfo entries.
|
||||
pub fn clear(self: DiskCache) !void {
|
||||
std.fs.cwd().deleteFile(self.path) catch |err| switch (err) {
|
||||
error.FileNotFound => {},
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
pub const AddResult = enum { added, updated };
|
||||
|
||||
/// Add or update a hostname entry in the cache.
|
||||
/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
|
||||
/// The cache file is created if it doesn't exist with secure permissions (0600).
|
||||
pub fn add(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) !AddResult {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
|
||||
// Create cache directory if needed
|
||||
if (std.fs.path.dirname(self.path)) |dir| {
|
||||
std.fs.makeDirAbsolute(dir) catch |err| switch (err) {
|
||||
error.PathAlreadyExists => {},
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
// Open or create cache file with secure permissions
|
||||
const file = std.fs.createFileAbsolute(self.path, .{
|
||||
.read = true,
|
||||
.truncate = false,
|
||||
.mode = 0o600,
|
||||
}) catch |err| switch (err) {
|
||||
error.PathAlreadyExists => blk: {
|
||||
const existing_file = try std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
);
|
||||
errdefer existing_file.close();
|
||||
try fixupPermissions(existing_file);
|
||||
break :blk existing_file;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
// Lock
|
||||
_ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
defer file.unlock();
|
||||
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Add or update entry
|
||||
const gop = try entries.getOrPut(hostname);
|
||||
const result: AddResult = if (!gop.found_existing) add: {
|
||||
const hostname_copy = try alloc.dupe(u8, hostname);
|
||||
errdefer alloc.free(hostname_copy);
|
||||
const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
|
||||
errdefer alloc.free(terminfo_copy);
|
||||
|
||||
gop.key_ptr.* = hostname_copy;
|
||||
gop.value_ptr.* = .{
|
||||
.hostname = gop.key_ptr.*,
|
||||
.timestamp = std.time.timestamp(),
|
||||
.terminfo_version = terminfo_copy,
|
||||
};
|
||||
break :add .added;
|
||||
} else update: {
|
||||
// Update timestamp for existing entry
|
||||
gop.value_ptr.timestamp = std.time.timestamp();
|
||||
break :update .updated;
|
||||
};
|
||||
|
||||
try self.writeCacheFile(alloc, entries, null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Remove a hostname entry from the cache.
|
||||
/// No error is returned if the hostname doesn't exist or the cache file is missing.
|
||||
pub fn remove(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) !void {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
try fixupPermissions(file);
|
||||
|
||||
// Acquire exclusive lock
|
||||
_ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
defer file.unlock();
|
||||
|
||||
// Read existing entries
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove the entry if it exists and ensure we free the memory
|
||||
if (entries.fetchRemove(hostname)) |kv| {
|
||||
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||
alloc.free(kv.value.hostname);
|
||||
alloc.free(kv.value.terminfo_version);
|
||||
}
|
||||
|
||||
try self.writeCacheFile(alloc, entries, null);
|
||||
}
|
||||
|
||||
/// Check if a hostname exists in the cache.
|
||||
/// Returns false if the cache file doesn't exist.
|
||||
pub fn contains(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) !bool {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return false,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
try fixupPermissions(file);
|
||||
|
||||
// Read existing entries
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
return entries.contains(hostname);
|
||||
}
|
||||
|
||||
fn fixupPermissions(file: std.fs.File) !void {
|
||||
// Ensure file has correct permissions (readable/writable by
|
||||
// owner only)
|
||||
const stat = try file.stat();
|
||||
if (stat.mode & 0o777 != 0o600) {
|
||||
try file.chmod(0o600);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeCacheFile(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
entries: std.StringHashMap(Entry),
|
||||
expire_days: ?u32,
|
||||
) !void {
|
||||
var td: TempDir = try .init();
|
||||
defer td.deinit();
|
||||
|
||||
const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 });
|
||||
defer tmp_file.close();
|
||||
const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache");
|
||||
defer alloc.free(tmp_path);
|
||||
|
||||
const writer = tmp_file.writer();
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
// Only write non-expired entries
|
||||
if (kv.value_ptr.isExpired(expire_days)) continue;
|
||||
try kv.value_ptr.format(writer);
|
||||
}
|
||||
|
||||
// Atomic replace
|
||||
try std.fs.renameAbsolute(tmp_path, self.path);
|
||||
}
|
||||
|
||||
/// List all entries in the cache.
|
||||
/// The returned HashMap must be freed using `deinitEntries`.
|
||||
/// Returns an empty map if the cache file doesn't exist.
|
||||
pub fn list(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
) !std.StringHashMap(Entry) {
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{},
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return .init(alloc),
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
return readEntries(alloc, file);
|
||||
}
|
||||
|
||||
/// Free memory allocated by the `list` function.
|
||||
/// This must be called to properly deallocate all entry data.
|
||||
pub fn deinitEntries(
|
||||
alloc: Allocator,
|
||||
entries: *std.StringHashMap(Entry),
|
||||
) void {
|
||||
// All our entries we dupe the memory owned by the hostname and the
|
||||
// terminfo, and we always match the hostname key and value.
|
||||
var it = entries.iterator();
|
||||
while (it.next()) |entry| {
|
||||
assert(entry.key_ptr.*.ptr == entry.value_ptr.hostname.ptr);
|
||||
alloc.free(entry.value_ptr.hostname);
|
||||
alloc.free(entry.value_ptr.terminfo_version);
|
||||
}
|
||||
entries.deinit();
|
||||
}
|
||||
|
||||
fn readEntries(
|
||||
alloc: Allocator,
|
||||
file: std.fs.File,
|
||||
) !std.StringHashMap(Entry) {
|
||||
const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE);
|
||||
defer alloc.free(content);
|
||||
|
||||
var entries = std.StringHashMap(Entry).init(alloc);
|
||||
var lines = std.mem.tokenizeScalar(u8, content, '\n');
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
const entry = Entry.parse(trimmed) orelse continue;
|
||||
|
||||
// Always allocate hostname first to avoid key pointer confusion
|
||||
const hostname = try alloc.dupe(u8, entry.hostname);
|
||||
errdefer alloc.free(hostname);
|
||||
|
||||
const gop = try entries.getOrPut(hostname);
|
||||
if (!gop.found_existing) {
|
||||
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
|
||||
gop.value_ptr.* = .{
|
||||
.hostname = hostname,
|
||||
.timestamp = entry.timestamp,
|
||||
.terminfo_version = terminfo_copy,
|
||||
};
|
||||
} else {
|
||||
// Don't need the copy since entry already exists
|
||||
alloc.free(hostname);
|
||||
|
||||
// Handle duplicate entries - keep newer timestamp
|
||||
if (entry.timestamp > gop.value_ptr.timestamp) {
|
||||
gop.value_ptr.timestamp = entry.timestamp;
|
||||
if (!std.mem.eql(
|
||||
u8,
|
||||
gop.value_ptr.terminfo_version,
|
||||
entry.terminfo_version,
|
||||
)) {
|
||||
alloc.free(gop.value_ptr.terminfo_version);
|
||||
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
|
||||
gop.value_ptr.terminfo_version = terminfo_copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Supports both standalone hostnames and user@hostname format
|
||||
fn isValidCacheKey(key: []const u8) bool {
|
||||
// 253 + 1 + 64 for user@hostname
|
||||
if (key.len == 0 or key.len > 320) return false;
|
||||
|
||||
// Check for user@hostname format
|
||||
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
|
||||
const user = key[0..at_pos];
|
||||
const hostname = key[at_pos + 1 ..];
|
||||
return isValidUser(user) and isValidHostname(hostname);
|
||||
}
|
||||
|
||||
return isValidHostname(key);
|
||||
}
|
||||
|
||||
// Basic hostname validation - accepts domains and IPs
|
||||
// (including IPv6 in brackets)
|
||||
fn isValidHostname(host: []const u8) bool {
|
||||
if (host.len == 0 or host.len > 253) return false;
|
||||
|
||||
// Handle IPv6 addresses in brackets
|
||||
if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') {
|
||||
const ipv6_part = host[1 .. host.len - 1];
|
||||
if (ipv6_part.len == 0) return false;
|
||||
var has_colon = false;
|
||||
for (ipv6_part) |c| {
|
||||
switch (c) {
|
||||
'a'...'f', 'A'...'F', '0'...'9' => {},
|
||||
':' => has_colon = true,
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
return has_colon;
|
||||
}
|
||||
|
||||
// Standard hostname/domain validation
|
||||
for (host) |c| {
|
||||
switch (c) {
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {},
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
|
||||
// No leading/trailing dots or hyphens, no consecutive dots
|
||||
if (host[0] == '.' or host[0] == '-' or
|
||||
host[host.len - 1] == '.' or host[host.len - 1] == '-')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return std.mem.indexOf(u8, host, "..") == null;
|
||||
}
|
||||
|
||||
fn isValidUser(user: []const u8) bool {
|
||||
if (user.len == 0 or user.len > 64) return false;
|
||||
for (user) |c| {
|
||||
switch (c) {
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {},
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
test "disk cache default path" {
|
||||
const testing = std.testing;
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
const path = try DiskCache.defaultPath(alloc, "ghostty");
|
||||
defer alloc.free(path);
|
||||
try testing.expect(path.len > 0);
|
||||
}
|
||||
|
||||
test "disk cache clear" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Create our path
|
||||
var td: TempDir = try .init();
|
||||
defer td.deinit();
|
||||
{
|
||||
var file = try td.dir.createFile("cache", .{});
|
||||
defer file.close();
|
||||
try file.writer().writeAll("HELLO!");
|
||||
}
|
||||
const path = try td.dir.realpathAlloc(alloc, "cache");
|
||||
defer alloc.free(path);
|
||||
|
||||
// Setup our cache
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
try cache.clear();
|
||||
|
||||
// Verify the file is gone
|
||||
try testing.expectError(
|
||||
error.FileNotFound,
|
||||
td.dir.openFile("cache", .{}),
|
||||
);
|
||||
}
|
||||
|
||||
test "disk cache operations" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Create our path
|
||||
var td: TempDir = try .init();
|
||||
defer td.deinit();
|
||||
{
|
||||
var file = try td.dir.createFile("cache", .{});
|
||||
defer file.close();
|
||||
try file.writer().writeAll("HELLO!");
|
||||
}
|
||||
const path = try td.dir.realpathAlloc(alloc, "cache");
|
||||
defer alloc.free(path);
|
||||
|
||||
// Setup our cache
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.updated,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expect(
|
||||
try cache.contains(alloc, "example.com"),
|
||||
);
|
||||
|
||||
// List
|
||||
var entries = try cache.list(alloc);
|
||||
deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove
|
||||
try cache.remove(alloc, "example.com");
|
||||
try testing.expect(
|
||||
!(try cache.contains(alloc, "example.com")),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
}
|
||||
|
||||
// Tests
|
||||
test "hostname validation - valid cases" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidHostname("example.com"));
|
||||
try testing.expect(isValidHostname("sub.example.com"));
|
||||
try testing.expect(isValidHostname("host-name.domain.org"));
|
||||
try testing.expect(isValidHostname("192.168.1.1"));
|
||||
try testing.expect(isValidHostname("a"));
|
||||
try testing.expect(isValidHostname("1"));
|
||||
}
|
||||
|
||||
test "hostname validation - IPv6 addresses" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidHostname("[::1]"));
|
||||
try testing.expect(isValidHostname("[2001:db8::1]"));
|
||||
try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported
|
||||
try testing.expect(!isValidHostname("[]")); // Empty IPv6
|
||||
try testing.expect(!isValidHostname("[invalid]")); // No colons
|
||||
}
|
||||
|
||||
test "hostname validation - invalid cases" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(!isValidHostname(""));
|
||||
try testing.expect(!isValidHostname("host\nname"));
|
||||
try testing.expect(!isValidHostname(".example.com"));
|
||||
try testing.expect(!isValidHostname("example.com."));
|
||||
try testing.expect(!isValidHostname("host..domain"));
|
||||
try testing.expect(!isValidHostname("-hostname"));
|
||||
try testing.expect(!isValidHostname("hostname-"));
|
||||
try testing.expect(!isValidHostname("host name"));
|
||||
try testing.expect(!isValidHostname("host_name"));
|
||||
try testing.expect(!isValidHostname("host@domain"));
|
||||
try testing.expect(!isValidHostname("host:port"));
|
||||
|
||||
// Too long
|
||||
const long_host = "a" ** 254;
|
||||
try testing.expect(!isValidHostname(long_host));
|
||||
}
|
||||
|
||||
test "user validation - valid cases" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidUser("user"));
|
||||
try testing.expect(isValidUser("deploy"));
|
||||
try testing.expect(isValidUser("test-user"));
|
||||
try testing.expect(isValidUser("user_name"));
|
||||
try testing.expect(isValidUser("user.name"));
|
||||
try testing.expect(isValidUser("user123"));
|
||||
try testing.expect(isValidUser("a"));
|
||||
}
|
||||
|
||||
test "user validation - complex realistic cases" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidUser("git"));
|
||||
try testing.expect(isValidUser("ubuntu"));
|
||||
try testing.expect(isValidUser("root"));
|
||||
try testing.expect(isValidUser("service.account"));
|
||||
try testing.expect(isValidUser("user-with-dashes"));
|
||||
}
|
||||
|
||||
test "user validation - invalid cases" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(!isValidUser(""));
|
||||
try testing.expect(!isValidUser("user name"));
|
||||
try testing.expect(!isValidUser("user@domain"));
|
||||
try testing.expect(!isValidUser("user:group"));
|
||||
try testing.expect(!isValidUser("user\nname"));
|
||||
|
||||
// Too long
|
||||
const long_user = "a" ** 65;
|
||||
try testing.expect(!isValidUser(long_user));
|
||||
}
|
||||
|
||||
test "cache key validation - hostname format" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidCacheKey("example.com"));
|
||||
try testing.expect(isValidCacheKey("sub.example.com"));
|
||||
try testing.expect(isValidCacheKey("192.168.1.1"));
|
||||
try testing.expect(isValidCacheKey("[::1]"));
|
||||
try testing.expect(!isValidCacheKey(""));
|
||||
try testing.expect(!isValidCacheKey(".invalid.com"));
|
||||
}
|
||||
|
||||
test "cache key validation - user@hostname format" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isValidCacheKey("user@example.com"));
|
||||
try testing.expect(isValidCacheKey("deploy@prod.server.com"));
|
||||
try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
|
||||
try testing.expect(isValidCacheKey("user_name@host.domain.org"));
|
||||
try testing.expect(isValidCacheKey("git@github.com"));
|
||||
try testing.expect(isValidCacheKey("ubuntu@[::1]"));
|
||||
try testing.expect(!isValidCacheKey("@example.com"));
|
||||
try testing.expect(!isValidCacheKey("user@"));
|
||||
try testing.expect(!isValidCacheKey("user@@host"));
|
||||
try testing.expect(!isValidCacheKey("user@.invalid.com"));
|
||||
}
|
154
src/cli/ssh-cache/Entry.zig
Normal file
154
src/cli/ssh-cache/Entry.zig
Normal file
@ -0,0 +1,154 @@
|
||||
/// A single entry within our SSH entry cache. Our SSH entry cache
|
||||
/// stores which hosts we've sent our terminfo to so that we don't have
|
||||
/// to send it again. It doesn't store any sensitive information.
|
||||
const Entry = @This();
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
hostname: []const u8,
|
||||
timestamp: i64,
|
||||
terminfo_version: []const u8,
|
||||
|
||||
pub fn parse(line: []const u8) ?Entry {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r\n");
|
||||
if (trimmed.len == 0) return null;
|
||||
|
||||
// Parse format: hostname|timestamp|terminfo_version
|
||||
var iter = std.mem.tokenizeScalar(u8, trimmed, '|');
|
||||
const hostname = iter.next() orelse return null;
|
||||
const timestamp_str = iter.next() orelse return null;
|
||||
const terminfo_version = iter.next() orelse "xterm-ghostty";
|
||||
const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| {
|
||||
std.log.warn(
|
||||
"Invalid timestamp in cache entry: {s} err={}",
|
||||
.{ timestamp_str, err },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return .{
|
||||
.hostname = hostname,
|
||||
.timestamp = timestamp,
|
||||
.terminfo_version = terminfo_version,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn format(self: Entry, writer: anytype) !void {
|
||||
try writer.print(
|
||||
"{s}|{d}|{s}\n",
|
||||
.{ self.hostname, self.timestamp, self.terminfo_version },
|
||||
);
|
||||
}
|
||||
|
||||
pub fn isExpired(self: Entry, expire_days_: ?u32) bool {
|
||||
const expire_days = expire_days_ orelse return false;
|
||||
const now = std.time.timestamp();
|
||||
const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day);
|
||||
return age_days > expire_days;
|
||||
}
|
||||
|
||||
test "cache entry expiration" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const fresh_entry: Entry = .{
|
||||
.hostname = "test.com",
|
||||
.timestamp = now - std.time.s_per_day, // 1 day old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!fresh_entry.isExpired(90));
|
||||
|
||||
const old_entry: Entry = .{
|
||||
.hostname = "old.com",
|
||||
.timestamp = now - (std.time.s_per_day * 100), // 100 days old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(old_entry.isExpired(90));
|
||||
|
||||
// Test never-expire case
|
||||
try testing.expect(!old_entry.isExpired(null));
|
||||
}
|
||||
|
||||
test "cache entry expiration exact boundary" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
// Exactly at expiration boundary
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now - (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
try testing.expect(boundary_entry.isExpired(29));
|
||||
}
|
||||
|
||||
test "cache entry expiration large timestamp" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now + (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
}
|
||||
|
||||
test "cache entry parsing valid formats" {
|
||||
const testing = std.testing;
|
||||
|
||||
const entry = Entry.parse("example.com|1640995200|xterm-ghostty").?;
|
||||
try testing.expectEqualStrings("example.com", entry.hostname);
|
||||
try testing.expectEqual(@as(i64, 1640995200), entry.timestamp);
|
||||
try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version);
|
||||
|
||||
// Test default terminfo version
|
||||
const entry_no_version = Entry.parse("test.com|1640995200").?;
|
||||
try testing.expectEqualStrings(
|
||||
"xterm-ghostty",
|
||||
entry_no_version.terminfo_version,
|
||||
);
|
||||
|
||||
// Test complex hostnames
|
||||
const complex_entry = Entry.parse("user@server.example.com|1640995200|xterm-ghostty").?;
|
||||
try testing.expectEqualStrings(
|
||||
"user@server.example.com",
|
||||
complex_entry.hostname,
|
||||
);
|
||||
}
|
||||
|
||||
test "cache entry parsing invalid formats" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expect(Entry.parse("") == null);
|
||||
|
||||
// Invalid format (no pipe)
|
||||
try testing.expect(Entry.parse("v1") == null);
|
||||
|
||||
// Missing timestamp
|
||||
try testing.expect(Entry.parse("example.com") == null);
|
||||
|
||||
// Invalid timestamp
|
||||
try testing.expect(Entry.parse("example.com|invalid") == null);
|
||||
|
||||
// Empty terminfo should default
|
||||
try testing.expect(Entry.parse("example.com|1640995200|") != null);
|
||||
}
|
||||
|
||||
test "cache entry parsing malformed data resilience" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Extra pipes should not break parsing
|
||||
try testing.expect(Entry.parse("host|123|term|extra") != null);
|
||||
|
||||
// Whitespace handling
|
||||
try testing.expect(Entry.parse(" host|123|term ") != null);
|
||||
try testing.expect(Entry.parse("\n") == null);
|
||||
try testing.expect(Entry.parse(" \t \n") == null);
|
||||
|
||||
// Extremely large timestamp
|
||||
try testing.expect(
|
||||
Entry.parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") == null,
|
||||
);
|
||||
}
|
208
src/cli/ssh_cache.zig
Normal file
208
src/cli/ssh_cache.zig
Normal file
@ -0,0 +1,208 @@
|
||||
const std = @import("std");
|
||||
const fs = std.fs;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const xdg = @import("../os/xdg.zig");
|
||||
const args = @import("args.zig");
|
||||
const Action = @import("action.zig").Action;
|
||||
pub const Entry = @import("ssh-cache/Entry.zig");
|
||||
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
|
||||
|
||||
pub const Options = struct {
|
||||
clear: bool = false,
|
||||
add: ?[]const u8 = null,
|
||||
remove: ?[]const u8 = null,
|
||||
host: ?[]const u8 = null,
|
||||
@"expire-days": ?u32 = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
_ = self;
|
||||
}
|
||||
|
||||
pub fn help(self: Options) !void {
|
||||
_ = self;
|
||||
return Action.help_error;
|
||||
}
|
||||
};
|
||||
|
||||
/// Manage the SSH terminfo cache for automatic remote host setup.
|
||||
///
|
||||
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
||||
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
||||
/// manages the cache of successful installations to avoid redundant uploads.
|
||||
///
|
||||
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
||||
/// Entries older than the expiration period are automatically removed during cache
|
||||
/// operations. By default, entries never expire.
|
||||
///
|
||||
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
||||
/// If multiple are specified, one of the actions will be executed but
|
||||
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
||||
/// multiple actions into separate commands.
|
||||
///
|
||||
/// Examples:
|
||||
/// ghostty +ssh-cache # List all cached hosts
|
||||
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
||||
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
||||
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
||||
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
|
||||
{
|
||||
var iter = try args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
try args.parse(Options, alloc_gpa, &opts, &iter);
|
||||
}
|
||||
|
||||
const stdout = std.io.getStdOut().writer();
|
||||
const stderr = std.io.getStdErr().writer();
|
||||
|
||||
// Setup our disk cache to the standard location
|
||||
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
|
||||
if (opts.clear) {
|
||||
try cache.clear();
|
||||
try stdout.print("Cache cleared.\n", .{});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.add) |host| {
|
||||
const result = cache.add(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to add '{s}' to cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
switch (result) {
|
||||
.added => try stdout.print("Added '{s}' to cache.\n", .{host}),
|
||||
.updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}),
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.remove) |host| {
|
||||
cache.remove(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to remove '{s}' from cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
try stdout.print("Removed '{s}' from cache.\n", .{host});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.host) |host| {
|
||||
const cached = cache.contains(alloc, host) catch |err| switch (err) {
|
||||
error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to check host '{s}' in cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
if (cached) {
|
||||
try stdout.print(
|
||||
"'{s}' has Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
);
|
||||
return 0;
|
||||
} else {
|
||||
try stdout.print(
|
||||
"'{s}' does not have Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Default action: list all hosts
|
||||
var entries = try cache.list(alloc);
|
||||
defer DiskCache.deinitEntries(alloc, &entries);
|
||||
try listEntries(alloc, &entries, stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn listEntries(
|
||||
alloc: Allocator,
|
||||
entries: *const std.StringHashMap(Entry),
|
||||
writer: anytype,
|
||||
) !void {
|
||||
if (entries.count() == 0) {
|
||||
try writer.print("No hosts in cache.\n", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort entries by hostname for consistent output
|
||||
var items = std.ArrayList(Entry).init(alloc);
|
||||
defer items.deinit();
|
||||
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
try items.append(kv.value_ptr.*);
|
||||
}
|
||||
|
||||
std.mem.sort(Entry, items.items, {}, struct {
|
||||
fn lessThan(_: void, a: Entry, b: Entry) bool {
|
||||
return std.mem.lessThan(u8, a.hostname, b.hostname);
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
|
||||
const now = std.time.timestamp();
|
||||
|
||||
for (items.items) |entry| {
|
||||
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
|
||||
if (age_days == 0) {
|
||||
try writer.print(" {s} (today)\n", .{entry.hostname});
|
||||
} else if (age_days == 1) {
|
||||
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
|
||||
} else {
|
||||
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
_ = DiskCache;
|
||||
_ = Entry;
|
||||
}
|
@ -2202,6 +2202,8 @@ keybind: Keybinds = .{},
|
||||
/// its default value is used, so you must explicitly disable features you don't
|
||||
/// want. You can also use `true` or `false` to turn all features on or off.
|
||||
///
|
||||
/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title`
|
||||
///
|
||||
/// Available features:
|
||||
///
|
||||
/// * `cursor` - Set the cursor to a blinking bar at the prompt.
|
||||
@ -2210,7 +2212,24 @@ keybind: Keybinds = .{},
|
||||
///
|
||||
/// * `title` - Set the window title via shell integration.
|
||||
///
|
||||
/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title`
|
||||
/// * `ssh-env` - Enable SSH environment variable compatibility. Automatically
|
||||
/// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to
|
||||
/// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION.
|
||||
/// Whether or not these variables will be accepted by the remote host(s) will
|
||||
/// depend on whether or not the variables are allowed in their sshd_config.
|
||||
///
|
||||
/// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts.
|
||||
/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when
|
||||
/// connecting to hosts that lack it. Requires `infocmp` to be available locally
|
||||
/// and `tic` to be available on remote hosts. Once terminfo is installed on a
|
||||
/// remote host, it will be automatically "cached" to avoid repeat installations.
|
||||
/// If desired, the `+ssh-cache` CLI action can be used to manage the installation
|
||||
/// cache manually using various arguments.
|
||||
///
|
||||
/// SSH features work independently and can be combined for optimal experience:
|
||||
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its
|
||||
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
|
||||
/// `xterm-256color` with environment variables if terminfo installation fails.
|
||||
@"shell-integration-features": ShellIntegrationFeatures = .{},
|
||||
|
||||
/// Custom entries into the command palette.
|
||||
@ -6636,6 +6655,8 @@ pub const ShellIntegrationFeatures = packed struct {
|
||||
cursor: bool = true,
|
||||
sudo: bool = false,
|
||||
title: bool = true,
|
||||
@"ssh-env": bool = false,
|
||||
@"ssh-terminfo": bool = false,
|
||||
};
|
||||
|
||||
pub const RepeatableCommand = struct {
|
||||
|
@ -95,6 +95,78 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
|
||||
}
|
||||
fi
|
||||
|
||||
# SSH Integration
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
||||
ssh() {
|
||||
builtin local ssh_term ssh_opts
|
||||
ssh_term="xterm-256color"
|
||||
ssh_opts=()
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then
|
||||
ssh_opts+=(-o "SetEnv COLORTERM=truecolor")
|
||||
ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION")
|
||||
fi
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then
|
||||
builtin local ssh_user ssh_hostname
|
||||
|
||||
while IFS=' ' read -r ssh_key ssh_value; do
|
||||
case "$ssh_key" in
|
||||
user) ssh_user="$ssh_value" ;;
|
||||
hostname) ssh_hostname="$ssh_value" ;;
|
||||
esac
|
||||
[[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break
|
||||
done < <(builtin command ssh -G "$@" 2>/dev/null)
|
||||
|
||||
builtin local ssh_target="${ssh_user}@${ssh_hostname}"
|
||||
|
||||
if [[ -n "$ssh_hostname" ]]; then
|
||||
# Check if terminfo is already cached
|
||||
if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then
|
||||
ssh_term="xterm-ghostty"
|
||||
elif builtin command -v infocmp >/dev/null 2>&1; then
|
||||
builtin local ssh_terminfo ssh_cpath_dir ssh_cpath
|
||||
|
||||
ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null)
|
||||
|
||||
if [[ -n "$ssh_terminfo" ]]; then
|
||||
builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
|
||||
|
||||
ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$"
|
||||
ssh_cpath="$ssh_cpath_dir/socket"
|
||||
|
||||
if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" '
|
||||
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
|
||||
' 2>/dev/null; then
|
||||
ssh_term="xterm-ghostty"
|
||||
ssh_opts+=(-o "ControlPath=$ssh_cpath")
|
||||
|
||||
# Cache successful installation
|
||||
if [[ -n "$ssh_target" ]] && builtin command -v ghostty >/dev/null 2>&1; then
|
||||
ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
builtin echo "Warning: Failed to install terminfo." >&2
|
||||
fi
|
||||
else
|
||||
builtin echo "Warning: Could not generate terminfo data." >&2
|
||||
fi
|
||||
else
|
||||
builtin echo "Warning: ghostty command not available for cache management." >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@"
|
||||
}
|
||||
fi
|
||||
|
||||
# Import bash-preexec, safe to do multiple times
|
||||
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
|
||||
|
||||
|
@ -98,6 +98,95 @@
|
||||
(external sudo) $@args
|
||||
}
|
||||
|
||||
# SSH Integration
|
||||
use str
|
||||
|
||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) {
|
||||
fn ssh {|@args|
|
||||
var ssh-term = "xterm-256color"
|
||||
var ssh-opts = []
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) {
|
||||
set ssh-opts = (conj $ssh-opts
|
||||
-o "SetEnv COLORTERM=truecolor"
|
||||
-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"
|
||||
)
|
||||
}
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) {
|
||||
var ssh-user = ""
|
||||
var ssh-hostname = ""
|
||||
|
||||
# Parse ssh config
|
||||
var ssh-config = (external ssh -G $@args 2>/dev/null | slurp)
|
||||
for line (str:split "\n" $ssh-config) {
|
||||
var parts = (str:split " " $line)
|
||||
if (> (count $parts) 1) {
|
||||
var ssh-key = $parts[0]
|
||||
var ssh-value = $parts[1]
|
||||
if (eq $ssh-key user) {
|
||||
set ssh-user = $ssh-value
|
||||
} elif (eq $ssh-key hostname) {
|
||||
set ssh-hostname = $ssh-value
|
||||
}
|
||||
if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ssh-target = $ssh-user"@"$ssh-hostname
|
||||
|
||||
if (not-eq $ssh-hostname "") {
|
||||
# Check if terminfo is already cached
|
||||
if (and (has-external ghostty) (bool ?(external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1))) {
|
||||
set ssh-term = "xterm-ghostty"
|
||||
} elif (has-external infocmp) {
|
||||
var ssh-terminfo = (external infocmp -0 -x xterm-ghostty 2>/dev/null | slurp)
|
||||
|
||||
if (not-eq $ssh-terminfo "") {
|
||||
echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2
|
||||
|
||||
var ssh-cpath-dir = ""
|
||||
try {
|
||||
set ssh-cpath-dir = (external mktemp -d "/tmp/ghostty-ssh-"$ssh-user".XXXXXX" 2>/dev/null | slurp)
|
||||
} catch {
|
||||
set ssh-cpath-dir = "/tmp/ghostty-ssh-"$ssh-user"."(randint 10000 99999)
|
||||
}
|
||||
var ssh-cpath = $ssh-cpath-dir"/socket"
|
||||
|
||||
if (bool ?(echo $ssh-terminfo | external ssh $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args '
|
||||
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
|
||||
' 2>/dev/null)) {
|
||||
set ssh-term = "xterm-ghostty"
|
||||
set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath)
|
||||
|
||||
# Cache successful installation
|
||||
if (and (not-eq $ssh-target "") (has-external ghostty)) {
|
||||
external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1
|
||||
}
|
||||
} else {
|
||||
echo "Warning: Failed to install terminfo." >&2
|
||||
}
|
||||
} else {
|
||||
echo "Warning: Could not generate terminfo data." >&2
|
||||
}
|
||||
} else {
|
||||
echo "Warning: ghostty command not available for cache management." >&2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
external E:TERM=$ssh-term ssh $@ssh-opts $@args
|
||||
}
|
||||
}
|
||||
|
||||
defer {
|
||||
mark-prompt-start
|
||||
report-pwd
|
||||
|
@ -86,6 +86,89 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
end
|
||||
end
|
||||
|
||||
# SSH Integration
|
||||
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
|
||||
if contains ssh-env $features; or contains ssh-terminfo $features
|
||||
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
|
||||
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
|
||||
set -l ssh_term "xterm-256color"
|
||||
set -l ssh_opts
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if contains ssh-env $features
|
||||
set -a ssh_opts -o "SetEnv COLORTERM=truecolor"
|
||||
set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"
|
||||
end
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if contains ssh-terminfo $features
|
||||
set -l ssh_user
|
||||
set -l ssh_hostname
|
||||
|
||||
for line in (command ssh -G $argv 2>/dev/null)
|
||||
set -l parts (string split ' ' -- $line)
|
||||
if test (count $parts) -ge 2
|
||||
switch $parts[1]
|
||||
case user
|
||||
set ssh_user $parts[2]
|
||||
case hostname
|
||||
set ssh_hostname $parts[2]
|
||||
end
|
||||
if test -n "$ssh_user"; and test -n "$ssh_hostname"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
set -l ssh_target "$ssh_user@$ssh_hostname"
|
||||
|
||||
if test -n "$ssh_hostname"
|
||||
# Check if terminfo is already cached
|
||||
if command -v ghostty >/dev/null 2>&1; and ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1
|
||||
set ssh_term "xterm-ghostty"
|
||||
else if command -v infocmp >/dev/null 2>&1
|
||||
set -l ssh_terminfo
|
||||
set -l ssh_cpath_dir
|
||||
set -l ssh_cpath
|
||||
|
||||
set ssh_terminfo (infocmp -0 -x xterm-ghostty 2>/dev/null)
|
||||
|
||||
if test -n "$ssh_terminfo"
|
||||
echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
|
||||
|
||||
set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random))
|
||||
set ssh_cpath "$ssh_cpath_dir/socket"
|
||||
|
||||
if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv '
|
||||
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
|
||||
' 2>/dev/null
|
||||
set ssh_term "xterm-ghostty"
|
||||
set -a ssh_opts -o "ControlPath=$ssh_cpath"
|
||||
|
||||
# Cache successful installation
|
||||
if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1
|
||||
ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true
|
||||
end
|
||||
else
|
||||
echo "Warning: Failed to install terminfo." >&2
|
||||
end
|
||||
else
|
||||
echo "Warning: Could not generate terminfo data." >&2
|
||||
end
|
||||
else
|
||||
echo "Warning: ghostty command not available for cache management." >&2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
env TERM="$ssh_term" command ssh $ssh_opts $argv
|
||||
end
|
||||
end
|
||||
|
||||
# Setup prompt marking
|
||||
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror
|
||||
# If we never got the output end event, then we need to send it now.
|
||||
|
@ -244,6 +244,81 @@ _ghostty_deferred_init() {
|
||||
}
|
||||
fi
|
||||
|
||||
# SSH Integration
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
||||
ssh() {
|
||||
emulate -L zsh
|
||||
setopt local_options no_glob_subst
|
||||
|
||||
local ssh_term ssh_opts
|
||||
ssh_term="xterm-256color"
|
||||
ssh_opts=()
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then
|
||||
ssh_opts+=(-o "SetEnv COLORTERM=truecolor")
|
||||
ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION")
|
||||
fi
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then
|
||||
local ssh_user ssh_hostname
|
||||
|
||||
while IFS=' ' read -r ssh_key ssh_value; do
|
||||
case "$ssh_key" in
|
||||
user) ssh_user="$ssh_value" ;;
|
||||
hostname) ssh_hostname="$ssh_value" ;;
|
||||
esac
|
||||
[[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break
|
||||
done < <(command ssh -G "$@" 2>/dev/null)
|
||||
|
||||
local ssh_target="${ssh_user}@${ssh_hostname}"
|
||||
|
||||
if [[ -n "$ssh_hostname" ]]; then
|
||||
# Check if terminfo is already cached
|
||||
if (( $+commands[ghostty] )) && ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then
|
||||
ssh_term="xterm-ghostty"
|
||||
elif (( $+commands[infocmp] )); then
|
||||
local ssh_terminfo ssh_cpath_dir ssh_cpath
|
||||
|
||||
ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null)
|
||||
|
||||
if [[ -n "$ssh_terminfo" ]]; then
|
||||
print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
|
||||
|
||||
ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$"
|
||||
ssh_cpath="$ssh_cpath_dir/socket"
|
||||
|
||||
if print "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" '
|
||||
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
|
||||
' 2>/dev/null; then
|
||||
ssh_term="xterm-ghostty"
|
||||
ssh_opts+=(-o "ControlPath=$ssh_cpath")
|
||||
|
||||
# Cache successful installation
|
||||
if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then
|
||||
ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
print "Warning: Failed to install terminfo." >&2
|
||||
fi
|
||||
else
|
||||
print "Warning: Could not generate terminfo data." >&2
|
||||
fi
|
||||
else
|
||||
print "Warning: ghostty command not available for cache management." >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@"
|
||||
}
|
||||
fi
|
||||
|
||||
# Some zsh users manually run `source ~/.zshrc` in order to apply rc file
|
||||
# changes to the current shell. This is a terrible practice that breaks many
|
||||
# things, including our shell integration. For example, Oh My Zsh and Prezto
|
||||
|
@ -177,10 +177,28 @@ pub fn setupFeatures(
|
||||
};
|
||||
var buffer = try std.BoundedArray(u8, capacity).init(0);
|
||||
|
||||
inline for (fields) |field| {
|
||||
if (@field(features, field.name)) {
|
||||
// Sort the fields so that the output is deterministic. This is
|
||||
// done at comptime so it has no runtime cost
|
||||
const fields_sorted: [fields.len][]const u8 = comptime fields: {
|
||||
var fields_sorted: [fields.len][]const u8 = undefined;
|
||||
for (fields, 0..) |field, i| fields_sorted[i] = field.name;
|
||||
std.mem.sortUnstable(
|
||||
[]const u8,
|
||||
&fields_sorted,
|
||||
{},
|
||||
(struct {
|
||||
fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
|
||||
return std.ascii.orderIgnoreCase(lhs, rhs) == .lt;
|
||||
}
|
||||
}).lessThan,
|
||||
);
|
||||
break :fields fields_sorted;
|
||||
};
|
||||
|
||||
inline for (fields_sorted) |name| {
|
||||
if (@field(features, name)) {
|
||||
if (buffer.len > 0) try buffer.append(',');
|
||||
try buffer.appendSlice(field.name);
|
||||
try buffer.appendSlice(name);
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,8 +219,8 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true });
|
||||
try testing.expectEqualStrings("cursor,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true });
|
||||
try testing.expectEqualStrings("cursor,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
|
||||
// Test: all features disabled
|
||||
@ -210,7 +228,7 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false });
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false });
|
||||
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
|
||||
}
|
||||
|
||||
@ -219,8 +237,8 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false });
|
||||
try testing.expectEqualStrings("sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false });
|
||||
try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user