mirror of
https://github.com/neovim/neovim.git
synced 2026-11-06 05:16:52 +00:00
feat(remote-ssh): support ":connect ssh://"
Problem: - :connect does not support ssh:// URLs. - ssh:// support is "owned" by netrw in an ad-hoc fashion. Solution: - Implement ssh config parsing. - Implement remote-ssh `:connect` support: - Detect remote OS/architecture via uname. - Bootstrap Nvim binary on remote (`~/.local/share/nvim-remote`). - Start headless, "isolated" Nvim with `NVIM_APPNAME=nvim-remote`. - Forward remote Unix socket via SSH -L tunnel. - Use SSH ControlMaster multiplexing for connection reuse. - Teardown: kill remote process + SSH mux, preserve data dir. Signed-off-by: Szymon Wilczek <swilczek.lx@gmail.com>
This commit is contained in:
1 parent
b4d549f439
commit
b94cdb848e
10 files changed
+1045
-47
No files matched your search
@@ -345,6 +345,8 @@ EDITOR
|
||||
< Typing õõ in |Normal-mode| now invokes a mapping bound to ]].
|
||||
• New |complete-items| field "commit_chars": characters that accept the
|
||||
selected completion item when typed.
|
||||
• |:connect| with `ssh://` URI orchestrates a remote Nvim session over SSH.
|
||||
See |remote-ssh|.
|
||||
|
||||
EVENTS
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
*remote_ssh.txt* Nvim
|
||||
|
||||
NVIM REFERENCE MANUAL
|
||||
|
||||
Remote SSH *remote-ssh* *remote-ssh-plugin*
|
||||
|
||||
Nvim can connect to remote SSH servers.
|
||||
Using the |:connect| command with an `ssh://` URI, Nvim will automatically
|
||||
connect to a host, bootstrap itself, and connect the local UI to the remote
|
||||
headless server.
|
||||
|
||||
Type |gO| to see the table of contents.
|
||||
|
||||
==============================================================================
|
||||
1. Introduction *remote-ssh-introduction* *E6101*
|
||||
|
||||
The remote-ssh feature uses `ssh` to connect to a remote system. It:
|
||||
- Detects the remote architecture.
|
||||
- Installs the Nvim release matching the local version (the current nightly
|
||||
for prerelease builds) under `~/.local/share/nvim-remote/releases/`,
|
||||
leaving any Nvim already installed on the remote host untouched.
|
||||
- Starts the remote Nvim with `NVIM_APPNAME=nvim-remote`, keeping remote
|
||||
state separate from any local Nvim installation on the remote host.
|
||||
- Initiates a headless Nvim server and connects your local UI via a Unix
|
||||
domain socket.
|
||||
- On disconnect, kills the remote headless process and SSH mux socket, but
|
||||
leaves the persistent data directory (`~/.local/share/nvim-remote`) intact
|
||||
for fast reconnects.
|
||||
|
||||
==============================================================================
|
||||
2. Usage *remote-ssh-usage*
|
||||
|
||||
From the command line: ~
|
||||
|
||||
Connect to a remote server using `-c` with |:connect|. SSH key-based
|
||||
authentication is required; password prompts are not supported.
|
||||
>sh
|
||||
nvim -c 'connect ssh://user@192.168.1.100'
|
||||
nvim -c 'connect ssh://root@server:2222'
|
||||
<
|
||||
|
||||
From a running Nvim session: ~
|
||||
|
||||
`:connect` ssh://{uri}
|
||||
|
||||
Connects to {uri} over SSH. The remote-ssh feature bootstraps the remote
|
||||
host and reconnects the local UI to the remote server.
|
||||
>vim
|
||||
:connect ssh://user@192.168.1.100
|
||||
:connect ssh://root@server:2222
|
||||
<
|
||||
|
||||
`:connect` {address}
|
||||
|
||||
Without the `ssh://` prefix, `:connect` connects to a local server at the
|
||||
given address (e.g. a Unix socket path).
|
||||
|
||||
==============================================================================
|
||||
3. Requirements *remote-ssh-requirements*
|
||||
|
||||
Both local and remote systems must meet certain requirements:
|
||||
|
||||
Local (Host):
|
||||
- `ssh` (OpenSSH 4.0+) installed and accessible in `$PATH`.
|
||||
- Multiplexing support (`ControlMaster`). Note: Win32-OpenSSH does not
|
||||
support multiplexing, so Windows clients are not supported currently.
|
||||
- `/tmp` directory must be writable.
|
||||
|
||||
Remote (Target):
|
||||
- Linux (x86_64, aarch64) or macOS (arm64, x86_64).
|
||||
- `tar`, `curl` to download and extract the Nvim release.
|
||||
- A POSIX `sh`; the login shell itself may be any shell.
|
||||
- A writable `/tmp` for the server socket.
|
||||
|
||||
==============================================================================
|
||||
4. Remote Environment *remote-ssh-configs*
|
||||
|
||||
The remote Nvim starts with `NVIM_APPNAME=nvim-remote`, which isolates its
|
||||
configuration and data directories from any existing Nvim installation on
|
||||
the remote host. See |$NVIM_APPNAME|.
|
||||
|
||||
Remote paths used:
|
||||
- Config: `~/.config/nvim-remote/`
|
||||
- Data: `~/.local/share/nvim-remote/`
|
||||
- Binary: `~/.local/share/nvim-remote/releases/{version}/bin/nvim`
|
||||
|
||||
The data directory persists across connections for fast reconnects. On
|
||||
teardown, only the headless process and SSH mux socket are cleaned up.
|
||||
|
||||
==============================================================================
|
||||
vim:tw=78:ts=8:ft=help:norl:
|
||||
@@ -1,4 +1,5 @@
|
||||
local uv = vim.uv
|
||||
local M = {}
|
||||
|
||||
--- @class vim.SystemOpts
|
||||
--- @inlinedoc
|
||||
@@ -45,6 +46,12 @@ local uv = vim.uv
|
||||
--- @field stdout? string `nil` if stdout is disabled or has a custom handler.
|
||||
--- @field stderr? string `nil` if stderr is disabled or has a custom handler.
|
||||
|
||||
--- @class (package) vim.SystemRunWaitResult : vim.SystemObj
|
||||
--- @field code? integer Exit code, if the process exited before the predicate matched.
|
||||
--- @field signal? integer Exit signal, if the process exited before the predicate matched.
|
||||
--- @field stdout string Captured stdout so far.
|
||||
--- @field stderr string Captured stderr so far.
|
||||
|
||||
--- @class (package) vim.SystemState
|
||||
--- @field cmd string[]
|
||||
--- @field handle? uv.uv_process_t
|
||||
@@ -78,7 +85,7 @@ end
|
||||
--- @class vim.SystemObj
|
||||
--- @field cmd string[] Command name and args
|
||||
--- @field pid integer Process ID
|
||||
--- @field private _state vim.SystemState
|
||||
--- @field package _state vim.SystemState
|
||||
local SystemObj = {}
|
||||
|
||||
--- @param state vim.SystemState
|
||||
@@ -211,6 +218,54 @@ function SystemObj:is_closing()
|
||||
return handle == nil or handle:is_closing() or false
|
||||
end
|
||||
|
||||
--- @nodoc
|
||||
--- Runs a command and waits until it exits or streamed output satisfies a predicate.
|
||||
---
|
||||
--- Unlike `vim.system(...):wait()`, this can return before the process exits. That is needed for
|
||||
--- long-lived commands such as `ssh -L ...`, which print a readiness marker and then keep running.
|
||||
---
|
||||
--- @param cmd string[] Command to execute.
|
||||
--- @param opts? { stdin?: string } `stdin` is written to the process, then closed.
|
||||
--- @param wait_until? fun(stdout: string, stderr: string): boolean Predicate checked after output.
|
||||
--- @param timeout? integer Time limit in ms.
|
||||
--- @return vim.SystemRunWaitResult
|
||||
function M.run_wait(cmd, opts, wait_until, timeout)
|
||||
vim.validate('cmd', cmd, 'table')
|
||||
vim.validate('opts', opts, 'table', true)
|
||||
vim.validate('wait_until', wait_until, 'function', true)
|
||||
vim.validate('timeout', timeout, 'number', true)
|
||||
|
||||
local obj = vim.system(cmd, { text = true, stdin = opts and opts.stdin }) --[[@as vim.SystemRunWaitResult]]
|
||||
local state = obj._state
|
||||
|
||||
local function stdout()
|
||||
return table.concat(state.stdout_data or {})
|
||||
end
|
||||
|
||||
local function stderr()
|
||||
return table.concat(state.stderr_data or {})
|
||||
end
|
||||
|
||||
local success = vim.wait(timeout or vim._maxint, function()
|
||||
return state.result ~= nil or (wait_until and wait_until(stdout(), stderr())) or false
|
||||
end, 50, true)
|
||||
|
||||
if not success then
|
||||
if not obj:is_closing() then
|
||||
obj:kill('sigterm')
|
||||
end
|
||||
obj:wait(1000)
|
||||
error('command timed out')
|
||||
end
|
||||
|
||||
obj.code = state.result and state.result.code or nil
|
||||
obj.signal = state.result and state.result.signal or nil
|
||||
obj.stdout = state.result and state.result.stdout or stdout()
|
||||
obj.stderr = state.result and state.result.stderr or stderr()
|
||||
|
||||
return obj
|
||||
end
|
||||
|
||||
--- @param output? fun(err: string?, data: string?)|boolean
|
||||
--- @param text? boolean
|
||||
--- @return uv.uv_stream_t? pipe
|
||||
@@ -542,3 +597,5 @@ function vim.system(cmd, opts, on_exit)
|
||||
end
|
||||
return run(cmd, opts, on_exit)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -666,6 +666,61 @@ local function check_external_tools()
|
||||
'Install curl using your package manager.',
|
||||
})
|
||||
end
|
||||
|
||||
if vim.fn.executable('ssh') == 1 then
|
||||
local ssh_path = vim.fn.exepath('ssh')
|
||||
local ssh_job = vim.system({ ssh_path, '-V' }, { text = true }):wait()
|
||||
-- ssh -V prints version to stderr
|
||||
local ssh_out = vim.trim(ssh_job.stderr or '')
|
||||
if ssh_job.code == 0 then
|
||||
health.ok(('%s (%s)'):format(ssh_out, ssh_path))
|
||||
|
||||
local version_str = ssh_out:match('OpenSSH_([%d%.]+)')
|
||||
local ver = version_str and vim.version.parse(version_str)
|
||||
if ver then
|
||||
if vim.fn.has('win32') == 1 then
|
||||
health.warn(
|
||||
'Win32-OpenSSH does not support ControlMaster (multiplexing). Remote SSH features might be degraded or unsupported.'
|
||||
)
|
||||
elseif ver >= vim.version.parse('4.0') then
|
||||
health.ok('OpenSSH version supports multiplexing')
|
||||
else
|
||||
health.warn(
|
||||
'OpenSSH version is older than 4.0. Multiplexing is not supported, remote connections may be slow or fail.'
|
||||
)
|
||||
end
|
||||
else
|
||||
health.warn('Could not determine OpenSSH version. Multiplexing support is unknown.')
|
||||
end
|
||||
|
||||
if vim.fn.has('win32') == 0 then
|
||||
local ssh_g = vim.system({ ssh_path, '-G', 'example.com' }):wait()
|
||||
if ssh_g.code == 0 then
|
||||
local ssh_cfg = ssh_g.stdout or ''
|
||||
local controlmaster = ssh_cfg:match('controlmaster%s+(%S+)')
|
||||
local controlpath = ssh_cfg:match('controlpath%s+(%S+)')
|
||||
if controlmaster and controlmaster ~= 'no' then
|
||||
health.ok(('SSH multiplexing configured (ControlMaster=%s)'):format(controlmaster))
|
||||
elseif controlpath and controlpath ~= 'none' then
|
||||
health.ok(('SSH ControlPath resolved to: %s'):format(controlpath))
|
||||
else
|
||||
health.info(
|
||||
'SSH multiplexing not configured in ssh_config. :connect will set it automatically.'
|
||||
)
|
||||
end
|
||||
else
|
||||
health.warn('`ssh -G` failed. Cannot verify SSH multiplexing configuration.')
|
||||
end
|
||||
end
|
||||
else
|
||||
health.warn('ssh is installed but failed to run `ssh -V`', { ssh_job.stderr })
|
||||
end
|
||||
else
|
||||
health.warn('ssh not found', {
|
||||
'Required for remote SSH connections (:connect ssh://).',
|
||||
'Install ssh using your package manager.',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function detect_terminal()
|
||||
|
||||
+382
-37
@@ -22,8 +22,15 @@ local function is_multi_value_directive(param)
|
||||
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 string[] The parsed host names in the configuration
|
||||
---@return vim.net.SshHost[] The parsed host configurations
|
||||
function M.parse_ssh_config(text)
|
||||
local i = 1
|
||||
local line = 1
|
||||
@@ -176,66 +183,404 @@ function M.parse_ssh_config(text)
|
||||
return node
|
||||
end
|
||||
|
||||
local hostnames = {}
|
||||
---@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 vim.list_contains(hostnames, 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
|
||||
-- This is done just to assign the type
|
||||
node.value = node.value ---@type string[]
|
||||
if node.param:lower() == 'match' and node.value then
|
||||
local current = nil
|
||||
for ind, val in ipairs(node.value) do
|
||||
if
|
||||
val:lower() == 'host'
|
||||
and ind + 1 <= #node.value
|
||||
and is_valid(assert(node.value[ind + 1]))
|
||||
then
|
||||
current = node.value[ind + 1]
|
||||
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
|
||||
if current then
|
||||
table.insert(hostnames, current)
|
||||
end
|
||||
elseif node.param:lower() == 'host' and node.value then
|
||||
for _, value in ipairs(node.value) do
|
||||
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(hostnames, value)
|
||||
table.insert(valid, value)
|
||||
end
|
||||
end
|
||||
add_aliases(valid)
|
||||
else
|
||||
local val = (type(node.value) == 'string' and node.value or nil) --[[@as string?]]
|
||||
if val then
|
||||
for _, h in ipairs(current_hosts) do
|
||||
if lp == 'hostname' then
|
||||
h.hostname = val
|
||||
elseif lp == 'user' then
|
||||
h.user = val
|
||||
elseif lp == 'port' then
|
||||
h.port = val
|
||||
elseif lp == 'identityfile' then
|
||||
h.identity_file = val
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
flush()
|
||||
|
||||
return hostnames
|
||||
return hosts
|
||||
end
|
||||
|
||||
---@param filename string
|
||||
---@return string[] The hostnames configured in the file located at filename
|
||||
---@return vim.net.SshHost[] The host configurations in the file
|
||||
function M.parse_config(filename)
|
||||
local file = io.open(filename, 'r')
|
||||
if not file then
|
||||
error('Cannot read ssh configuration file')
|
||||
end
|
||||
local config_string = file:read('*a')
|
||||
file:close()
|
||||
|
||||
return M.parse_ssh_config(config_string)
|
||||
local text = vim.fn.readblob(filename)
|
||||
return M.parse_ssh_config(text)
|
||||
end
|
||||
|
||||
---@return string[] The hostnames configured in the ssh configuration file
|
||||
--- located at "~/.ssh/config".
|
||||
--- Note: This does not currently process `Include` directives in the
|
||||
--- configuration file.
|
||||
function M.get_hosts()
|
||||
local config_path = vim.fs.normalize('~/.ssh/config') ---@type string
|
||||
---@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 currently 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
|
||||
|
||||
return M.parse_config(config_path)
|
||||
local _log --- @type vim.Log?
|
||||
|
||||
--- 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', current_level = vim.log.levels.INFO })
|
||||
end
|
||||
return _log
|
||||
end
|
||||
|
||||
---@param msg string
|
||||
---@param level? integer vim.log.levels.* (default INFO)
|
||||
local function notify(msg, level)
|
||||
level = level or vim.log.levels.INFO
|
||||
if level >= vim.log.levels.ERROR then
|
||||
log().error(msg)
|
||||
elseif level >= vim.log.levels.WARN then
|
||||
log().warn(msg)
|
||||
else
|
||||
log().info(msg)
|
||||
end
|
||||
local function show()
|
||||
vim.notify('remote-ssh: ' .. msg, level)
|
||||
vim.cmd.redraw()
|
||||
end
|
||||
if vim.in_fast_event() then
|
||||
vim.schedule(show)
|
||||
else
|
||||
show()
|
||||
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 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}
|
||||
---@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.fn.stdpath('cache') .. '/ssh' --[[@as string]]
|
||||
vim.fn.mkdir(mux_dir, 'p')
|
||||
-- `%C` is SSH's hash of (localhost, 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.
|
||||
local mux_path = mux_dir .. '/mux_%C'
|
||||
local ssh_cmd = {
|
||||
'ssh',
|
||||
'-T',
|
||||
'-o',
|
||||
'ControlMaster=auto',
|
||||
'-o',
|
||||
'ControlPath=' .. mux_path,
|
||||
'-o',
|
||||
'ControlPersist=10m',
|
||||
}
|
||||
if opts.ssh_args then
|
||||
vim.list_extend(ssh_cmd, opts.ssh_args)
|
||||
end
|
||||
if ssh_uri.port then
|
||||
table.insert(ssh_cmd, '-p')
|
||||
table.insert(ssh_cmd, ssh_uri.port)
|
||||
end
|
||||
---@type string
|
||||
local target = ssh_uri.host
|
||||
if ssh_uri.user then
|
||||
target = ssh_uri.user .. '@' .. ssh_uri.host
|
||||
end
|
||||
table.insert(ssh_cmd, target)
|
||||
|
||||
if opts.remote_cmd then
|
||||
vim.list_extend(ssh_cmd, opts.remote_cmd)
|
||||
end
|
||||
|
||||
return ssh_cmd
|
||||
end
|
||||
|
||||
--- Runs `script` via `sh -s` over stdin, so the remote login shell never re-parses it
|
||||
---
|
||||
---@param uri {host:string, user?:string, port?:string}
|
||||
---@param script string POSIX shell script.
|
||||
---@param opts? { ssh_args?: string[], wait_until?: fun(stdout: string, stderr: string): boolean }
|
||||
---@return vim.SystemRunWaitResult
|
||||
local function run_script(uri, script, opts)
|
||||
opts = opts or {}
|
||||
local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = { 'sh', '-s' } })
|
||||
log().debug('run_script: running', ssh_cmd)
|
||||
return core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000)
|
||||
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 = core_system().run_wait(ssh_cmd, nil, nil, 300000)
|
||||
log().debug('get_system_info: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
|
||||
if obj.code ~= 0 then
|
||||
error(
|
||||
'Failed to detect remote system info: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout)
|
||||
)
|
||||
end
|
||||
|
||||
local lines = vim.split(vim.trim(obj.stdout), '\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: ' .. 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
|
||||
|
||||
--- Installs the matching Nvim release on the remote host, unless present.
|
||||
--- Uses a per-version dir, never touching the user's own Nvim.
|
||||
---
|
||||
---@param uri {host:string, user?:string, port?:string}
|
||||
---@param os string
|
||||
---@param arch string
|
||||
---@return string nvim Remote path of the `nvim` binary, as a shell word.
|
||||
local function check_and_install(uri, 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-' .. 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(string.format('Unsupported OS/Arch combination: %s/%s', os, arch))
|
||||
end
|
||||
|
||||
local release_name = string.format('nvim-%s-%s', target_os, target_arch)
|
||||
local release_url = string.format(
|
||||
'https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz',
|
||||
tag,
|
||||
release_name
|
||||
)
|
||||
local dest = '"$HOME"/.local/share/nvim-remote/releases/' .. install_name
|
||||
|
||||
local remote_script = string.format(
|
||||
[[
|
||||
set -eu
|
||||
dest=%s
|
||||
if [ -x "$dest/bin/nvim" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Installing Nvim %s..." >&2
|
||||
mkdir -p "${dest%%/*}"
|
||||
tmp=$(mktemp -d "$dest.XXXXXX")
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
curl -fsSL -o "$tmp/nvim.tar.gz" '%s'
|
||||
tar -xzf "$tmp/nvim.tar.gz" -C "$tmp"
|
||||
# atomic rename; keep a concurrent install that finished first
|
||||
[ -e "$dest" ] || mv "$tmp/%s" "$dest"
|
||||
]],
|
||||
dest,
|
||||
install_name,
|
||||
release_url,
|
||||
release_name
|
||||
)
|
||||
|
||||
log().debug('check_and_install: install', install_name, 'os', target_os, 'arch', target_arch)
|
||||
|
||||
local obj = run_script(uri, remote_script)
|
||||
log().debug('check_and_install: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
|
||||
if obj.code ~= 0 then
|
||||
error('Installation failed: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout))
|
||||
end
|
||||
|
||||
return dest .. '/bin/nvim'
|
||||
end
|
||||
|
||||
--- @param uri_str string
|
||||
--- @return string local_socket path to the local forwarded socket
|
||||
--- @return vim.net.SshUri uri parsed SSH URI for cleanup
|
||||
--- @return fun() teardown function to close the SSH master connection
|
||||
function M.start(uri_str)
|
||||
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
|
||||
notify('Connecting to ' .. uri_str .. '...')
|
||||
local os, arch = M.get_system_info(uri)
|
||||
|
||||
notify('Checking remote Nvim install...')
|
||||
local nvim = check_and_install(uri, os, arch)
|
||||
|
||||
local local_sock = vim.fn.tempname() .. '_remote_nvim.sock'
|
||||
|
||||
-- Fresh private dir per session: `mkdir` fails if it exists,
|
||||
-- so nobody can plant a socket
|
||||
local token = assert(vim.uv.random(8)):gsub('.', function(c)
|
||||
return ('%02x'):format(c:byte())
|
||||
end)
|
||||
local remote_dir = '/tmp/nvim-remote.' .. token
|
||||
local remote_sock = remote_dir .. '/nvim.sock'
|
||||
|
||||
local remote_cmd = string.format(
|
||||
[[
|
||||
dir='%s'
|
||||
sock="$dir/nvim.sock"
|
||||
mkdir -m 700 "$dir" || exit 1
|
||||
NVIM_PID=
|
||||
trap 'kill $NVIM_PID 2>/dev/null; rm -rf "$dir"' EXIT
|
||||
trap 'exit 1' HUP INT TERM PIPE
|
||||
NVIM_APPNAME=nvim-remote %s --headless --listen "$sock" </dev/null &
|
||||
NVIM_PID=$!
|
||||
while [ ! -S "$sock" ]; do
|
||||
if ! kill -0 $NVIM_PID 2>/dev/null; then
|
||||
echo "NVIM_CRASHED"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "NVIM_READY"
|
||||
# no pty, so no SIGHUP on disconnect: a failed heartbeat write triggers the EXIT trap
|
||||
while kill -0 $NVIM_PID 2>/dev/null; do
|
||||
sleep 1
|
||||
printf '\n' 2>/dev/null || exit 1
|
||||
done
|
||||
]],
|
||||
remote_dir,
|
||||
nvim
|
||||
)
|
||||
|
||||
local forward = local_sock .. ':' .. remote_sock
|
||||
|
||||
notify('Establishing SSH tunnel...')
|
||||
log().debug('start: local_sock', local_sock)
|
||||
local tunnel = run_script(uri, remote_cmd, {
|
||||
ssh_args = { '-L', forward },
|
||||
wait_until = function(stdout)
|
||||
return stdout:match('NVIM_READY') ~= nil
|
||||
end,
|
||||
})
|
||||
log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)
|
||||
|
||||
-- no NVIM_READY means the tunnel already exited
|
||||
if not tunnel.stdout:match('NVIM_READY') then
|
||||
-- the forward lives in the ssh master, which outlives this session
|
||||
vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait()
|
||||
local msg = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'
|
||||
or 'SSH tunnel exited before the remote Nvim was ready'
|
||||
log().error(msg, 'code', tunnel.code, 'stderr', tunnel.stderr)
|
||||
local detail = vim.trim(tunnel.stderr)
|
||||
error(detail ~= '' and (msg .. ': ' .. detail) or msg)
|
||||
end
|
||||
|
||||
notify('Connected to ' .. uri_str)
|
||||
|
||||
local cleaned_up = false
|
||||
|
||||
local function teardown()
|
||||
if cleaned_up then
|
||||
return
|
||||
end
|
||||
cleaned_up = true
|
||||
log().info('teardown: closing ssh mux for ' .. uri_str)
|
||||
local stop_cmd = get_ssh_cmd(uri, { ssh_args = { '-O', 'exit' } })
|
||||
vim.system(stop_cmd):wait()
|
||||
-- Wait for the original tunnel process to close its stdio handles.
|
||||
if not tunnel:is_closing() then
|
||||
tunnel:wait(1000)
|
||||
end
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd('VimLeavePre', {
|
||||
callback = teardown,
|
||||
})
|
||||
|
||||
return local_sock, uri, teardown
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -215,4 +215,34 @@ function M.to_buf(uri)
|
||||
return vim.fn.bufadd(M.to_fname(uri))
|
||||
end
|
||||
|
||||
---@class vim.net.SshUri
|
||||
---@nodoc
|
||||
---@field host string
|
||||
---@field user? string
|
||||
---@field port? string
|
||||
|
||||
---@param str string
|
||||
---@return vim.net.SshUri
|
||||
function M._parse_ssh_uri(str)
|
||||
---@type vim.net.SshUri
|
||||
local uri = { host = '' }
|
||||
---@type string?
|
||||
local scheme_match = str:match('^ssh://(.*)')
|
||||
if scheme_match then
|
||||
str = scheme_match
|
||||
end
|
||||
local user, rest = str:match('^(.-)@(.*)$')
|
||||
if user and rest then
|
||||
uri.user = user
|
||||
str = rest
|
||||
end
|
||||
local port_match = str:match(':(%d+)$')
|
||||
if port_match then
|
||||
uri.port = port_match
|
||||
str = str:sub(1, -(#port_match + 2))
|
||||
end
|
||||
uri.host = str
|
||||
return uri
|
||||
end
|
||||
|
||||
return M
|
||||
+24
-1
@@ -5912,13 +5912,36 @@ static void ex_detach(exarg_T *eap)
|
||||
/// Connects the current UI to a different server
|
||||
///
|
||||
/// ":connect <address>" detaches the current UI and connects to the given server.
|
||||
/// ":connect ssh://<uri>" bootstraps a remote Nvim over SSH, then connects.
|
||||
/// ":connect! <address>" stops the current server if no other UIs are attached, then connects to the given server.
|
||||
static void ex_connect(exarg_T *eap)
|
||||
{
|
||||
char *addr = eap->arg;
|
||||
typval_T ssh_rettv = { .v_type = VAR_UNKNOWN };
|
||||
|
||||
// SSH URI: bootstrap remote Nvim, get local forwarded socket
|
||||
if (strncmp(addr, "ssh://", 6) == 0) {
|
||||
typval_T argvars[2];
|
||||
argvars[0].v_type = VAR_STRING;
|
||||
argvars[0].vval.v_string = addr;
|
||||
argvars[1].v_type = VAR_UNKNOWN;
|
||||
|
||||
nlua_call_typval("vim.net._ssh", "start", argvars, &ssh_rettv);
|
||||
|
||||
if (ssh_rettv.v_type != VAR_STRING || ssh_rettv.vval.v_string == NULL) {
|
||||
emsg("E6101: SSH connection failed");
|
||||
tv_clear(&ssh_rettv);
|
||||
return;
|
||||
}
|
||||
|
||||
addr = ssh_rettv.vval.v_string;
|
||||
}
|
||||
|
||||
bool stop_server = eap->forceit ? (ui_active() == 1) : false;
|
||||
|
||||
Error err = ERROR_INIT;
|
||||
remote_ui_connect(current_ui, eap->arg, &err);
|
||||
remote_ui_connect(current_ui, addr, &err);
|
||||
tv_clear(&ssh_rettv);
|
||||
|
||||
if (ERROR_SET(&err)) {
|
||||
emsg(err.msg);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
local t = require('test.testutil')
|
||||
local n = require('test.functional.testnvim')()
|
||||
local parser = require('vim.net._ssh')
|
||||
local describe, it = t.describe, t.it
|
||||
local before_each, after_each = t.before_each, t.after_each
|
||||
local clear = n.clear
|
||||
local eq = t.eq
|
||||
local is_os = t.is_os
|
||||
local skip = t.skip
|
||||
|
||||
describe('SSH parser', function()
|
||||
it('parses SSH configuration strings', function()
|
||||
@@ -40,13 +45,18 @@ describe('SSH parser', function()
|
||||
IdentitiesOnly yes
|
||||
]]
|
||||
|
||||
eq({
|
||||
'dev',
|
||||
'prod',
|
||||
'test',
|
||||
'quoted string',
|
||||
'gh',
|
||||
}, parser.parse_ssh_config(config))
|
||||
local hosts = parser.parse_ssh_config(config)
|
||||
eq(
|
||||
{ 'dev', 'prod', 'test', 'quoted string', 'foo', 'gh' },
|
||||
vim.tbl_map(function(h)
|
||||
return h.alias
|
||||
end, hosts)
|
||||
)
|
||||
eq('dev.example.com', hosts[1].hostname)
|
||||
eq('devuser', hosts[1].user)
|
||||
eq('2222', hosts[1].port)
|
||||
eq('198.51.100.10', hosts[2].hostname)
|
||||
eq('admin', hosts[2].user)
|
||||
end)
|
||||
|
||||
it('ignores repeated and trailing separators', function()
|
||||
@@ -58,7 +68,12 @@ describe('SSH parser', function()
|
||||
'',
|
||||
}, '\n')
|
||||
|
||||
eq({ 'alpha', 'beta' }, parser.parse_ssh_config(config))
|
||||
eq(
|
||||
{ 'alpha', 'beta' },
|
||||
vim.tbl_map(function(h)
|
||||
return h.alias
|
||||
end, parser.parse_ssh_config(config))
|
||||
)
|
||||
end)
|
||||
|
||||
it('fails when a quote is not closed', function()
|
||||
@@ -89,3 +104,310 @@ describe('SSH parser', function()
|
||||
eq(false, ok)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('vim.net._ssh', function()
|
||||
local fake_bin_dir
|
||||
|
||||
local function setup_fake_ssh(behavior)
|
||||
behavior = behavior or {}
|
||||
local is_windows = package.config:sub(1, 1) == '\\'
|
||||
fake_bin_dir = t.tmpname()
|
||||
os.remove(fake_bin_dir)
|
||||
vim.uv.fs_mkdir(fake_bin_dir, 511)
|
||||
local fake_ssh_path = fake_bin_dir .. (is_windows and '/ssh.cmd' or '/ssh')
|
||||
local fake_tunnel_pid = fake_bin_dir .. '/tunnel.pid'
|
||||
|
||||
local script
|
||||
if is_windows then
|
||||
script = t.dedent([=[
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
set "ARGS=%*"
|
||||
|
||||
echo(!ARGS! | findstr /C:"uname -s && uname -m" >nul
|
||||
if not errorlevel 1 (
|
||||
]=] .. (behavior.uname or [=[
|
||||
echo Linux
|
||||
echo x86_64
|
||||
]=]) .. [=[
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo(!ARGS! | findstr /C:"-L" >nul
|
||||
if not errorlevel 1 (
|
||||
echo(!ARGS! | findstr /C:"ControlMaster" >nul
|
||||
if errorlevel 1 (
|
||||
echo FAIL: Multiplexing flags missing! 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
]=] .. (behavior.tunnel or [=[
|
||||
set "SOCK="
|
||||
set "NEXT_L="
|
||||
for %%A in (%*) do (
|
||||
if defined NEXT_L (
|
||||
set "SOCK=%%~A"
|
||||
set "NEXT_L="
|
||||
) else if "%%~A"=="-L" (
|
||||
set "NEXT_L=1"
|
||||
)
|
||||
)
|
||||
if not defined SOCK (
|
||||
echo Unexpected SSH command: !ARGS! 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
> "!SOCK!" type NUL
|
||||
exit /b 0
|
||||
]=]) .. [=[
|
||||
)
|
||||
|
||||
echo(!ARGS! | findstr /C:"TARGET_VER" >nul
|
||||
if not errorlevel 1 (
|
||||
]=] .. (behavior.installer or [=[
|
||||
echo Installing Neovim... 1>&2
|
||||
exit /b 0
|
||||
]=]) .. [=[
|
||||
)
|
||||
|
||||
echo(!ARGS! | findstr /C:"-O" >nul
|
||||
if not errorlevel 1 (
|
||||
echo(!ARGS! | findstr /C:"exit" >nul
|
||||
if not errorlevel 1 exit /b 0
|
||||
)
|
||||
|
||||
echo Unexpected SSH command: !ARGS! 1>&2
|
||||
exit /b 1
|
||||
]=])
|
||||
else
|
||||
script = t.dedent([=[
|
||||
#!/usr/bin/env bash
|
||||
ARGS="$*"
|
||||
if [[ "$ARGS" == *" sh -s" ]]; then
|
||||
ARGS="$ARGS $(cat)"
|
||||
fi
|
||||
PID_FILE=]=] .. string.format('%q', fake_tunnel_pid) .. [=[
|
||||
|
||||
ARGS_FILE=]=] .. string.format('%q', fake_bin_dir .. '/tunnel.args') .. [=[
|
||||
|
||||
if [[ "$ARGS" == *"uname -s && uname -m"* ]]; then
|
||||
]=] .. (behavior.uname or [=[
|
||||
echo "Linux"
|
||||
echo "x86_64"
|
||||
]=]) .. [=[
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ARGS" == *"-O cancel -L"* ]]; then
|
||||
touch ]=] .. string.format('%q', fake_bin_dir .. '/forward.cancelled') .. [=[
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ARGS" == *"-L"* ]]; then
|
||||
if [[ "$ARGS" != *"ControlMaster"* ]]; then
|
||||
echo "FAIL: Multiplexing flags missing!" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARGS" != *"sh -s"*"NVIM_READY"* ]]; then
|
||||
echo "FAIL: tunnel script not fed to sh -s" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$ARGS" > "$ARGS_FILE"
|
||||
]=] .. (behavior.tunnel or [=[
|
||||
SOCK=$(echo "$ARGS" | grep -oE '\-L [^:]+' | cut -d' ' -f2)
|
||||
sleep 60 &
|
||||
SLEEP_PID=$!
|
||||
echo "$$ $SLEEP_PID" > "$PID_FILE"
|
||||
trap 'kill "$SLEEP_PID" 2>/dev/null || true; rm -f "$PID_FILE"; exit 0' TERM INT EXIT
|
||||
echo "NVIM_READY"
|
||||
touch "$SOCK"
|
||||
wait "$SLEEP_PID"
|
||||
]=]) .. [=[
|
||||
fi
|
||||
|
||||
if [[ "$ARGS" == *"releases/download/"* ]]; then
|
||||
printf '%s' "$ARGS" > ]=] .. string.format('%q', fake_bin_dir .. '/install.args') .. [=[
|
||||
|
||||
]=] .. (behavior.installer or [=[
|
||||
echo "Installing Neovim..." >&2
|
||||
exit 0
|
||||
]=]) .. [=[
|
||||
fi
|
||||
|
||||
if [[ "$ARGS" == *"-O"*"exit"* ]]; then
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
set -- $(cat "$PID_FILE")
|
||||
kill "$2" 2>/dev/null || true
|
||||
kill "$1" 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Unexpected SSH command: $ARGS" >&2
|
||||
exit 1
|
||||
]=])
|
||||
end
|
||||
t.write_file(fake_ssh_path, script)
|
||||
vim.uv.fs_chmod(fake_ssh_path, 493)
|
||||
|
||||
local path_sep = is_windows and ';' or ':'
|
||||
n.exec_lua(string.format(
|
||||
[[
|
||||
_G.orig_path = vim.fn.getenv('PATH')
|
||||
vim.fn.setenv('PATH', %q .. %q .. _G.orig_path)
|
||||
]],
|
||||
fake_bin_dir,
|
||||
path_sep
|
||||
))
|
||||
end
|
||||
|
||||
local function teardown_fake_ssh()
|
||||
if fake_bin_dir then
|
||||
n.exec_lua([[
|
||||
if _G.orig_path then
|
||||
vim.fn.setenv('PATH', _G.orig_path)
|
||||
end
|
||||
]])
|
||||
end
|
||||
end
|
||||
|
||||
before_each(function()
|
||||
clear()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
teardown_fake_ssh()
|
||||
end)
|
||||
|
||||
describe('remote-ssh (Introspection)', function()
|
||||
it('detects linux x86_64 successfully', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh({
|
||||
uname = [[
|
||||
echo "Linux"
|
||||
echo "x86_64"
|
||||
]],
|
||||
})
|
||||
local res = n.exec_lua([[
|
||||
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
|
||||
]])
|
||||
eq('linux', res[1])
|
||||
eq('x86_64', res[2])
|
||||
end)
|
||||
|
||||
it('detects macos arm64 successfully', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh({
|
||||
uname = [[
|
||||
echo "Darwin"
|
||||
echo "arm64"
|
||||
]],
|
||||
})
|
||||
local res = n.exec_lua([[
|
||||
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
|
||||
]])
|
||||
eq('darwin', res[1])
|
||||
eq('arm64', res[2])
|
||||
end)
|
||||
|
||||
it('fails fast on Windows', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh({
|
||||
uname = [[
|
||||
echo "MSYS_NT-10.0-19045"
|
||||
echo "x86_64"
|
||||
]],
|
||||
})
|
||||
local status, err = pcall(function()
|
||||
n.exec_lua([[
|
||||
require('vim.net._ssh').get_system_info({ host = 'server' })
|
||||
]])
|
||||
end)
|
||||
eq(false, status)
|
||||
assert(string.match(err, 'Windows targets are not supported'))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('remote-ssh (Orchestration)', function()
|
||||
it('returns local socket with key-based auth', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh()
|
||||
local sock = n.exec_lua([[
|
||||
local sock, _, teardown = require('vim.net._ssh').start('user@test-server')
|
||||
teardown()
|
||||
return sock
|
||||
]])
|
||||
|
||||
assert(sock:match('_remote_nvim%.sock$'))
|
||||
end)
|
||||
|
||||
it('installs the matching release privately and runs it', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh()
|
||||
local release = n.exec_lua([[
|
||||
local _, _, teardown = require('vim.net._ssh').start('user@test-server')
|
||||
teardown()
|
||||
local v = vim.version()
|
||||
local base = ('v%d.%d.%d'):format(v.major, v.minor, v.patch)
|
||||
return v.prerelease and ('nightly-' .. base) or base
|
||||
]])
|
||||
local install = t.read_file(fake_bin_dir .. '/install.args')
|
||||
local tag = release:match('^nightly') and 'nightly' or release
|
||||
t.matches('releases/download/' .. vim.pesc(tag) .. '/nvim%-linux%-x86_64%.tar%.gz', install)
|
||||
t.matches('dest="%$HOME"/%.local/share/nvim%-remote/releases/' .. vim.pesc(release), install)
|
||||
eq(nil, install:find('.local/bin', 1, true))
|
||||
t.matches(
|
||||
'"%$HOME"/%.local/share/nvim%-remote/releases/'
|
||||
.. vim.pesc(release)
|
||||
.. '/bin/nvim %-%-headless',
|
||||
t.read_file(fake_bin_dir .. '/tunnel.args')
|
||||
)
|
||||
end)
|
||||
|
||||
it('fails when ssh exits before the remote Nvim is ready', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh({
|
||||
tunnel = [[
|
||||
echo "channel 0: open failed: administratively prohibited" >&2
|
||||
exit 255
|
||||
]],
|
||||
})
|
||||
t.matches(
|
||||
'SSH tunnel exited before the remote Nvim was ready: channel 0: open failed',
|
||||
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').start('user@test-server')]])
|
||||
)
|
||||
eq(true, vim.uv.fs_stat(fake_bin_dir .. '/forward.cancelled') ~= nil)
|
||||
end)
|
||||
|
||||
it('fails when the remote Nvim crashes during startup', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh({
|
||||
tunnel = [[
|
||||
echo "NVIM_CRASHED"
|
||||
exit 1
|
||||
]],
|
||||
})
|
||||
t.matches(
|
||||
'Remote Nvim crashed during startup',
|
||||
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').start('user@test-server')]])
|
||||
)
|
||||
end)
|
||||
|
||||
it('serves each session from a fresh private remote directory', function()
|
||||
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
|
||||
setup_fake_ssh()
|
||||
local function remote_dir()
|
||||
n.exec_lua([[
|
||||
local _, _, teardown = require('vim.net._ssh').start('user@test-server')
|
||||
teardown()
|
||||
]])
|
||||
local args = t.read_file(fake_bin_dir .. '/tunnel.args')
|
||||
t.matches('mkdir %-m 700 "%$dir"', args)
|
||||
return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')
|
||||
end
|
||||
|
||||
local dir1, dir2 = remote_dir(), remote_dir()
|
||||
assert(dir1 and dir2, 'remote socket path not found')
|
||||
assert(dir1 ~= dir2, 'remote socket directory reused across sessions')
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -136,6 +136,50 @@ describe('vim.system', function()
|
||||
end)
|
||||
end)
|
||||
|
||||
it('run_wait() can return before process exit', function()
|
||||
t.skip(t.is_os('win'), 'shell command is POSIX-only')
|
||||
|
||||
exec_lua(function()
|
||||
local res
|
||||
local ok, err = pcall(function()
|
||||
res = require('vim._core.system').run_wait(
|
||||
{ 'bash', '-c', 'echo READY; sleep 10' },
|
||||
nil,
|
||||
function(stdout)
|
||||
return stdout:match('READY') ~= nil
|
||||
end,
|
||||
10000
|
||||
)
|
||||
|
||||
assert(res)
|
||||
assert(res.code == nil)
|
||||
assert(res.stdout == 'READY\n')
|
||||
assert(vim.api.nvim_get_proc(res.pid), 'process already exited')
|
||||
end)
|
||||
|
||||
if res then
|
||||
if not res:is_closing() then
|
||||
res:kill('sigterm')
|
||||
end
|
||||
res:wait(1000)
|
||||
end
|
||||
if not ok then
|
||||
error(err)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
it('run_wait() writes stdin', function()
|
||||
t.skip(t.is_os('win'), 'shell command is POSIX-only')
|
||||
|
||||
eq(
|
||||
'hello',
|
||||
exec_lua(function()
|
||||
return require('vim._core.system').run_wait({ 'cat' }, { stdin = 'hello' }, nil, 10000).stdout
|
||||
end)
|
||||
)
|
||||
end)
|
||||
|
||||
it('SystemObj:wait() does not process non-fast events #27292', function()
|
||||
eq(
|
||||
false,
|
||||
|
||||
@@ -362,4 +362,33 @@ describe('vim.uri', function()
|
||||
{},
|
||||
}, parsed)
|
||||
end)
|
||||
|
||||
describe('SSH URI parser', function()
|
||||
it('parses ssh://user@host:port', function()
|
||||
local uri = exec_lua(function()
|
||||
return require('vim.uri')._parse_ssh_uri('ssh://root@localhost:2222')
|
||||
end)
|
||||
eq('root', uri.user)
|
||||
eq('localhost', uri.host)
|
||||
eq('2222', uri.port)
|
||||
end)
|
||||
|
||||
it('parses user@host', function()
|
||||
local uri = exec_lua(function()
|
||||
return require('vim.uri')._parse_ssh_uri('admin@server.local')
|
||||
end)
|
||||
eq('admin', uri.user)
|
||||
eq('server.local', uri.host)
|
||||
eq(nil, uri.port)
|
||||
end)
|
||||
|
||||
it('parses host only', function()
|
||||
local uri = exec_lua(function()
|
||||
return require('vim.uri')._parse_ssh_uri('myalias')
|
||||
end)
|
||||
eq(nil, uri.user)
|
||||
eq('myalias', uri.host)
|
||||
eq(nil, uri.port)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Reference in new issue
Block a user