From a1add2a1687168ede0bcd8d1f5c51ff5743644ac Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Thu, 10 Sep 2026 21:56:17 -0400 Subject: [PATCH] feat: connect to hosts that are not in ssh_config A typed user or port was parsed and then lost, so `:SSHConnect user@host` authenticated as the local user. Typed values now outrank what `ssh -G` reports, and the picker offers manual entry instead of dead-ending when ssh_config is empty. Closes #9 --- README.md | 4 +- lua/sshfs/api.lua | 12 ++- lua/sshfs/lib/ssh_config.lua | 18 ++++ lua/sshfs/ui/select.lua | 40 +++++++-- tests/manual_host_spec.lua | 170 +++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/manual_host_spec.lua diff --git a/README.md b/README.md index 4b420a5..67c3dc0 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ require("sshfs").setup({ ## 🔧 Commands - `:checkhealth sshfs` - Verify dependencies and configuration -- `:SSHConnect [host]` - Mount a remote host +- `:SSHConnect [host]` - Mount a remote host. Accepts an ssh_config alias or a host that is in no config at all, e.g. `:SSHConnect user@example.com:/srv -p 2222`. Without an argument the picker offers "Enter host manually..." for the same thing. - `:SSHTest [host]` - Test SSH resolution and authentication without mounting - `:SSHDisconnect` - Unmount current host - `:SSHDisconnectAll` - Unmount all hosts @@ -337,7 +337,7 @@ If [which-key.nvim](https://github.com/folke/which-key.nvim) is installed, the ` ## 🚀 Usage -1. `:SSHConnect` — pick a host and mount path (home/root/custom/global_paths/host_paths). +1. `:SSHConnect` — pick a host (or enter one manually) and mount path (home/root/custom/global_paths/host_paths). 2. Work from the mount: - `:SSHFiles`, `:SSHGrep`, or `:SSHChangeDir` - Live remote search: `:SSHLiveFind` / `:SSHLiveGrep` (streams over SSH, still mounted) diff --git a/lua/sshfs/api.lua b/lua/sshfs/api.lua index 6eef33c..cb34cd2 100644 --- a/lua/sshfs/api.lua +++ b/lua/sshfs/api.lua @@ -373,7 +373,11 @@ Api.setup = function() vim.api.nvim_create_user_command("SSHConnect", function(opts) if opts.args and opts.args ~= "" then local SSHConfig = require("sshfs.lib.ssh_config") - local host = SSHConfig.parse_host(opts.args) + local host = SSHConfig.resolve_input(opts.args) + if not host then + vim.notify("Could not read a host from: " .. opts.args, vim.log.levels.ERROR) + return + end Api.connect(host) else Api.connect() @@ -383,7 +387,11 @@ Api.setup = function() vim.api.nvim_create_user_command("SSHTest", function(opts) if opts.args and opts.args ~= "" then local SSHConfig = require("sshfs.lib.ssh_config") - local host = SSHConfig.parse_host(opts.args) + local host = SSHConfig.resolve_input(opts.args) + if not host then + vim.notify("Could not read a host from: " .. opts.args, vim.log.levels.ERROR) + return + end Api.test(host) else Api.test() diff --git a/lua/sshfs/lib/ssh_config.lua b/lua/sshfs/lib/ssh_config.lua index a7cc40c..6d1ddb6 100644 --- a/lua/sshfs/lib/ssh_config.lua +++ b/lua/sshfs/lib/ssh_config.lua @@ -177,6 +177,24 @@ function SSHConfig.parse_host(command) return host end +--- Resolve a typed host string through ssh -G, keeping anything typed explicitly +--- @param input string e.g. "user@example.com:/srv -p 2222" or a config alias +--- @return table|nil host Host object, or nil when no host name was given +function SSHConfig.resolve_input(input) + local parsed = SSHConfig.parse_host(vim.trim(input or "")) + if not parsed.name or parsed.name == "" then return nil end + + local resolved = SSHConfig.get_host_config(parsed.name) + if not resolved then return parsed end + + -- A typed user or port is not in ssh_config, so it outranks the resolved value. + return vim.tbl_extend("force", resolved, { + user = parsed.user or resolved.user, + port = parsed.port or resolved.port, + path = parsed.path, + }) +end + --- Clear the hosts and resolved_configs caches to force re-parsing on next get_hosts() call function SSHConfig.refresh() CACHE.hosts = nil diff --git a/lua/sshfs/ui/select.lua b/lua/sshfs/ui/select.lua index cb8cede..390bdf8 100644 --- a/lua/sshfs/ui/select.lua +++ b/lua/sshfs/ui/select.lua @@ -3,6 +3,8 @@ local Select = {} +local MANUAL_ENTRY = "Enter host manually..." + --- Get active mounts or display warning. ---@return table|nil Active mounts array or nil if none found local function get_active_mounts_or_warn() @@ -90,20 +92,37 @@ function Select.unmount(callback) end) end +--- Prompt for a host that is not in ssh_config, e.g. "user@example.com" +---@param callback function Callback invoked with the resolved host object +function Select.host_input(callback) + vim.ui.input({ prompt = "Connect to (user@host): " }, function(input) + if not input or vim.trim(input) == "" then return end + + local host = require("sshfs.lib.ssh_config").resolve_input(input) + if not host then + vim.notify("Could not read a host from: " .. input, vim.log.levels.ERROR) + return + end + callback(host) + end) +end + --- Host selection for choosing an SSH Host to connect to. ---@param callback function Callback invoked with selected host object function Select.host(callback) local SSHConfig = require("sshfs.lib.ssh_config") local hosts = SSHConfig.get_hosts() + -- Nothing to pick from means the manual prompt is the only way through. if not hosts or #hosts == 0 then - vim.notify("No SSH hosts found in configuration", vim.log.levels.WARN) + Select.host_input(callback) return end -- Sort hosts alphabetically local host_list = vim.deepcopy(hosts) table.sort(host_list) + table.insert(host_list, MANUAL_ENTRY) vim.ui.select(host_list, { prompt = "Select SSH host to connect:", @@ -111,14 +130,19 @@ function Select.host(callback) return item end, }, function(choice) - if choice then - local host, err = SSHConfig.get_host_config(choice) - if not host then - vim.notify("Failed to resolve SSH config: " .. (err or "Unknown error"), vim.log.levels.ERROR) - return - end - callback(host) + if not choice then return end + + if choice == MANUAL_ENTRY then + Select.host_input(callback) + return + end + + local host, err = SSHConfig.get_host_config(choice) + if not host then + vim.notify("Failed to resolve SSH config: " .. (err or "Unknown error"), vim.log.levels.ERROR) + return end + callback(host) end) end diff --git a/tests/manual_host_spec.lua b/tests/manual_host_spec.lua new file mode 100644 index 0000000..e5a40a2 --- /dev/null +++ b/tests/manual_host_spec.lua @@ -0,0 +1,170 @@ +-- tests/manual_host_spec.lua +-- Connecting to a host that is not in ssh_config, typed as `user@host` +-- +-- A typed user or port cannot come from ssh_config, so it must outrank +-- whatever `ssh -G` reports for the same name. + +--- Load SSHConfig against a faked `ssh -G` +--- @param resolved table|nil Key/value lines ssh -G reports, or nil to fail resolution +local function load_config(resolved) + stub.reload() + require("sshfs.config").setup({}) + + stub.system(function() + if not resolved then return "", 255 end + + local lines = {} + for key, value in pairs(resolved) do + table.insert(lines, key .. " " .. value) + end + table.sort(lines) + return table.concat(lines, "\n") .. "\n", 0 + end) + + return require("sshfs.lib.ssh_config") +end + +describe("SSHConfig.resolve_input", function() + it("keeps a typed user over the one ssh -G reports", function() + local SSHConfig = load_config({ user = "localuser", port = "22", hostname = "10.0.0.5" }) + + local host = SSHConfig.resolve_input("remoteuser@10.0.0.5") + stub.restore_all() + + expect.eq(host.user, "remoteuser", "the typed user is the whole point of typing it") + expect.eq(host.name, "10.0.0.5") + end) + + it("keeps a typed port over the resolved one", function() + local SSHConfig = load_config({ user = "deploy", port = "22", hostname = "example.com" }) + + local host = SSHConfig.resolve_input("example.com -p 2222") + stub.restore_all() + + expect.eq(host.port, "2222") + end) + + it("falls back to the resolved values when nothing is typed", function() + local SSHConfig = load_config({ user = "deploy", port = "2222", hostname = "10.0.0.5" }) + + local host = SSHConfig.resolve_input("alias") + stub.restore_all() + + expect.eq(host.user, "deploy") + expect.eq(host.port, "2222") + expect.eq(host.hostname, "10.0.0.5", "an alias must still gain its resolved config") + end) + + it("still returns a usable host when ssh -G cannot resolve it", function() + local SSHConfig = load_config(nil) + + local host = SSHConfig.resolve_input("remoteuser@10.0.0.5") + stub.restore_all() + + expect.truthy(host, "an unresolvable host must still be connectable") + expect.eq(host.user, "remoteuser") + expect.eq(host.name, "10.0.0.5") + end) + + it("carries a typed remote path", function() + local SSHConfig = load_config({ user = "deploy", port = "22", hostname = "example.com" }) + + local host = SSHConfig.resolve_input("deploy@example.com:/srv/app") + stub.restore_all() + + expect.eq(host.path, "/srv/app") + end) + + it("returns nil for an empty input", function() + local SSHConfig = load_config({ user = "deploy", port = "22" }) + + expect.is_nil(SSHConfig.resolve_input("")) + expect.is_nil(SSHConfig.resolve_input(" ")) + stub.restore_all() + end) +end) + +describe("manual host entry", function() + it("prompts when ssh_config lists no hosts", function() + load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" }) + package.loaded["sshfs.lib.ssh_config"].get_hosts = function() + return {} + end + + local prompted = false + stub.set("ui.input", function(_, on_input) + prompted = true + on_input("remoteuser@10.0.0.5") + end) + + local selected + require("sshfs.ui.select").host(function(host) + selected = host + end) + stub.restore_all() + + expect.truthy(prompted, "an empty ssh_config must not be a dead end") + expect.eq(selected.user, "remoteuser") + end) + + it("offers a manual entry alongside the configured hosts", function() + load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" }) + package.loaded["sshfs.lib.ssh_config"].get_hosts = function() + return { "alpha", "beta" } + end + + local offered + stub.set("ui.select", function(items) + offered = items + end) + + require("sshfs.ui.select").host(function() end) + stub.restore_all() + + expect.eq(#offered, 3, "the two hosts plus a manual entry") + expect.eq(offered[#offered], "Enter host manually...") + end) + + it("prompts when the manual entry is chosen", function() + load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" }) + package.loaded["sshfs.lib.ssh_config"].get_hosts = function() + return { "alpha" } + end + + stub.set("ui.select", function(items, _, on_choice) + on_choice(items[#items]) + end) + stub.set("ui.input", function(_, on_input) + on_input("remoteuser@10.0.0.5 -p 2222") + end) + + local selected + require("sshfs.ui.select").host(function(host) + selected = host + end) + stub.restore_all() + + expect.truthy(selected, "choosing manual entry must reach the callback") + expect.eq(selected.user, "remoteuser") + expect.eq(selected.port, "2222") + end) + + it("does nothing when the prompt is cancelled", function() + load_config({ user = "deploy", port = "22" }) + package.loaded["sshfs.lib.ssh_config"].get_hosts = function() + return {} + end + + stub.set("ui.input", function(_, on_input) + on_input(nil) + end) + + local called = false + require("sshfs.ui.select").host(function() + called = true + end) + stub.restore_all() + + expect.eq(called, false, "cancelling must not connect anywhere") + end) +end)