mirror of
https://github.com/neovim/neovim.git
synced 2026-11-06 05:16:52 +00:00
- SshHost fields: follow ssh's "first wins" rule, so e.g. a later User line in the same block no longer overrides an earlier one. - Local server now exits on its own when the remote session ends. - Before auto-install, try existing `nvim` found in remote `$PATH`. - Combine the remote_ssh_x.sh scripts into one remote_ssh.sh script. - Ignore `RemoteCommand` from ssh_config, which made ssh refuse our command. - Don't accumulate the remote heartbeat in memory for the whole session: `run_wait()` captures output only until it returns. - Before detaching the UI, check that the remote Nvim answers through the forward: sshd may refuse the forward, but still run the script. - Stop the session (tunnel, remote Nvim) if any connect step fails, or if the UI detached meanwhile. - Run `ssh -G` once per session, instead of for every ssh command. - Remote script handles signals at once, instead of after the heartbeat's - Error if :restart is attempted. FUTURE: Remote :restart prints a marker, with its [command], on stdout. It also sends the TUI a restart event pointing at the LOCAL server. The TUI reattaches there. Local server repeats the remote-ssh connect steps, and sends the TUI a connect event for the new forwarded socket. Then it runs [command] (e.g. session restore) on the new remote over RPC.
645 lines
20 KiB
Lua
645 lines
20 KiB
Lua
-- Converted into Lua from https://github.com/cyjake/ssh-config
|
|
-- TODO (siddhantdev): deal with include directives
|
|
|
|
local M = {}
|
|
|
|
local whitespace_pattern = '%s'
|
|
local line_break_pattern = '[\r\n]'
|
|
|
|
--- Command that runs ssh. Tests replace it with a fake.
|
|
M.ssh_cmd = { 'ssh' }
|
|
|
|
--- Remote-ssh data dir, relative to the remote `$HOME`.
|
|
local remote_data_dir = '.local/share/nvim-remote'
|
|
|
|
local _log --- @type vim.Log?
|
|
|
|
---@param param string
|
|
local function is_multi_value_directive(param)
|
|
local multi_value_directives = {
|
|
'globalknownhostsfile',
|
|
'host',
|
|
'ipqos',
|
|
'sendenv',
|
|
'userknownhostsfile',
|
|
'proxycommand',
|
|
'match',
|
|
'canonicaldomains',
|
|
}
|
|
|
|
return vim.list_contains(multi_value_directives, param:lower())
|
|
end
|
|
|
|
---@class vim.net.SshHost
|
|
---@field alias string Host alias from ssh config.
|
|
---@field hostname? string Resolved Hostname directive.
|
|
---@field user? string User directive.
|
|
---@field port? string Port directive.
|
|
---@field identity_file? string IdentityFile directive.
|
|
|
|
---@param text string The ssh configuration which needs to be parsed.
|
|
---@return vim.net.SshHost[] The parsed host configurations.
|
|
function M.parse_ssh_config(text)
|
|
local i = 1
|
|
local line = 1
|
|
|
|
local function consume()
|
|
if i <= #text then
|
|
local char = text:sub(i, i)
|
|
i = i + 1
|
|
return char
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local chr = consume()
|
|
|
|
local function parse_spaces()
|
|
local spaces = ''
|
|
while chr and chr:match(whitespace_pattern) do
|
|
spaces = spaces .. chr
|
|
chr = consume()
|
|
end
|
|
return spaces
|
|
end
|
|
|
|
local function parse_linebreaks()
|
|
local breaks = ''
|
|
while chr and chr:match(line_break_pattern) do
|
|
line = line + 1
|
|
breaks = breaks .. chr
|
|
chr = consume()
|
|
end
|
|
return breaks
|
|
end
|
|
|
|
local function parse_parameter_name()
|
|
local param = ''
|
|
while chr and not chr:match('[ \t=]') do
|
|
param = param .. chr
|
|
chr = consume()
|
|
end
|
|
return param
|
|
end
|
|
|
|
local function parse_separator()
|
|
local sep = parse_spaces()
|
|
if chr == '=' then
|
|
sep = sep .. chr
|
|
chr = consume()
|
|
end
|
|
return sep .. parse_spaces()
|
|
end
|
|
|
|
local function parse_value()
|
|
local val = {}
|
|
local quoted, escaped = false, false
|
|
|
|
while chr and not chr:match(line_break_pattern) do
|
|
if escaped then
|
|
table.insert(val, chr == '"' and chr or '\\' .. chr)
|
|
escaped = false
|
|
elseif chr == '"' and (#val == 0 or quoted) then
|
|
quoted = not quoted
|
|
elseif chr == '\\' then
|
|
escaped = true
|
|
elseif chr == '#' and not quoted then
|
|
break
|
|
else
|
|
table.insert(val, chr)
|
|
end
|
|
chr = consume()
|
|
end
|
|
|
|
if quoted or escaped then
|
|
error(('Unexpected line break at line %d'):format(line))
|
|
end
|
|
|
|
return vim.trim(table.concat(val))
|
|
end
|
|
|
|
local function parse_comment()
|
|
while chr and not chr:match(line_break_pattern) do
|
|
chr = consume()
|
|
end
|
|
end
|
|
|
|
---@return string[]
|
|
local function parse_multiple_values()
|
|
local results = {}
|
|
local val = {}
|
|
local quoted = false
|
|
local escaped = false
|
|
|
|
while chr and not chr:match(line_break_pattern) do
|
|
if escaped then
|
|
table.insert(val, chr == '"' and chr or '\\' .. chr)
|
|
escaped = false
|
|
elseif chr == '"' then
|
|
quoted = not quoted
|
|
elseif chr == '\\' then
|
|
escaped = true
|
|
elseif quoted then
|
|
table.insert(val, chr)
|
|
elseif chr:match('[ \t=]') then
|
|
if #val > 0 then
|
|
table.insert(results, vim.trim(table.concat(val)))
|
|
val = {}
|
|
end
|
|
elseif chr == '#' and #results > 0 then
|
|
break
|
|
else
|
|
table.insert(val, chr)
|
|
end
|
|
chr = consume()
|
|
end
|
|
|
|
if quoted or escaped then
|
|
error(('Unexpected line break at line %d'):format(line))
|
|
end
|
|
|
|
if #val > 0 then
|
|
table.insert(results, vim.trim(table.concat(val)))
|
|
end
|
|
|
|
return results
|
|
end
|
|
|
|
local function parse_directive()
|
|
local param = parse_parameter_name()
|
|
local multiple = is_multi_value_directive(param)
|
|
local _ = parse_separator()
|
|
local value = multiple and parse_multiple_values() or parse_value()
|
|
|
|
local result = {
|
|
param = param,
|
|
value = value,
|
|
}
|
|
|
|
return result
|
|
end
|
|
|
|
local function parse_line()
|
|
local _ = parse_spaces()
|
|
if chr == '#' then
|
|
parse_comment()
|
|
return nil
|
|
end
|
|
local node = parse_directive()
|
|
local _ = parse_linebreaks()
|
|
|
|
return node
|
|
end
|
|
|
|
---@type vim.net.SshHost[]
|
|
local hosts = {}
|
|
local seen = {} ---@type table<string, boolean>
|
|
---@type vim.net.SshHost[]
|
|
local current_hosts = {}
|
|
|
|
---@param value string
|
|
local function is_valid(value)
|
|
return not (value:find('[?*!]') or seen[value])
|
|
end
|
|
|
|
local function flush()
|
|
for _, h in ipairs(current_hosts) do
|
|
table.insert(hosts, h)
|
|
end
|
|
current_hosts = {}
|
|
end
|
|
|
|
---@param aliases string[]
|
|
local function add_aliases(aliases)
|
|
flush()
|
|
for _, alias in ipairs(aliases) do
|
|
if is_valid(alias) then
|
|
seen[alias] = true
|
|
table.insert(current_hosts, { alias = alias })
|
|
end
|
|
end
|
|
end
|
|
|
|
while chr do
|
|
local node = parse_line()
|
|
if node then
|
|
local lp = node.param:lower()
|
|
if lp == 'match' and node.value then
|
|
local values = node.value --[[@as string[] ]]
|
|
local match_aliases = {} ---@type string[]
|
|
for ind, val in ipairs(values) do
|
|
if val:lower() == 'host' and ind + 1 <= #values and is_valid(assert(values[ind + 1])) then
|
|
table.insert(match_aliases, values[ind + 1])
|
|
end
|
|
end
|
|
add_aliases(match_aliases)
|
|
elseif lp == 'host' and node.value then
|
|
local valid = {} ---@type string[]
|
|
for _, value in
|
|
ipairs(node.value --[[@as string[] ]])
|
|
do
|
|
if is_valid(value) then
|
|
table.insert(valid, value)
|
|
end
|
|
end
|
|
add_aliases(valid)
|
|
else
|
|
local field = ({
|
|
hostname = 'hostname',
|
|
user = 'user',
|
|
port = 'port',
|
|
identityfile = 'identity_file',
|
|
})[lp]
|
|
local val = (type(node.value) == 'string' and node.value or nil) --[[@as string?]]
|
|
if field and val then
|
|
-- As ssh: the first obtained value wins.
|
|
for _, h in ipairs(current_hosts) do
|
|
h[field] = h[field] or val
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
flush()
|
|
|
|
return hosts
|
|
end
|
|
|
|
---@param filename string
|
|
---@return vim.net.SshHost[] The host configurations in the file.
|
|
function M.parse_config(filename)
|
|
local text = vim.fn.readblob(filename)
|
|
return M.parse_ssh_config(text)
|
|
end
|
|
|
|
---@param filename? string Path to the SSH config file. Defaults to `~/.ssh/config`.
|
|
---@return string[] The hostnames configured in the SSH config file. Note: This does not process
|
|
--- `Include` directives.
|
|
function M.get_hosts(filename)
|
|
filename = filename or vim.fs.normalize('~/.ssh/config')
|
|
local ok, hosts = pcall(M.parse_config, filename)
|
|
if not ok then
|
|
return {}
|
|
end
|
|
return vim.tbl_map(
|
|
---@param h vim.net.SshHost
|
|
function(h)
|
|
return h.alias
|
|
end,
|
|
hosts
|
|
)
|
|
end
|
|
|
|
--- Lazy `vim.log` instance for the remote-ssh feature. Writes to `stdpath('log')/remote-ssh.log`.
|
|
---@return vim.Log
|
|
local function log()
|
|
if not _log then
|
|
_log = vim.log.new({ name = 'remote-ssh', level = vim.log.levels.INFO })
|
|
end
|
|
return _log
|
|
end
|
|
|
|
--- Returns a function that shows (and logs) connect status as one |progress-message|, updated in
|
|
--- place.
|
|
---@return fun(status: 'running'|'success'|'failed', msg: string)
|
|
local function new_progress_report()
|
|
local progress = { kind = 'progress', source = 'remote-ssh', title = 'remote-ssh' }
|
|
return function(status, msg)
|
|
if status == 'failed' then
|
|
log().error(msg)
|
|
else
|
|
log().info(msg)
|
|
end
|
|
progress.status = status
|
|
progress.id = vim.api.nvim_echo({ { msg } }, status ~= 'running', progress)
|
|
vim.cmd.redraw()
|
|
end
|
|
end
|
|
|
|
local function core_system()
|
|
-- Keep this lazy so parser-only users can load vim.net._ssh without editor state.
|
|
return require('vim._core.system')
|
|
end
|
|
|
|
--- Builds a command for the remote host identified by `ssh_uri`.
|
|
---
|
|
--- `ssh_args` are inserted before the target; `remote_cmd` is appended after the target.
|
|
---
|
|
---@param ssh_uri {host:string, user?:string, port?:string, config?:string}
|
|
---@param opts? { ssh_args?: string[], remote_cmd?: string[] }
|
|
---@return string[]
|
|
local function get_ssh_cmd(ssh_uri, opts)
|
|
opts = opts or {}
|
|
local mux_dir = vim.fs.joinpath(vim.fn.stdpath('cache') --[[@as string]], 'ssh')
|
|
vim.fn.mkdir(mux_dir, 'p')
|
|
-- `%C` is SSH's hash of (local hostname, remote host, port, user). Its fixed length keeps the
|
|
-- ControlPath socket under the 104-byte `sun_path` limit, which the raw `%h_%p_%r` name plus a
|
|
-- long base dir can exceed. See also |vim.fs.slug()|.
|
|
local mux_path = vim.fs.joinpath(mux_dir, 'mux_%C')
|
|
local ssh_cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), {
|
|
'-T',
|
|
-- Fails instead of prompting for a password or key passphrase.
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'ControlMaster=auto',
|
|
'-o',
|
|
('ControlPath=%s'):format(mux_path),
|
|
'-o',
|
|
'ControlPersist=10m',
|
|
-- Workaround "Cannot execute command-line and remote command" if `RemoteCommand` is in user
|
|
-- ssh_config.
|
|
'-o',
|
|
'RemoteCommand=none',
|
|
})
|
|
local target = ssh_uri.user and ('%s@%s'):format(ssh_uri.user, ssh_uri.host) or ssh_uri.host
|
|
local dest = ssh_uri.port and { '-p', ssh_uri.port } or {}
|
|
-- Ends option parsing, so a target like `-oProxyCommand=x` is not taken as an option.
|
|
vim.list_extend(dest, { '--', target })
|
|
|
|
-- Effective ssh_config. Once per `ssh_uri`: `ssh -G` evaluates e.g. `Match exec`, or DNS for
|
|
-- `CanonicalizeHostname`.
|
|
if not ssh_uri.config then
|
|
local cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), { '-G', unpack(dest) })
|
|
ssh_uri.config = vim.system(cmd, { text = true }):wait().stdout or ''
|
|
end
|
|
local config = ('\n%s'):format(ssh_uri.config)
|
|
-- At the default `ask`, BatchMode fails on an unknown host key. `accept-new` records it instead,
|
|
-- as if the user typed "yes"; a changed key still fails. An explicit ssh_config value is kept.
|
|
if config:match('\nstricthostkeychecking (%S+)') == 'ask' then
|
|
vim.list_extend(ssh_cmd, { '-o', 'StrictHostKeyChecking=accept-new' })
|
|
end
|
|
-- At the default 0, a dropped connection (e.g. after sleep) leaves the session frozen until TCP
|
|
-- gives up. 15s, times the default `ServerAliveCountMax` 3: ends the session within 45s.
|
|
if config:match('\nserveraliveinterval (%S+)') == '0' then
|
|
vim.list_extend(ssh_cmd, { '-o', 'ServerAliveInterval=15' })
|
|
end
|
|
if opts.ssh_args then
|
|
vim.list_extend(ssh_cmd, opts.ssh_args)
|
|
end
|
|
vim.list_extend(ssh_cmd, dest)
|
|
|
|
if opts.remote_cmd then
|
|
vim.list_extend(ssh_cmd, opts.remote_cmd)
|
|
end
|
|
|
|
return ssh_cmd
|
|
end
|
|
|
|
--- Runs the local `$VIMRUNTIME/scripts/remote_ssh.sh` on the remote host. The script goes to
|
|
--- `sh -s` over stdin, so the remote login shell (any shell) parses only `args`.
|
|
---
|
|
---@param uri {host:string, user?:string, port?:string}
|
|
---@param args string[] Script args: plain words, which no shell expands, so they need no quoting.
|
|
---@param opts? { ssh_args?: string[], wait_until?: fun(stdout: string, stderr: string): boolean }
|
|
---@param on_exit? fun(out: vim.SystemCompleted) See `run_wait()`.
|
|
---@return vim.SystemRunWaitResult
|
|
local function run_script(uri, args, opts, on_exit)
|
|
opts = opts or {}
|
|
for _, arg in ipairs(args) do
|
|
assert(arg:match('^[%w%-%._/:]+$'), ('not a plain word: %s'):format(arg))
|
|
end
|
|
local script =
|
|
vim.fn.readblob(vim.fs.joinpath(assert(vim.env.VIMRUNTIME), 'scripts', 'remote_ssh.sh'))
|
|
local remote_cmd = vim.list_extend({ 'sh', '-s', '--' }, args)
|
|
local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = remote_cmd })
|
|
log().debug('run_script: running', ssh_cmd)
|
|
return core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000, on_exit)
|
|
end
|
|
|
|
--- Gets the operating system and architecture from the remote system.
|
|
---
|
|
---@param uri {host:string, user?:string, port?:string}
|
|
---@return string os, string arch
|
|
function M.get_system_info(uri)
|
|
local ssh_cmd = get_ssh_cmd(uri, { remote_cmd = { 'uname -s && uname -m' } })
|
|
log().debug('get_system_info: running', ssh_cmd)
|
|
|
|
local obj = vim.system(ssh_cmd, { text = true }):wait(300000)
|
|
log().debug('get_system_info: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
|
|
if obj.code ~= 0 then
|
|
local detail = obj.stderr ~= '' and obj.stderr or obj.stdout
|
|
error(('Failed to detect remote system info: %s'):format(detail))
|
|
end
|
|
|
|
local lines = vim.split(vim.trim(obj.stdout or ''), '\n', { plain = true })
|
|
local valid_lines = {}
|
|
for _, line in ipairs(lines) do
|
|
if vim.trim(line) ~= '' then
|
|
table.insert(valid_lines, vim.trim(line))
|
|
end
|
|
end
|
|
|
|
if #valid_lines < 2 then
|
|
error(('Unexpected output from system info detection: %s'):format(obj.stdout))
|
|
end
|
|
|
|
---@type string
|
|
local os = valid_lines[#valid_lines - 1]:lower()
|
|
---@type string
|
|
local arch = valid_lines[#valid_lines]:lower()
|
|
|
|
if os:match('msys') or os:match('windows') or os:match('mingw') or os:match('cygwin') then
|
|
error('Not implemented yet: Windows targets are not supported.')
|
|
end
|
|
|
|
return os, arch
|
|
end
|
|
|
|
--- Gets the Nvim release URL/dir/version based on the local version + remote platform.
|
|
---
|
|
---@param os string
|
|
---@param arch string
|
|
---@return string dir Install dir, relative to the remote `$HOME`, e.g.
|
|
--- `.local/share/nvim-remote/releases/v0.12.0-linux-x86_64`. Per platform too: a shared `$HOME`
|
|
--- may be mounted on remote hosts of different platforms.
|
|
---@return string url Release archive.
|
|
---@return string? version Minimum version of an `nvim` on the remote `$PATH` to use instead, or nil
|
|
--- for a prerelease (nightlies are not ordered by version).
|
|
local function get_release(os, arch)
|
|
local ver = vim.version()
|
|
local base = ('v%d.%d.%d'):format(ver.major, ver.minor, ver.patch)
|
|
-- prereleases: install the current nightly, once per version
|
|
local tag = ver.prerelease and 'nightly' or base
|
|
local install_name = ver.prerelease and ('nightly-%s'):format(base) or base
|
|
|
|
local os_map = { linux = 'linux', darwin = 'macos' }
|
|
local arch_map = { x86_64 = 'x86_64', aarch64 = 'arm64', arm64 = 'arm64' }
|
|
|
|
local target_os = os_map[os]
|
|
local target_arch = arch_map[arch]
|
|
if not target_os or not target_arch then
|
|
error(('Unsupported OS/Arch combination: %s/%s'):format(os, arch))
|
|
end
|
|
|
|
local release_name = ('nvim-%s-%s'):format(target_os, target_arch)
|
|
local release_url = ('https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz'):format(
|
|
tag,
|
|
release_name
|
|
)
|
|
local dir = ('%s/releases/%s-%s-%s'):format(remote_data_dir, install_name, target_os, target_arch)
|
|
return dir, release_url, not ver.prerelease and base or nil
|
|
end
|
|
|
|
--- Sends a request to the Nvim server at `addr`, and waits for its result. Over a plain socket, not
|
|
--- an RPC channel: that would also let the (remote) server call the API of this (local) Nvim.
|
|
---@param addr string
|
|
---@param method string
|
|
---@param params any[]
|
|
---@return boolean ok
|
|
local function request(addr, method, params)
|
|
local pipe = assert(vim.uv.new_pipe())
|
|
local received = ''
|
|
local response --- @type any
|
|
local done = false
|
|
pipe:connect(addr, function(err)
|
|
if err then
|
|
done = true
|
|
return
|
|
end
|
|
pipe:write(vim.mpack.encode({ 0, 0, method, params }))
|
|
pipe:read_start(function(read_err, data)
|
|
if read_err or not data then
|
|
done = true
|
|
return
|
|
end
|
|
received = ('%s%s'):format(received, data)
|
|
local ok, msg = pcall(vim.mpack.decode, received)
|
|
if ok then
|
|
response = msg
|
|
done = true
|
|
end
|
|
end)
|
|
end)
|
|
vim.wait(30000, function()
|
|
return done
|
|
end)
|
|
pipe:close()
|
|
-- Response: `[1, msgid, error, result]`.
|
|
return response ~= nil and response[1] == 1 and response[3] == vim.NIL
|
|
end
|
|
|
|
--- See `M.start()`.
|
|
---@param chan? integer
|
|
---@param report fun(status: 'running'|'success'|'failed', msg: string)
|
|
local function start(uri_str, chan, report)
|
|
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
|
|
report('running', ('Connecting to %s...'):format(uri_str))
|
|
local dir, url, version = get_release(M.get_system_info(uri))
|
|
|
|
local local_sock = ('%s_remote_nvim.sock'):format(vim.fn.tempname())
|
|
local token = vim.text.hexencode(assert(vim.uv.random(8)))
|
|
local remote_sock = ('/tmp/nvim-remote.%s/nvim.sock'):format(token)
|
|
|
|
local forward = ('%s:%s'):format(local_sock, remote_sock)
|
|
local cleaned_up = false
|
|
local tunnel --- @type vim.SystemRunWaitResult
|
|
|
|
--- Stops this (local) server when the session ends (remote quit, crash, lost connection),
|
|
--- unless it is still in use: a UI is attached or a buffer is modified.
|
|
local function on_exit()
|
|
vim.schedule(function()
|
|
-- Without NVIM_READY there was no session (`start()` fails instead).
|
|
if cleaned_up or not (tunnel and tunnel.stdout:match('NVIM_READY')) then
|
|
return
|
|
end
|
|
local modified = vim.iter(vim.api.nvim_list_bufs()):any(function(buf)
|
|
return vim.bo[buf].modified
|
|
end)
|
|
if modified or #vim.api.nvim_list_uis() > 0 then
|
|
log().info(('session ended: %s; local server in use, keeping it'):format(uri_str))
|
|
return
|
|
end
|
|
log().info(('session ended: %s; stopping local server'):format(uri_str))
|
|
-- Not `qall!`, so a running :terminal job keeps this server alive.
|
|
local ok, err = pcall(vim.cmd.qall)
|
|
if not ok then
|
|
log().info(('local server kept: %s'):format(err))
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Stops the tunnel (and thus the remote server), and cancels its forward, which lives in the ssh
|
|
--- master.
|
|
local function teardown()
|
|
if cleaned_up then
|
|
return
|
|
end
|
|
cleaned_up = true
|
|
log().info(('teardown: stopping tunnel for %s'):format(uri_str))
|
|
if not tunnel:is_closing() then
|
|
tunnel:kill('sigterm')
|
|
end
|
|
-- Waits for the tunnel to close its stdio handles.
|
|
tunnel:wait(1000)
|
|
vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait(1000)
|
|
end
|
|
|
|
report('running', 'Starting remote Nvim...')
|
|
log().debug('start: local_sock', local_sock)
|
|
tunnel = run_script(uri, { remote_sock, dir, url, version }, {
|
|
ssh_args = { '-L', forward },
|
|
wait_until = function(stdout)
|
|
return stdout:match('NVIM_READY') ~= nil
|
|
end,
|
|
}, on_exit)
|
|
log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)
|
|
|
|
local err --- @type string?
|
|
if not tunnel.stdout:match('NVIM_READY') then
|
|
-- The tunnel already exited.
|
|
err = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'
|
|
or 'SSH tunnel exited before the remote Nvim was ready'
|
|
local detail = vim.trim(tunnel.stderr)
|
|
err = detail ~= '' and ('%s: %s'):format(err, detail) or err
|
|
elseif not request(local_sock, 'nvim_get_api_info', {}) then
|
|
-- sshd may refuse the forward (e.g. `AllowStreamLocalForwarding no`) but run the script.
|
|
err = 'Remote Nvim is not reachable through the SSH forward'
|
|
elseif
|
|
chan
|
|
and not vim.iter(vim.api.nvim_list_uis()):any(function(ui)
|
|
return ui.chan == chan
|
|
end)
|
|
then
|
|
err = ('UI detached: channel %d'):format(chan)
|
|
end
|
|
if err then
|
|
log().error(err, 'code', tunnel.code, 'stderr', tunnel.stderr)
|
|
teardown()
|
|
error(err)
|
|
end
|
|
|
|
report('success', ('Connected to %s'):format(uri_str))
|
|
vim.api.nvim_create_autocmd('VimLeavePre', {
|
|
callback = teardown,
|
|
})
|
|
|
|
return local_sock, teardown
|
|
end
|
|
|
|
--- Asks to confirm, then connects.
|
|
---
|
|
--- @param uri_str string
|
|
--- @param chan? integer UI to connect: fails (and stops the session) if it detached meanwhile.
|
|
--- @return string? local_socket Path to the local forwarded socket, or nil if declined.
|
|
--- @return fun()? teardown Stops this session's tunnel (and thus the remote server). Other sessions
|
|
--- sharing the SSH master are unaffected.
|
|
function M.start(uri_str, chan)
|
|
local msg = ('This will install Nvim on %s (in ~/%s/), if missing.\n'):format(
|
|
uri_str,
|
|
remote_data_dir
|
|
)
|
|
if vim.fn.confirm(msg, 'Connect? &Yes\n&No', 1, 'Question') ~= 1 then
|
|
return nil
|
|
end
|
|
local report = new_progress_report()
|
|
local ok, local_sock, teardown = pcall(start, uri_str, chan, report)
|
|
if not ok then
|
|
report('failed', ('Failed to connect to %s'):format(uri_str))
|
|
error(local_sock, 0)
|
|
end
|
|
return local_sock, teardown
|
|
end
|
|
|
|
return M
|