diff --git a/src/cli/action.zig b/src/cli/action.zig index 9d1fad027..728f36efe 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,8 +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 list_ssh_cache = @import("list_ssh_cache.zig"); -const clear_ssh_cache = @import("clear_ssh_cache.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"); @@ -43,11 +42,8 @@ pub const Action = enum { /// List keybind actions @"list-actions", - /// List hosts cached by SSH shell integration for terminfo installation - @"list-ssh-cache", - - /// Clear hosts cached by SSH shell integration for terminfo installation - @"clear-ssh-cache", + /// Manage SSH terminfo cache for automatic remote host setup + @"ssh-cache", /// Edit the config file in the configured terminal editor. @"edit-config", @@ -163,8 +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), - .@"list-ssh-cache" => try list_ssh_cache.run(alloc), - .@"clear-ssh-cache" => try clear_ssh_cache.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), @@ -202,8 +197,7 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, - .@"list-ssh-cache" => list_ssh_cache.Options, - .@"clear-ssh-cache" => clear_ssh_cache.Options, + .@"ssh-cache" => ssh_cache.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, diff --git a/src/cli/clear_ssh_cache.zig b/src/cli/clear_ssh_cache.zig deleted file mode 100644 index 062af4221..000000000 --- a/src/cli/clear_ssh_cache.zig +++ /dev/null @@ -1,40 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const args = @import("args.zig"); -const Action = @import("action.zig").Action; -const ssh_cache = @import("ssh_cache.zig"); - -pub const Options = struct { - pub fn deinit(self: Options) void { - _ = self; - } - - /// Enables `-h` and `--help` to work. - pub fn help(self: Options) !void { - _ = self; - return Action.help_error; - } -}; - -/// Clear the Ghostty SSH terminfo cache. -/// -/// This command removes the cache of hosts where Ghostty's terminfo has been installed -/// via the ssh-terminfo shell integration feature. After clearing, terminfo will be -/// reinstalled on the next SSH connection to previously cached hosts. -/// -/// Use this if you need to force reinstallation of terminfo or clean up old entries. -pub fn run(alloc: Allocator) !u8 { - var opts: Options = .{}; - defer opts.deinit(); - - { - var iter = try args.argsIterator(alloc); - defer iter.deinit(); - try args.parse(Options, alloc, &opts, &iter); - } - - const stdout = std.io.getStdOut().writer(); - try ssh_cache.clearCache(alloc, stdout); - - return 0; -} diff --git a/src/cli/list_ssh_cache.zig b/src/cli/list_ssh_cache.zig deleted file mode 100644 index b4328201d..000000000 --- a/src/cli/list_ssh_cache.zig +++ /dev/null @@ -1,41 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const args = @import("args.zig"); -const Action = @import("action.zig").Action; -const ssh_cache = @import("ssh_cache.zig"); - -pub const Options = struct { - pub fn deinit(self: Options) void { - _ = self; - } - - /// Enables `-h` and `--help` to work. - pub fn help(self: Options) !void { - _ = self; - return Action.help_error; - } -}; - -/// List hosts with Ghostty SSH terminfo installed via the ssh-terminfo shell integration feature. -/// -/// This command shows all remote hosts where Ghostty's terminfo has been successfully -/// installed through the SSH integration. The cache is automatically maintained when -/// connecting to remote hosts with `shell-integration-features = ssh-terminfo` enabled. -/// -/// Use `+clear-ssh-cache` to remove cached entries if you need to force terminfo -/// reinstallation or clean up stale host entries. -pub fn run(alloc: Allocator) !u8 { - var opts: Options = .{}; - defer opts.deinit(); - - { - var iter = try args.argsIterator(alloc); - defer iter.deinit(); - try args.parse(Options, alloc, &opts, &iter); - } - - const stdout = std.io.getStdOut().writer(); - try ssh_cache.listCachedHosts(alloc, stdout); - - return 0; -} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 02462816c..71c47a7a7 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -1,51 +1,754 @@ const std = @import("std"); +const fs = std.fs; const Allocator = std.mem.Allocator; -const Child = std.process.Child; +const xdg = @import("../os/xdg.zig"); +const args = @import("args.zig"); +const Action = @import("action.zig").Action; -/// Get the path to the shared cache script -fn getCacheScriptPath(alloc: Allocator) ![]u8 { - // Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path - const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch { - // Fallback: assume we're running from build directory - return try alloc.dupe(u8, "src"); - }; - defer alloc.free(resources_dir); +pub const CacheError = error{ + InvalidCacheKey, + CacheLocked, +} || fs.File.OpenError || fs.File.WriteError || Allocator.Error; - return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-ssh-cache" }); +const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries +const NEVER_EXPIRE = 0; +const SECONDS_PER_DAY = 86400; + +pub const Options = struct { + clear: bool = false, + add: ?[]const u8 = null, + remove: ?[]const u8 = null, + host: ?[]const u8 = null, + @"expire-days": u32 = NEVER_EXPIRE, + + pub fn deinit(self: *Options) void { + _ = self; + } + + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +const CacheEntry = struct { + hostname: []const u8, + timestamp: i64, + terminfo_version: []const u8, + + fn parse(line: []const u8) ?CacheEntry { + 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 CacheEntry{ + .hostname = hostname, + .timestamp = timestamp, + .terminfo_version = terminfo_version, + }; + } + + fn format(self: CacheEntry, writer: anytype) !void { + try writer.print("{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }); + } + + fn isExpired(self: CacheEntry, expire_days: u32) bool { + if (expire_days == NEVER_EXPIRE) return false; + const now = std.time.timestamp(); + const age_days = @divTrunc(now - self.timestamp, SECONDS_PER_DAY); + return age_days > expire_days; + } +}; + +const AddResult = enum { + added, + updated, +}; + +fn getCachePath(allocator: Allocator) ![]const u8 { + const state_dir = try xdg.state(allocator, .{ .subdir = "ghostty" }); + defer allocator.free(state_dir); + return try std.fs.path.join(allocator, &.{ state_dir, "ssh_cache" }); } -/// Generic function to run cache script commands -fn runCacheCommand(alloc: Allocator, writer: anytype, command: []const u8) !void { - const script_path = try getCacheScriptPath(alloc); - defer alloc.free(script_path); +// Supports both standalone hostnames and user@hostname format +fn isValidCacheKey(key: []const u8) bool { + if (key.len == 0 or key.len > 320) return false; // 253 + 1 + 64 for user@hostname - var child = Child.init(&[_][]const u8{ script_path, command }, alloc); - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Pipe; + // 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); + } - try child.spawn(); + return isValidHostname(key); +} - const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stdout); +// 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; - const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize)); - defer alloc.free(stderr); + // 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', ':' => { + if (c == ':') has_colon = true; + }, + else => return false, + } + } + return has_colon; + } - _ = try child.wait(); + // Standard hostname/domain validation + for (host) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, + else => return false, + } + } - // Output the results regardless of exit code - try writer.writeAll(stdout); - if (stderr.len > 0) { - try writer.writeAll(stderr); + // 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; +} + +fn acquireFileLock(file: fs.File) CacheError!void { + _ = file.tryLock(.exclusive) catch { + return CacheError.CacheLocked; + }; +} + +fn readCacheFile( + alloc: Allocator, + path: []const u8, + entries: *std.StringHashMap(CacheEntry), +) !void { + const file = fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + + const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); + defer alloc.free(content); + + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + + if (CacheEntry.parse(trimmed)) |entry| { + // Always allocate hostname first to avoid key pointer confusion + const hostname_copy = try alloc.dupe(u8, entry.hostname); + errdefer alloc.free(hostname_copy); + + const gop = try entries.getOrPut(hostname_copy); + if (!gop.found_existing) { + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.* = CacheEntry{ + .hostname = hostname_copy, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Don't need the copy since entry already exists + alloc.free(hostname_copy); + + // 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; + } + } + } + } } } -/// List cached hosts by calling the external script -pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void { - try runCacheCommand(alloc, writer, "list"); +// Atomic write via temp file + rename, filters out expired entries +fn writeCacheFile( + alloc: Allocator, + path: []const u8, + entries: *const std.StringHashMap(CacheEntry), + expire_days: u32, +) !void { + // Ensure parent directory exists + const dir = std.fs.path.dirname(path).?; + fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + // Write to temp file first + const tmp_path = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); + defer alloc.free(tmp_path); + + const tmp_file = try fs.createFileAbsolute(tmp_path, .{ .mode = 0o600 }); + defer tmp_file.close(); + errdefer fs.deleteFileAbsolute(tmp_path) catch {}; + + const writer = tmp_file.writer(); + + // Only write non-expired entries + var iter = entries.iterator(); + while (iter.next()) |kv| { + if (!kv.value_ptr.isExpired(expire_days)) { + try kv.value_ptr.format(writer); + } + } + + // Atomic replace + try fs.renameAbsolute(tmp_path, path); } -/// Clear cache by calling the external script -pub fn clearCache(alloc: Allocator, writer: anytype) !void { - try runCacheCommand(alloc, writer, "clear"); +fn checkHost(alloc: Allocator, host: []const u8) !bool { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + return entries.contains(host); } + +fn addHost(alloc: Allocator, host: []const u8) !AddResult { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + // Create cache directory if needed + const dir = std.fs.path.dirname(path).?; + fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + // Open or create cache file with secure permissions + const file = fs.createFileAbsolute(path, .{ + .read = true, + .truncate = false, + .mode = 0o600, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |open_err| { + return open_err; + }; + + // Verify and fix permissions on existing file + const stat = existing_file.stat() catch |stat_err| { + existing_file.close(); + return stat_err; + }; + + // Ensure file has correct permissions (readable/writable by owner only) + if (stat.mode & 0o777 != 0o600) { + existing_file.chmod(0o600) catch |chmod_err| { + existing_file.close(); + return chmod_err; + }; + } + + break :blk existing_file; + }, + else => return err, + }; + defer file.close(); + + try acquireFileLock(file); + defer file.unlock(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + + // Add or update entry + const gop = try entries.getOrPut(host); + const result = if (!gop.found_existing) blk: { + gop.key_ptr.* = try alloc.dupe(u8, host); + gop.value_ptr.* = .{ + .hostname = gop.key_ptr.*, + .timestamp = std.time.timestamp(), + .terminfo_version = "xterm-ghostty", + }; + break :blk AddResult.added; + } else blk: { + // Update timestamp for existing entry + gop.value_ptr.timestamp = std.time.timestamp(); + break :blk AddResult.updated; + }; + + try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); + return result; +} + +fn removeHost(alloc: Allocator, host: []const u8) !void { + if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; + + const path = try getCachePath(alloc); + + const file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + + try acquireFileLock(file); + defer file.unlock(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + try readCacheFile(alloc, path, &entries); + + _ = entries.fetchRemove(host); + + try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); +} + +fn listHosts(alloc: Allocator, writer: anytype) !void { + const path = try getCachePath(alloc); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + + readCacheFile(alloc, path, &entries) catch |err| switch (err) { + error.FileNotFound => { + try writer.print("No hosts in cache.\n", .{}); + return; + }, + else => return err, + }; + + if (entries.count() == 0) { + try writer.print("No hosts in cache.\n", .{}); + return; + } + + // Sort entries by hostname for consistent output + var items = std.ArrayList(CacheEntry).init(alloc); + defer items.deinit(); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try items.append(kv.value_ptr.*); + } + + std.mem.sort(CacheEntry, items.items, {}, struct { + fn lessThan(_: void, a: CacheEntry, b: CacheEntry) 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, SECONDS_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 }); + } + } +} + +fn clearCache(alloc: Allocator) !void { + const path = try getCachePath(alloc); + + fs.deleteFileAbsolute(path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +/// 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. +/// +/// 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(); + + if (opts.clear) { + try clearCache(alloc); + try stdout.print("Cache cleared.\n", .{}); + return 0; + } + + if (opts.add) |host| { + const result = addHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); + 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| { + removeHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + CacheError.CacheLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); + return 1; + }, + }; + try stdout.print("Removed '{s}' from cache.\n", .{host}); + return 0; + } + + if (opts.host) |host| { + const cached = checkHost(alloc, host) catch |err| switch (err) { + CacheError.InvalidCacheKey => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + error.AccessDenied, error.PermissionDenied => { + try stderr.print("Error: Permission denied\n", .{}); + return 1; + }, + else => { + try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); + 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 + try listHosts(alloc, stdout); + return 0; +} + +// 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]") == false); // Interface notation not supported + try testing.expect(isValidHostname("[]") == false); // Empty IPv6 + try testing.expect(isValidHostname("[invalid]") == false); // 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")); +} + +test "cache entry expiration" { + const testing = std.testing; + const now = std.time.timestamp(); + + const fresh_entry = CacheEntry{ + .hostname = "test.com", + .timestamp = now - SECONDS_PER_DAY, // 1 day old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!fresh_entry.isExpired(90)); + + const old_entry = CacheEntry{ + .hostname = "old.com", + .timestamp = now - (SECONDS_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(NEVER_EXPIRE)); +} + +test "cache entry expiration - boundary cases" { + const testing = std.testing; + const now = std.time.timestamp(); + + // Exactly at expiration boundary + const boundary_entry = CacheEntry{ + .hostname = "boundary.com", + .timestamp = now - (SECONDS_PER_DAY * 30), // Exactly 30 days old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired + try testing.expect(boundary_entry.isExpired(29)); // Should be expired +} + +test "cache entry parsing - valid formats" { + const testing = std.testing; + + const entry = CacheEntry.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 = CacheEntry.parse("test.com|1640995200").?; + try testing.expectEqualStrings("xterm-ghostty", entry_no_version.terminfo_version); + + // Test complex hostnames + const complex_entry = CacheEntry.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(CacheEntry.parse("") == null); + try testing.expect(CacheEntry.parse("v1") == null); // Invalid format (no pipe) + try testing.expect(CacheEntry.parse("example.com") == null); // Missing timestamp + try testing.expect(CacheEntry.parse("example.com|invalid") == null); // Invalid timestamp + try testing.expect(CacheEntry.parse("example.com|1640995200|") != null); // Empty terminfo should default +} + +test "cache entry parsing - malformed data resilience" { + const testing = std.testing; + + // Extra pipes should not break parsing + try testing.expect(CacheEntry.parse("host|123|term|extra") != null); + + // Whitespace handling + try testing.expect(CacheEntry.parse(" host|123|term ") != null); + try testing.expect(CacheEntry.parse("\n") == null); + try testing.expect(CacheEntry.parse(" \t \n") == null); +} + +test "duplicate cache entries - memory management" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var entries = std.StringHashMap(CacheEntry).init(alloc); + defer entries.deinit(); + + // Simulate reading a cache file with duplicate hostnames + const cache_content = "example.com|1640995200|xterm-ghostty\nexample.com|1640995300|xterm-ghostty-v2\n"; + + var lines = std.mem.tokenizeScalar(u8, cache_content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (CacheEntry.parse(trimmed)) |entry| { + const gop = try entries.getOrPut(entry.hostname); + if (!gop.found_existing) { + const hostname_copy = try alloc.dupe(u8, entry.hostname); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.key_ptr.* = hostname_copy; + gop.value_ptr.* = CacheEntry{ + .hostname = hostname_copy, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Test the duplicate handling logic + 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; + } + } + } + } + } + + // Verify only one entry exists with the newer timestamp + try testing.expect(entries.count() == 1); + const entry = entries.get("example.com").?; + try testing.expectEqual(@as(i64, 1640995300), entry.timestamp); + try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version); +} + +test "concurrent access simulation - file locking" { + const testing = std.testing; + + // This test simulates the file locking mechanism + // In practice, this would require actual file operations + // but we can test the error handling logic + + const TestError = error{CacheLocked}; + + const result = TestError.CacheLocked; + try testing.expectError(TestError.CacheLocked, result); +} + diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index b51dae9c7..8c4cd9e12 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -97,73 +97,172 @@ fi # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" + : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - fi - - # SSH wrapper + # SSH wrapper that preserves Ghostty features across remote connections ssh() { - local env=() opts=() ctrl=() + local ssh_env=() ssh_opts=() - # Set up env vars first so terminfo installation inherits them + # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local vars=( - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} + local -a ssh_env_vars=( + "COLORTERM=truecolor" + "TERM_PROGRAM=ghostty" ) - for v in "${vars[@]}"; do - builtin export "${v?}" - opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + if [[ -n "$TERM_PROGRAM_VERSION" ]]; then + ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") + fi + + # Temporarily export variables for SSH transmission + local -a ssh_exported_vars=() + for ssh_v in "${ssh_env_vars[@]}"; do + local ssh_var_name="${ssh_v%%=*}" + + if [[ -n "${!ssh_var_name+x}" ]]; then + ssh_exported_vars+=("$ssh_var_name=${!ssh_var_name}") + else + ssh_exported_vars+=("$ssh_var_name") + fi + + builtin export "${ssh_v?}" + + # Use both SendEnv and SetEnv for maximum compatibility + ssh_opts+=(-o "SendEnv $ssh_var_name") + ssh_opts+=(-o "SetEnv $ssh_v") done + + ssh_env+=("${ssh_env_vars[@]}") fi - # Install terminfo if needed, reuse control connection for main session + # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - # Get target (only when needed for terminfo) - builtin local target - target=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - - if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then - env+=(TERM=xterm-ghostty) - elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local tinfo - tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n "$tinfo" ]]; then - builtin echo "Setting up Ghostty terminfo on remote host..." >&2 - builtin local cpath - cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(builtin echo "$tinfo" | builtin command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') in - OK) - builtin echo "Terminfo setup complete." >&2 - [[ -n "$target" ]] && "$_CACHE" add "$target" - env+=(TERM=xterm-ghostty) - ctrl+=(-o "ControlPath=$cpath") - ;; - *) builtin echo "Warning: Failed to install terminfo." >&2 ;; - esac + builtin local ssh_config ssh_user ssh_hostname + ssh_config=$(builtin command ssh -G "$@" 2>/dev/null) + ssh_user=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "ssh_user" ]] && echo "$ssh_value" && break + done) + ssh_hostname=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "hostname" ]] && echo "$ssh_value" && break + done) + ssh_target="${ssh_user}@${ssh_hostname}" + + if [[ -n "$ssh_hostname" ]]; then + # Detect timeout command (BSD compatibility) + local ssh_timeout_cmd="" + if command -v timeout >/dev/null 2>&1; then + ssh_timeout_cmd="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + ssh_timeout_cmd="gtimeout" + fi + + # Check if terminfo is already cached + local ssh_cache_check_success=false + if command -v ghostty >/dev/null 2>&1; then + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + else + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + fi + fi + + if [[ "$ssh_cache_check_success" == "true" ]]; then + ssh_env+=(TERM=xterm-ghostty) + elif builtin command -v infocmp >/dev/null 2>&1; then + builtin local ssh_terminfo + + # Generate terminfo data (BSD base64 compatibility) + if base64 --help 2>&1 | grep -q GNU; then + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + fi + + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up Ghostty terminfo on remote host..." >&2 + builtin local ssh_cpath_dir ssh_cpath + + 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" + + local ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU; then + ssh_base64_decode_cmd="base64 -d" + else + ssh_base64_decode_cmd="base64 -D" + fi + + if builtin echo "$ssh_terminfo" | $ssh_base64_decode_cmd | builtin 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 + builtin echo "Terminfo setup complete." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && command -v ghostty >/dev/null 2>&1; then + ( + set +m + { + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + fi + } & + ) + fi + else + builtin echo "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + builtin echo "Warning: Could not generate terminfo data." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + builtin echo "Warning: ghostty command not available for cache management." >&2 + ssh_env+=(TERM=xterm-256color) fi else - builtin echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + ssh_env+=(TERM=xterm-256color) + fi fi fi - # Fallback TERM only if terminfo didn't set it + # Ensure TERM is set when using ssh-env feature if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) + local ssh_term_set=false + for ssh_v in "${ssh_env[@]}"; do + if [[ "$ssh_v" =~ ^TERM= ]]; then + ssh_term_set=true + break + fi + done + if [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]]; then + ssh_env+=(TERM=xterm-256color) + fi fi - # Execute - if [[ ${#env[@]} -gt 0 ]]; then - env "${env[@]}" ssh "${opts[@]}" "${ctrl[@]}" "$@" - else - builtin command ssh "${opts[@]}" "${ctrl[@]}" "$@" + builtin command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + + # Restore original environment variables + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + for ssh_v in "${ssh_exported_vars[@]}"; do + if [[ "$ssh_v" == *=* ]]; then + builtin export "${ssh_v?}" + else + builtin unset "${ssh_v}" + fi + done fi + + return $ssh_ret } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 084861434..76fa7bafa 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -100,91 +100,238 @@ # SSH Integration use str + use re - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + if (or (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo)) { + var GHOSTTY_SSH_CACHE_TIMEOUT = (if (has-env GHOSTTY_SSH_CACHE_TIMEOUT) { echo $E:GHOSTTY_SSH_CACHE_TIMEOUT } else { echo 5 }) + var GHOSTTY_SSH_CHECK_TIMEOUT = (if (has-env GHOSTTY_SSH_CHECK_TIMEOUT) { echo $E:GHOSTTY_SSH_CHECK_TIMEOUT } else { echo 3 }) - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - var _CACHE = $E:GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache - } + # SSH wrapper that preserves Ghostty features across remote connections + fn ssh {|@args| + var ssh-env = [] + var ssh-opts = [] - # SSH wrapper - fn ssh {|@args| - var env = [] - var opts = [] - var ctrl = [] + # Configure environment variables for remote session + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + var ssh-env-vars = [ + COLORTERM=truecolor + TERM_PROGRAM=ghostty + ] - # Set up env vars first so terminfo installation inherits them - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - var vars = [ - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ] - if (not-eq $E:TERM_PROGRAM_VERSION '') { - set vars = [$@vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] - } + if (has-env TERM_PROGRAM_VERSION) { + set ssh-env-vars = [$@ssh-env-vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION] + } - for v $vars { - set-env (str:split = $v | take 1) (str:split = $v | drop 1 | str:join =) - var varname = (str:split = $v | take 1) - set opts = [$@opts -o 'SendEnv '$varname -o 'SetEnv '$v] - } - } - - # Install terminfo if needed, reuse control connection for main session - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - # Get target - var target = '' - try { - set target = (e:ssh -G $@args 2>/dev/null | e:awk '/^(user|hostname) /{print $2}' | e:paste -sd'@') - } catch { } - - if (and (not-eq $target '') ($_CACHE chk $target)) { - set env = [$@env TERM=xterm-ghostty] - } elif (has-external infocmp) { - var tinfo = '' - try { - set tinfo = (e:infocmp -x xterm-ghostty 2>/dev/null) - } catch { - echo "Warning: xterm-ghostty terminfo not found locally." >&2 - } - - if (not-eq $tinfo '') { - echo "Setting up Ghostty terminfo on remote host..." >&2 - var cpath = '/tmp/ghostty-ssh-'$E:USER'-'(randint 0 32767)'-'(date +%s) - var result = (echo $tinfo | e:ssh $@opts -o ControlMaster=yes -o ControlPath=$cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') + # Store original values for restoration + var ssh-exported-vars = [] + for ssh-v $ssh-env-vars { + var ssh-var-name = (str:split &max=2 = $ssh-v)[0] - if (eq $result OK) { - echo "Terminfo setup complete." >&2 - if (not-eq $target '') { $_CACHE add $target } - set env = [$@env TERM=xterm-ghostty] - set ctrl = [$@ctrl -o ControlPath=$cpath] - } else { - echo "Warning: Failed to install terminfo." >&2 - } + if (has-env $ssh-var-name) { + var original-value = (get-env $ssh-var-name) + set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name=$original-value] + } else { + set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name] + } + + # Export the variable + var ssh-var-parts = (str:split &max=2 = $ssh-v) + set-env $ssh-var-parts[0] $ssh-var-parts[1] + + # Use both SendEnv and SetEnv for maximum compatibility + set ssh-opts = [$@ssh-opts -o "SendEnv "$ssh-var-name] + set ssh-opts = [$@ssh-opts -o "SetEnv "$ssh-v] + } + + set ssh-env = [$@ssh-env $@ssh-env-vars] } - } else { - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 - } - } - # Fallback TERM only if terminfo didn't set it - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - if (and (eq $E:TERM xterm-ghostty) (not (str:contains (str:join ' ' $env) 'TERM='))) { - set env = [$@env TERM=xterm-256color] - } - } + # Install terminfo on remote host if needed + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { + var ssh-config = "" + try { + set ssh-config = (external ssh -G $@args 2>/dev/null | slurp) + } catch { + set ssh-config = "" + } - # Execute - if (> (count $env) 0) { - e:env $@env e:ssh $@opts $@ctrl $@args - } else { - e:ssh $@opts $@ctrl $@args + var ssh-user = "" + var ssh-hostname = "" + + for line (str:split "\n" $ssh-config) { + var parts = (str:split " " $line) + if (and (> (count $parts) 1) (eq $parts[0] user)) { + set ssh-user = $parts[1] + } + if (and (> (count $parts) 1) (eq $parts[0] hostname)) { + set ssh-hostname = $parts[1] + } + } + + var ssh-target = $ssh-user"@"$ssh-hostname + + if (not-eq $ssh-hostname "") { + # Detect timeout command (BSD compatibility) + var ssh-timeout-cmd = "" + try { + external timeout --help >/dev/null 2>&1 + set ssh-timeout-cmd = timeout + } catch { + try { + external gtimeout --help >/dev/null 2>&1 + set ssh-timeout-cmd = gtimeout + } catch { + # no timeout command available + } + } + + # Check if terminfo is already cached + var ssh-cache-check-success = $false + try { + external ghostty --help >/dev/null 2>&1 + if (not-eq $ssh-timeout-cmd "") { + try { + external $ssh-timeout-cmd $GHOSTTY_SSH_CHECK_TIMEOUT"s" ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 + set ssh-cache-check-success = $true + } catch { + # cache check failed + } + } else { + try { + external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1 + set ssh-cache-check-success = $true + } catch { + # cache check failed + } + } + } catch { + # ghostty not available + } + + if $ssh-cache-check-success { + set ssh-env = [$@ssh-env TERM=xterm-ghostty] + } else { + try { + external infocmp --help >/dev/null 2>&1 + + # Generate terminfo data (BSD base64 compatibility) + var ssh-terminfo = "" + try { + var base64-help = (external base64 --help 2>&1 | slurp) + if (str:contains $base64-help GNU) { + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 -w0 2>/dev/null | slurp) + } else { + set ssh-terminfo = (external infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | external base64 2>/dev/null | external tr -d '\n' | slurp) + } + } catch { + set ssh-terminfo = "" + } + + if (not-eq $ssh-terminfo "") { + echo "Setting up Ghostty terminfo on remote host..." >&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" + + var ssh-base64-decode-cmd = "" + try { + var base64-help = (external base64 --help 2>&1 | slurp) + if (str:contains $base64-help GNU) { + set ssh-base64-decode-cmd = "base64 -d" + } else { + set ssh-base64-decode-cmd = "base64 -D" + } + } catch { + set ssh-base64-decode-cmd = "base64 -d" + } + + var terminfo-install-success = $false + try { + echo $ssh-terminfo | external sh -c $ssh-base64-decode-cmd | 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 + ' >/dev/null 2>&1 + set terminfo-install-success = $true + } catch { + set terminfo-install-success = $false + } + + if $terminfo-install-success { + echo "Terminfo setup complete." >&2 + set ssh-env = [$@ssh-env TERM=xterm-ghostty] + set ssh-opts = [$@ssh-opts -o ControlPath=$ssh-cpath] + + # Cache successful installation + if (and (not-eq $ssh-target "") (has-external ghostty)) { + if (not-eq $ssh-timeout-cmd "") { + external $ssh-timeout-cmd $GHOSTTY_SSH_CACHE_TIMEOUT"s" ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & + } else { + external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 & + } + } + } else { + echo "Warning: Failed to install terminfo." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } else { + echo "Warning: Could not generate terminfo data." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } catch { + echo "Warning: ghostty command not available for cache management." >&2 + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + } else { + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + } + + # Ensure TERM is set when using ssh-env feature + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + var ssh-term-set = $false + for ssh-v $ssh-env { + if (str:has-prefix $ssh-v TERM=) { + set ssh-term-set = $true + break + } + } + if (and (not $ssh-term-set) (eq $E:TERM xterm-ghostty)) { + set ssh-env = [$@ssh-env TERM=xterm-256color] + } + } + + var ssh-ret = 0 + try { + external ssh $@ssh-opts $@args + } catch e { + set ssh-ret = $e[reason][exit-status] + } + + # Restore original environment variables + if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { + for ssh-v $ssh-exported-vars { + if (str:contains $ssh-v =) { + var ssh-var-parts = (str:split &max=2 = $ssh-v) + set-env $ssh-var-parts[0] $ssh-var-parts[1] + } else { + unset-env $ssh-v + } + } + } + + if (not-eq $ssh-ret 0) { + fail ssh-failed + } } - } } defer { diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 4c780b5a7..7dc121919 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -86,88 +86,173 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - # SSH Integration - if string match -qr 'ssh-(env|terminfo)' $GHOSTTY_SHELL_FEATURES + # SSH Integration for Fish Shell + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"; or string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + set -g GHOSTTY_SSH_CACHE_TIMEOUT (test -n "$GHOSTTY_SSH_CACHE_TIMEOUT"; and echo $GHOSTTY_SSH_CACHE_TIMEOUT; or echo 5) + set -g GHOSTTY_SSH_CHECK_TIMEOUT (test -n "$GHOSTTY_SSH_CHECK_TIMEOUT"; and echo $GHOSTTY_SSH_CHECK_TIMEOUT; or echo 3) - if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES - set -g _CACHE "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache" - end - - # SSH wrapper - function ssh - set -l env - set -l opts - set -l ctrl - - # Set up env vars first so terminfo installation inherits them - if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES - set -l vars \ - COLORTERM=truecolor \ - TERM_PROGRAM=ghostty + # SSH wrapper that preserves Ghostty features across remote connections + function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" + set -l ssh_env + set -l ssh_opts + # Configure environment variables for remote session + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_env_vars \ + "COLORTERM=truecolor" \ + "TERM_PROGRAM=ghostty" + if test -n "$TERM_PROGRAM_VERSION" - set -a vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" + set -a ssh_env_vars "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION" end - for v in $vars - set -l parts (string split = $v) - set -gx $parts[1] $parts[2] - set -a opts -o "SendEnv $parts[1]" -o "SetEnv $v" - end - end - - # Install terminfo if needed, reuse control connection for main session - if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES - # Get target - set -l target (command ssh -G $argv 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') - - if test -n "$target" -a ("$_CACHE" chk "$target") - set -a env TERM=xterm-ghostty - else if command -v infocmp >/dev/null 2>&1 - set -l tinfo (infocmp -x xterm-ghostty 2>/dev/null) - set -l status_code $status - - if test $status_code -ne 0 - echo "Warning: xterm-ghostty terminfo not found locally." >&2 + # Store original values for restoration + set -l ssh_exported_vars + for ssh_v in $ssh_env_vars + set -l ssh_var_name (string split -m1 '=' -- $ssh_v)[1] + + if set -q $ssh_var_name + set -a ssh_exported_vars "$ssh_var_name="(eval echo \$$ssh_var_name) + else + set -a ssh_exported_vars $ssh_var_name end - if test -n "$tinfo" - echo "Setting up Ghostty terminfo on remote host..." >&2 - set -l cpath "/tmp/ghostty-ssh-$USER-"(random)"-"(date +%s) - set -l result (echo "$tinfo" | command ssh $opts -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') + # Export the variable + set -gx (string split -m1 '=' -- $ssh_v) - switch $result - case OK - echo "Terminfo setup complete." >&2 - test -n "$target" && "$_CACHE" add "$target" - set -a env TERM=xterm-ghostty - set -a ctrl -o "ControlPath=$cpath" - case '*' - echo "Warning: Failed to install terminfo." >&2 + # Use both SendEnv and SetEnv for maximum compatibility + set -a ssh_opts -o "SendEnv $ssh_var_name" + set -a ssh_opts -o "SetEnv $ssh_v" + end + + set -a ssh_env $ssh_env_vars + end + + # Install terminfo on remote host if needed + if string match -q '*ssh-terminfo*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_config (command ssh -G $argv 2>/dev/null) + set -l ssh_user (echo $ssh_config | while read -l ssh_key ssh_value + test "$ssh_key" = "user"; and echo $ssh_value; and break + end) + set -l ssh_hostname (echo $ssh_config | while read -l ssh_key ssh_value + test "$ssh_key" = "hostname"; and echo $ssh_value; and break + end) + set -l ssh_target "$ssh_user@$ssh_hostname" + + if test -n "$ssh_hostname" + # Detect timeout command (BSD compatibility) + set -l ssh_timeout_cmd + if command -v timeout >/dev/null 2>&1 + set ssh_timeout_cmd timeout + else if command -v gtimeout >/dev/null 2>&1 + set ssh_timeout_cmd gtimeout + end + + # Check if terminfo is already cached + set -l ssh_cache_check_success false + if command -v ghostty >/dev/null 2>&1 + if test -n "$ssh_timeout_cmd" + if $ssh_timeout_cmd "$GHOSTTY_SSH_CHECK_TIMEOUT"s ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_cache_check_success true + end + else + if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_cache_check_success true + end end end + + if test "$ssh_cache_check_success" = "true" + set -a ssh_env TERM=xterm-ghostty + else if command -v infocmp >/dev/null 2>&1 + # Generate terminfo data (BSD base64 compatibility) + set -l ssh_terminfo + if base64 --help 2>&1 | grep -q GNU + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + set ssh_terminfo (infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + end + + if test -n "$ssh_terminfo" + echo "Setting up Ghostty terminfo on remote host..." >&2 + set -l ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set -l ssh_cpath "$ssh_cpath_dir/socket" + + set -l ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU + set ssh_base64_decode_cmd "base64 -d" + else + set ssh_base64_decode_cmd "base64 -D" + end + + if echo "$ssh_terminfo" | eval $ssh_base64_decode_cmd | 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 + echo "Terminfo setup complete." >&2 + set -a ssh_env 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 + fish -c " + if test -n '$ssh_timeout_cmd' + $ssh_timeout_cmd '$GHOSTTY_SSH_CACHE_TIMEOUT's ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true + else + ghostty +ssh-cache --add='$ssh_target' >/dev/null 2>&1; or true + end + " & + end + else + echo "Warning: Failed to install terminfo." >&2 + set -a ssh_env TERM=xterm-256color + end + else + echo "Warning: Could not generate terminfo data." >&2 + set -a ssh_env TERM=xterm-256color + end + else + echo "Warning: ghostty command not available for cache management." >&2 + set -a ssh_env TERM=xterm-256color + end else - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -a ssh_env TERM=xterm-256color + end end end - # Fallback TERM only if terminfo didn't set it - if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES - if test "$TERM" = xterm-ghostty -a ! (string join ' ' $env | string match -q '*TERM=*') - set -a env TERM=xterm-256color + # Ensure TERM is set when using ssh-env feature + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + set -l ssh_term_set false + for ssh_v in $ssh_env + if string match -q 'TERM=*' -- $ssh_v + set ssh_term_set true + break + end + end + if test "$ssh_term_set" = "false"; and test "$TERM" = "xterm-ghostty" + set -a ssh_env TERM=xterm-256color end end - # Execute - if test (count $env) -gt 0 - env $env command ssh $opts $ctrl $argv - else - command ssh $opts $ctrl $argv + command ssh $ssh_opts $argv + set -l ssh_ret $status + + # Restore original environment variables + if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES" + for ssh_v in $ssh_exported_vars + if string match -q '*=*' -- $ssh_v + set -gx (string split -m1 '=' -- $ssh_v) + else + set -e $ssh_v + end + end end + + return $ssh_ret end end diff --git a/src/shell-integration/shared/ghostty-ssh-cache b/src/shell-integration/shared/ghostty-ssh-cache deleted file mode 100755 index e0a6d8452..000000000 --- a/src/shell-integration/shared/ghostty-ssh-cache +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Minimal Ghostty SSH terminfo host cache - -readonly CACHE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/ghostty/terminfo_hosts" - -case "${1:-}" in - chk) [[ -f "$CACHE_FILE" ]] && grep -qFx "$2" "$CACHE_FILE" 2>/dev/null ;; - add) mkdir -p "${CACHE_FILE%/*}"; { [[ -f "$CACHE_FILE" ]] && cat "$CACHE_FILE"; echo "$2"; } | sort -u > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" && chmod 600 "$CACHE_FILE" ;; - list) [[ -s "$CACHE_FILE" ]] && echo "Hosts with Ghostty terminfo installed:" && cat "$CACHE_FILE" || echo "No cached hosts found." ;; - clear) rm -f "$CACHE_FILE" 2>/dev/null && echo "Ghostty SSH terminfo cache cleared." || echo "No Ghostty SSH terminfo cache found." ;; -esac diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 48f8cc934..9f78e9a89 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -245,78 +245,166 @@ _ghostty_deferred_init() { fi # SSH Integration - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then + if [[ "$GHOSTTY_SHELL_FEATURES" =~ (ssh-env|ssh-terminfo) ]]; then + : "${GHOSTTY_SSH_CACHE_TIMEOUT:=5}" + : "${GHOSTTY_SSH_CHECK_TIMEOUT:=3}" - if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache" - fi - - # SSH wrapper + # SSH wrapper that preserves Ghostty features across remote connections ssh() { - local -a env opts ctrl - env=() - opts=() - ctrl=() + emulate -L zsh + setopt local_options no_glob_subst + + local -a ssh_env ssh_opts - # Set up env vars first so terminfo installation inherits them + # Configure environment variables for remote session if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - local -a vars - vars=( - COLORTERM=truecolor - TERM_PROGRAM=ghostty - ${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION} + local -a ssh_env_vars=( + "COLORTERM=truecolor" + "TERM_PROGRAM=ghostty" ) - for v in "${vars[@]}"; do - export "${v?}" - opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v") + [[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION") + + # Temporarily export variables for SSH transmission + local -a ssh_exported_vars=() + local ssh_v ssh_var_name + for ssh_v in "${ssh_env_vars[@]}"; do + ssh_var_name="${ssh_v%%=*}" + + if [[ -n "${(P)ssh_var_name+x}" ]]; then + ssh_exported_vars+=("$ssh_var_name=${(P)ssh_var_name}") + else + ssh_exported_vars+=("$ssh_var_name") + fi + + export "${ssh_v}" + + # Use both SendEnv and SetEnv for maximum compatibility + ssh_opts+=(-o "SendEnv $ssh_var_name") + ssh_opts+=(-o "SetEnv $ssh_v") done + + ssh_env+=("${ssh_env_vars[@]}") fi - # Install terminfo if needed, reuse control connection for main session + # Install terminfo on remote host if needed if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then - # Get target (only when needed for terminfo) - local target - target=$(command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@') + local ssh_config ssh_user ssh_hostname ssh_target + ssh_config=$(command ssh -G "$@" 2>/dev/null) + ssh_user=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "user" ]] && printf '%s\n' "$ssh_value" && break + done) + ssh_hostname=$(printf '%s\n' "${(@f)ssh_config}" | while IFS=' ' read -r ssh_key ssh_value; do + [[ "$ssh_key" == "hostname" ]] && printf '%s\n' "$ssh_value" && break + done) + ssh_target="${ssh_user}@${ssh_hostname}" - if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then - env+=(TERM=xterm-ghostty) - elif command -v infocmp >/dev/null 2>&1; then - local tinfo - tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || echo "Warning: xterm-ghostty terminfo not found locally." >&2 - if [[ -n "$tinfo" ]]; then - echo "Setting up Ghostty terminfo on remote host..." >&2 - local cpath - cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)" - case $(echo "$tinfo" | command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit - command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; } - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL - ') in - OK) - echo "Terminfo setup complete." >&2 - [[ -n "$target" ]] && "$_CACHE" add "$target" - env+=(TERM=xterm-ghostty) - ctrl+=(-o "ControlPath=$cpath") - ;; - *) echo "Warning: Failed to install terminfo." >&2 ;; - esac + if [[ -n "$ssh_hostname" ]]; then + # Detect timeout command (BSD compatibility) + local ssh_timeout_cmd="" + if (( $+commands[timeout] )); then + ssh_timeout_cmd="timeout" + elif (( $+commands[gtimeout] )); then + ssh_timeout_cmd="gtimeout" + fi + + # Check if terminfo is already cached + local ssh_cache_check_success=false + if (( $+commands[ghostty] )); then + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + else + ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true + fi + fi + + if [[ "$ssh_cache_check_success" == "true" ]]; then + ssh_env+=(TERM=xterm-ghostty) + elif (( $+commands[infocmp] )); then + local ssh_terminfo + + # Generate terminfo data (BSD base64 compatibility) + if base64 --help 2>&1 | grep -q GNU; then + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 -w0 2>/dev/null) + else + ssh_terminfo=$(infocmp -0 -Q2 -q xterm-ghostty 2>/dev/null | base64 2>/dev/null | tr -d '\n') + fi + + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up Ghostty terminfo on remote host..." >&2 + local ssh_cpath_dir ssh_cpath + + 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" + + local ssh_base64_decode_cmd + if base64 --help 2>&1 | grep -q GNU; then + ssh_base64_decode_cmd="base64 -d" + else + ssh_base64_decode_cmd="base64 -D" + fi + + if print "$ssh_terminfo" | $ssh_base64_decode_cmd | 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 + print "Terminfo setup complete." >&2 + ssh_env+=(TERM=xterm-ghostty) + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + if [[ -n "$ssh_target" ]] && (( $+commands[ghostty] )); then + { + if [[ -n "$ssh_timeout_cmd" ]]; then + $ssh_timeout_cmd "${GHOSTTY_SSH_CACHE_TIMEOUT}s" ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + fi + } &! + fi + else + print "Warning: Failed to install terminfo." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + print "Warning: Could not generate terminfo data." >&2 + ssh_env+=(TERM=xterm-256color) + fi + else + print "Warning: ghostty command not available for cache management." >&2 + ssh_env+=(TERM=xterm-256color) fi else - echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2 + [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]] && ssh_env+=(TERM=xterm-256color) fi fi - # Fallback TERM only if terminfo didn't set it + # Ensure TERM is set when using ssh-env feature if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then - [[ $TERM == xterm-ghostty && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color) + local ssh_term_set=false ssh_v + for ssh_v in "${ssh_env[@]}"; do + [[ "$ssh_v" =~ ^TERM= ]] && ssh_term_set=true && break + done + [[ "$ssh_term_set" == "false" && "$TERM" == "xterm-ghostty" ]] && ssh_env+=(TERM=xterm-256color) fi - # Execute - if [[ ${#env[@]} -gt 0 ]]; then - env "${env[@]}" command ssh "${opts[@]}" "${ctrl[@]}" "$@" - else - command ssh "${opts[@]}" "${ctrl[@]}" "$@" + command ssh "${ssh_opts[@]}" "$@" + local ssh_ret=$? + + # Restore original environment variables + if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then + local ssh_v + for ssh_v in "${ssh_exported_vars[@]}"; do + if [[ "$ssh_v" == *=* ]]; then + export "${ssh_v}" + else + unset "${ssh_v}" + fi + done fi + + return $ssh_ret } fi