mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 00:06:09 +03:00
feat(ssh): rewrite SSH cache system in native Zig
- Eliminates standalone bash dependency - Consolidates `+list-ssh-cache` and `+clear-ssh-cache` actions into single `+ssh-cache` action with args - Structured cache format with timestamps and expiration support - Memory-safe entry handling with proper file locking - Comprehensive hostname validation (IPv4/IPv6/domains) - Atomic updates via temp file + rename - Updated shell integrations for improved cross-platform support and reliability - Cache operations are now unit-testable
This commit is contained in:
@ -9,8 +9,7 @@ const list_keybinds = @import("list_keybinds.zig");
|
|||||||
const list_themes = @import("list_themes.zig");
|
const list_themes = @import("list_themes.zig");
|
||||||
const list_colors = @import("list_colors.zig");
|
const list_colors = @import("list_colors.zig");
|
||||||
const list_actions = @import("list_actions.zig");
|
const list_actions = @import("list_actions.zig");
|
||||||
const list_ssh_cache = @import("list_ssh_cache.zig");
|
const ssh_cache = @import("ssh_cache.zig");
|
||||||
const clear_ssh_cache = @import("clear_ssh_cache.zig");
|
|
||||||
const edit_config = @import("edit_config.zig");
|
const edit_config = @import("edit_config.zig");
|
||||||
const show_config = @import("show_config.zig");
|
const show_config = @import("show_config.zig");
|
||||||
const validate_config = @import("validate_config.zig");
|
const validate_config = @import("validate_config.zig");
|
||||||
@ -43,11 +42,8 @@ pub const Action = enum {
|
|||||||
/// List keybind actions
|
/// List keybind actions
|
||||||
@"list-actions",
|
@"list-actions",
|
||||||
|
|
||||||
/// List hosts cached by SSH shell integration for terminfo installation
|
/// Manage SSH terminfo cache for automatic remote host setup
|
||||||
@"list-ssh-cache",
|
@"ssh-cache",
|
||||||
|
|
||||||
/// Clear hosts cached by SSH shell integration for terminfo installation
|
|
||||||
@"clear-ssh-cache",
|
|
||||||
|
|
||||||
/// Edit the config file in the configured terminal editor.
|
/// Edit the config file in the configured terminal editor.
|
||||||
@"edit-config",
|
@"edit-config",
|
||||||
@ -163,8 +159,7 @@ pub const Action = enum {
|
|||||||
.@"list-themes" => try list_themes.run(alloc),
|
.@"list-themes" => try list_themes.run(alloc),
|
||||||
.@"list-colors" => try list_colors.run(alloc),
|
.@"list-colors" => try list_colors.run(alloc),
|
||||||
.@"list-actions" => try list_actions.run(alloc),
|
.@"list-actions" => try list_actions.run(alloc),
|
||||||
.@"list-ssh-cache" => try list_ssh_cache.run(alloc),
|
.@"ssh-cache" => try ssh_cache.run(alloc),
|
||||||
.@"clear-ssh-cache" => try clear_ssh_cache.run(alloc),
|
|
||||||
.@"edit-config" => try edit_config.run(alloc),
|
.@"edit-config" => try edit_config.run(alloc),
|
||||||
.@"show-config" => try show_config.run(alloc),
|
.@"show-config" => try show_config.run(alloc),
|
||||||
.@"validate-config" => try validate_config.run(alloc),
|
.@"validate-config" => try validate_config.run(alloc),
|
||||||
@ -202,8 +197,7 @@ pub const Action = enum {
|
|||||||
.@"list-themes" => list_themes.Options,
|
.@"list-themes" => list_themes.Options,
|
||||||
.@"list-colors" => list_colors.Options,
|
.@"list-colors" => list_colors.Options,
|
||||||
.@"list-actions" => list_actions.Options,
|
.@"list-actions" => list_actions.Options,
|
||||||
.@"list-ssh-cache" => list_ssh_cache.Options,
|
.@"ssh-cache" => ssh_cache.Options,
|
||||||
.@"clear-ssh-cache" => clear_ssh_cache.Options,
|
|
||||||
.@"edit-config" => edit_config.Options,
|
.@"edit-config" => edit_config.Options,
|
||||||
.@"show-config" => show_config.Options,
|
.@"show-config" => show_config.Options,
|
||||||
.@"validate-config" => validate_config.Options,
|
.@"validate-config" => validate_config.Options,
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -1,51 +1,754 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const fs = std.fs;
|
||||||
const Allocator = std.mem.Allocator;
|
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
|
pub const CacheError = error{
|
||||||
fn getCacheScriptPath(alloc: Allocator) ![]u8 {
|
InvalidCacheKey,
|
||||||
// Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path
|
CacheLocked,
|
||||||
const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch {
|
} || fs.File.OpenError || fs.File.WriteError || Allocator.Error;
|
||||||
// Fallback: assume we're running from build directory
|
|
||||||
return try alloc.dupe(u8, "src");
|
|
||||||
};
|
|
||||||
defer alloc.free(resources_dir);
|
|
||||||
|
|
||||||
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
|
// Supports both standalone hostnames and user@hostname format
|
||||||
fn runCacheCommand(alloc: Allocator, writer: anytype, command: []const u8) !void {
|
fn isValidCacheKey(key: []const u8) bool {
|
||||||
const script_path = try getCacheScriptPath(alloc);
|
if (key.len == 0 or key.len > 320) return false; // 253 + 1 + 64 for user@hostname
|
||||||
defer alloc.free(script_path);
|
|
||||||
|
|
||||||
var child = Child.init(&[_][]const u8{ script_path, command }, alloc);
|
// Check for user@hostname format
|
||||||
child.stdout_behavior = .Pipe;
|
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
|
||||||
child.stderr_behavior = .Pipe;
|
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));
|
// Basic hostname validation - accepts domains and IPs (including IPv6 in brackets)
|
||||||
defer alloc.free(stdout);
|
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));
|
// Handle IPv6 addresses in brackets
|
||||||
defer alloc.free(stderr);
|
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
|
// No leading/trailing dots or hyphens, no consecutive dots
|
||||||
try writer.writeAll(stdout);
|
if (host[0] == '.' or host[0] == '-' or
|
||||||
if (stderr.len > 0) {
|
host[host.len - 1] == '.' or host[host.len - 1] == '-')
|
||||||
try writer.writeAll(stderr);
|
{
|
||||||
|
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
|
// Atomic write via temp file + rename, filters out expired entries
|
||||||
pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void {
|
fn writeCacheFile(
|
||||||
try runCacheCommand(alloc, writer, "list");
|
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
|
fn checkHost(alloc: Allocator, host: []const u8) !bool {
|
||||||
pub fn clearCache(alloc: Allocator, writer: anytype) !void {
|
if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey;
|
||||||
try runCacheCommand(alloc, writer, "clear");
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -97,73 +97,172 @@ fi
|
|||||||
|
|
||||||
# SSH Integration
|
# SSH Integration
|
||||||
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-(env|terminfo) ]]; then
|
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
|
# SSH wrapper that preserves Ghostty features across remote connections
|
||||||
readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SSH wrapper
|
|
||||||
ssh() {
|
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
|
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then
|
||||||
local vars=(
|
local -a ssh_env_vars=(
|
||||||
COLORTERM=truecolor
|
"COLORTERM=truecolor"
|
||||||
TERM_PROGRAM=ghostty
|
"TERM_PROGRAM=ghostty"
|
||||||
${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION}
|
|
||||||
)
|
)
|
||||||
for v in "${vars[@]}"; do
|
if [[ -n "$TERM_PROGRAM_VERSION" ]]; then
|
||||||
builtin export "${v?}"
|
ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION")
|
||||||
opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v")
|
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
|
done
|
||||||
|
|
||||||
|
ssh_env+=("${ssh_env_vars[@]}")
|
||||||
fi
|
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
|
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then
|
||||||
# Get target (only when needed for terminfo)
|
builtin local ssh_config ssh_user ssh_hostname
|
||||||
builtin local target
|
ssh_config=$(builtin command ssh -G "$@" 2>/dev/null)
|
||||||
target=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@')
|
ssh_user=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do
|
||||||
|
[[ "$ssh_key" == "ssh_user" ]] && echo "$ssh_value" && break
|
||||||
if [[ -n "$target" ]] && "$_CACHE" chk "$target"; then
|
done)
|
||||||
env+=(TERM=xterm-ghostty)
|
ssh_hostname=$(echo "$ssh_config" | while IFS=' ' read -r ssh_key ssh_value; do
|
||||||
elif builtin command -v infocmp >/dev/null 2>&1; then
|
[[ "$ssh_key" == "hostname" ]] && echo "$ssh_value" && break
|
||||||
builtin local tinfo
|
done)
|
||||||
tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || builtin echo "Warning: xterm-ghostty terminfo not found locally." >&2
|
ssh_target="${ssh_user}@${ssh_hostname}"
|
||||||
if [[ -n "$tinfo" ]]; then
|
|
||||||
builtin echo "Setting up Ghostty terminfo on remote host..." >&2
|
if [[ -n "$ssh_hostname" ]]; then
|
||||||
builtin local cpath
|
# Detect timeout command (BSD compatibility)
|
||||||
cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)"
|
local ssh_timeout_cmd=""
|
||||||
case $(builtin echo "$tinfo" | builtin command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" '
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit
|
ssh_timeout_cmd="timeout"
|
||||||
command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; }
|
elif command -v gtimeout >/dev/null 2>&1; then
|
||||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL
|
ssh_timeout_cmd="gtimeout"
|
||||||
') in
|
fi
|
||||||
OK)
|
|
||||||
builtin echo "Terminfo setup complete." >&2
|
# Check if terminfo is already cached
|
||||||
[[ -n "$target" ]] && "$_CACHE" add "$target"
|
local ssh_cache_check_success=false
|
||||||
env+=(TERM=xterm-ghostty)
|
if command -v ghostty >/dev/null 2>&1; then
|
||||||
ctrl+=(-o "ControlPath=$cpath")
|
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
|
||||||
*) builtin echo "Warning: Failed to install terminfo." >&2 ;;
|
else
|
||||||
esac
|
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
|
fi
|
||||||
else
|
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
|
||||||
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
|
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
|
fi
|
||||||
|
|
||||||
# Execute
|
builtin command ssh "${ssh_opts[@]}" "$@"
|
||||||
if [[ ${#env[@]} -gt 0 ]]; then
|
local ssh_ret=$?
|
||||||
env "${env[@]}" ssh "${opts[@]}" "${ctrl[@]}" "$@"
|
|
||||||
else
|
# Restore original environment variables
|
||||||
builtin command ssh "${opts[@]}" "${ctrl[@]}" "$@"
|
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
|
fi
|
||||||
|
|
||||||
|
return $ssh_ret
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -100,91 +100,238 @@
|
|||||||
|
|
||||||
# SSH Integration
|
# SSH Integration
|
||||||
use str
|
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) {
|
# SSH wrapper that preserves Ghostty features across remote connections
|
||||||
var _CACHE = $E:GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache
|
fn ssh {|@args|
|
||||||
}
|
var ssh-env = []
|
||||||
|
var ssh-opts = []
|
||||||
|
|
||||||
# SSH wrapper
|
# Configure environment variables for remote session
|
||||||
fn ssh {|@args|
|
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) {
|
||||||
var env = []
|
var ssh-env-vars = [
|
||||||
var opts = []
|
COLORTERM=truecolor
|
||||||
var ctrl = []
|
TERM_PROGRAM=ghostty
|
||||||
|
]
|
||||||
|
|
||||||
# Set up env vars first so terminfo installation inherits them
|
if (has-env TERM_PROGRAM_VERSION) {
|
||||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) {
|
set ssh-env-vars = [$@ssh-env-vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION]
|
||||||
var vars = [
|
}
|
||||||
COLORTERM=truecolor
|
|
||||||
TERM_PROGRAM=ghostty
|
|
||||||
]
|
|
||||||
if (not-eq $E:TERM_PROGRAM_VERSION '') {
|
|
||||||
set vars = [$@vars TERM_PROGRAM_VERSION=$E:TERM_PROGRAM_VERSION]
|
|
||||||
}
|
|
||||||
|
|
||||||
for v $vars {
|
# Store original values for restoration
|
||||||
set-env (str:split = $v | take 1) (str:split = $v | drop 1 | str:join =)
|
var ssh-exported-vars = []
|
||||||
var varname = (str:split = $v | take 1)
|
for ssh-v $ssh-env-vars {
|
||||||
set opts = [$@opts -o 'SendEnv '$varname -o 'SetEnv '$v]
|
var ssh-var-name = (str:split &max=2 = $ssh-v)[0]
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
|
||||||
')
|
|
||||||
|
|
||||||
if (eq $result OK) {
|
if (has-env $ssh-var-name) {
|
||||||
echo "Terminfo setup complete." >&2
|
var original-value = (get-env $ssh-var-name)
|
||||||
if (not-eq $target '') { $_CACHE add $target }
|
set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name=$original-value]
|
||||||
set env = [$@env TERM=xterm-ghostty]
|
} else {
|
||||||
set ctrl = [$@ctrl -o ControlPath=$cpath]
|
set ssh-exported-vars = [$@ssh-exported-vars $ssh-var-name]
|
||||||
} else {
|
}
|
||||||
echo "Warning: Failed to install terminfo." >&2
|
|
||||||
}
|
# 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
|
# Install terminfo on remote host if needed
|
||||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) {
|
if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) {
|
||||||
if (and (eq $E:TERM xterm-ghostty) (not (str:contains (str:join ' ' $env) 'TERM='))) {
|
var ssh-config = ""
|
||||||
set env = [$@env TERM=xterm-256color]
|
try {
|
||||||
}
|
set ssh-config = (external ssh -G $@args 2>/dev/null | slurp)
|
||||||
}
|
} catch {
|
||||||
|
set ssh-config = ""
|
||||||
|
}
|
||||||
|
|
||||||
# Execute
|
var ssh-user = ""
|
||||||
if (> (count $env) 0) {
|
var ssh-hostname = ""
|
||||||
e:env $@env e:ssh $@opts $@ctrl $@args
|
|
||||||
} else {
|
for line (str:split "\n" $ssh-config) {
|
||||||
e:ssh $@opts $@ctrl $@args
|
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 {
|
defer {
|
||||||
|
@ -86,88 +86,173 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# SSH Integration
|
# SSH Integration for Fish Shell
|
||||||
if string match -qr 'ssh-(env|terminfo)' $GHOSTTY_SHELL_FEATURES
|
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
|
# SSH wrapper that preserves Ghostty features across remote connections
|
||||||
set -g _CACHE "$GHOSTTY_RESOURCES_DIR/shell-integration/shared/ghostty-ssh-cache"
|
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
|
||||||
end
|
set -l ssh_env
|
||||||
|
set -l ssh_opts
|
||||||
# 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
|
|
||||||
|
|
||||||
|
# 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"
|
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
|
end
|
||||||
|
|
||||||
for v in $vars
|
# Store original values for restoration
|
||||||
set -l parts (string split = $v)
|
set -l ssh_exported_vars
|
||||||
set -gx $parts[1] $parts[2]
|
for ssh_v in $ssh_env_vars
|
||||||
set -a opts -o "SendEnv $parts[1]" -o "SetEnv $v"
|
set -l ssh_var_name (string split -m1 '=' -- $ssh_v)[1]
|
||||||
end
|
|
||||||
end
|
if set -q $ssh_var_name
|
||||||
|
set -a ssh_exported_vars "$ssh_var_name="(eval echo \$$ssh_var_name)
|
||||||
# Install terminfo if needed, reuse control connection for main session
|
else
|
||||||
if string match -q '*ssh-terminfo*' $GHOSTTY_SHELL_FEATURES
|
set -a ssh_exported_vars $ssh_var_name
|
||||||
# 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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if test -n "$tinfo"
|
# Export the variable
|
||||||
echo "Setting up Ghostty terminfo on remote host..." >&2
|
set -gx (string split -m1 '=' -- $ssh_v)
|
||||||
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
|
|
||||||
')
|
|
||||||
|
|
||||||
switch $result
|
# Use both SendEnv and SetEnv for maximum compatibility
|
||||||
case OK
|
set -a ssh_opts -o "SendEnv $ssh_var_name"
|
||||||
echo "Terminfo setup complete." >&2
|
set -a ssh_opts -o "SetEnv $ssh_v"
|
||||||
test -n "$target" && "$_CACHE" add "$target"
|
end
|
||||||
set -a env TERM=xterm-ghostty
|
|
||||||
set -a ctrl -o "ControlPath=$cpath"
|
set -a ssh_env $ssh_env_vars
|
||||||
case '*'
|
end
|
||||||
echo "Warning: Failed to install terminfo." >&2
|
|
||||||
|
# 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
|
||||||
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
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fallback TERM only if terminfo didn't set it
|
# Ensure TERM is set when using ssh-env feature
|
||||||
if string match -q '*ssh-env*' $GHOSTTY_SHELL_FEATURES
|
if string match -q '*ssh-env*' -- "$GHOSTTY_SHELL_FEATURES"
|
||||||
if test "$TERM" = xterm-ghostty -a ! (string join ' ' $env | string match -q '*TERM=*')
|
set -l ssh_term_set false
|
||||||
set -a env TERM=xterm-256color
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Execute
|
command ssh $ssh_opts $argv
|
||||||
if test (count $env) -gt 0
|
set -l ssh_ret $status
|
||||||
env $env command ssh $opts $ctrl $argv
|
|
||||||
else
|
# Restore original environment variables
|
||||||
command ssh $opts $ctrl $argv
|
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
|
end
|
||||||
|
|
||||||
|
return $ssh_ret
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -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
|
|
@ -245,78 +245,166 @@ _ghostty_deferred_init() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# SSH Integration
|
# 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
|
# SSH wrapper that preserves Ghostty features across remote connections
|
||||||
readonly _CACHE="${GHOSTTY_RESOURCES_DIR}/shell-integration/shared/ghostty-ssh-cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SSH wrapper
|
|
||||||
ssh() {
|
ssh() {
|
||||||
local -a env opts ctrl
|
emulate -L zsh
|
||||||
env=()
|
setopt local_options no_glob_subst
|
||||||
opts=()
|
|
||||||
ctrl=()
|
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
|
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then
|
||||||
local -a vars
|
local -a ssh_env_vars=(
|
||||||
vars=(
|
"COLORTERM=truecolor"
|
||||||
COLORTERM=truecolor
|
"TERM_PROGRAM=ghostty"
|
||||||
TERM_PROGRAM=ghostty
|
|
||||||
${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION}
|
|
||||||
)
|
)
|
||||||
for v in "${vars[@]}"; do
|
[[ -n "$TERM_PROGRAM_VERSION" ]] && ssh_env_vars+=("TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION")
|
||||||
export "${v?}"
|
|
||||||
opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v")
|
# 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
|
done
|
||||||
|
|
||||||
|
ssh_env+=("${ssh_env_vars[@]}")
|
||||||
fi
|
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
|
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-terminfo ]]; then
|
||||||
# Get target (only when needed for terminfo)
|
local ssh_config ssh_user ssh_hostname ssh_target
|
||||||
local target
|
ssh_config=$(command ssh -G "$@" 2>/dev/null)
|
||||||
target=$(command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@')
|
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
|
if [[ -n "$ssh_hostname" ]]; then
|
||||||
env+=(TERM=xterm-ghostty)
|
# Detect timeout command (BSD compatibility)
|
||||||
elif command -v infocmp >/dev/null 2>&1; then
|
local ssh_timeout_cmd=""
|
||||||
local tinfo
|
if (( $+commands[timeout] )); then
|
||||||
tinfo=$(infocmp -x xterm-ghostty 2>/dev/null) || echo "Warning: xterm-ghostty terminfo not found locally." >&2
|
ssh_timeout_cmd="timeout"
|
||||||
if [[ -n "$tinfo" ]]; then
|
elif (( $+commands[gtimeout] )); then
|
||||||
echo "Setting up Ghostty terminfo on remote host..." >&2
|
ssh_timeout_cmd="gtimeout"
|
||||||
local cpath
|
fi
|
||||||
cpath="/tmp/ghostty-ssh-$USER-$RANDOM-$(date +%s)"
|
|
||||||
case $(echo "$tinfo" | command ssh "${opts[@]}" -o ControlMaster=yes -o ControlPath="$cpath" -o ControlPersist=60s "$@" '
|
# Check if terminfo is already cached
|
||||||
infocmp xterm-ghostty >/dev/null 2>&1 && echo OK && exit
|
local ssh_cache_check_success=false
|
||||||
command -v tic >/dev/null 2>&1 || { echo NO_TIC; exit 1; }
|
if (( $+commands[ghostty] )); then
|
||||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && echo OK || echo FAIL
|
if [[ -n "$ssh_timeout_cmd" ]]; then
|
||||||
') in
|
$ssh_timeout_cmd "${GHOSTTY_SSH_CHECK_TIMEOUT}s" ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true
|
||||||
OK)
|
else
|
||||||
echo "Terminfo setup complete." >&2
|
ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 && ssh_cache_check_success=true
|
||||||
[[ -n "$target" ]] && "$_CACHE" add "$target"
|
fi
|
||||||
env+=(TERM=xterm-ghostty)
|
fi
|
||||||
ctrl+=(-o "ControlPath=$cpath")
|
|
||||||
;;
|
if [[ "$ssh_cache_check_success" == "true" ]]; then
|
||||||
*) echo "Warning: Failed to install terminfo." >&2 ;;
|
ssh_env+=(TERM=xterm-ghostty)
|
||||||
esac
|
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
|
fi
|
||||||
else
|
else
|
||||||
echo "Warning: infocmp not found locally. Terminfo installation unavailable." >&2
|
[[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]] && ssh_env+=(TERM=xterm-256color)
|
||||||
fi
|
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
|
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
|
fi
|
||||||
|
|
||||||
# Execute
|
command ssh "${ssh_opts[@]}" "$@"
|
||||||
if [[ ${#env[@]} -gt 0 ]]; then
|
local ssh_ret=$?
|
||||||
env "${env[@]}" command ssh "${opts[@]}" "${ctrl[@]}" "$@"
|
|
||||||
else
|
# Restore original environment variables
|
||||||
command ssh "${opts[@]}" "${ctrl[@]}" "$@"
|
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
|
fi
|
||||||
|
|
||||||
|
return $ssh_ret
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user