diff --git a/Dockerfile b/Dockerfile index 0003d29..1c1a2bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ RUN mkdir -p $opt_lsp_dir \ && ln -s $opt_bin_dir/lua-language-server /usr/local/bin/lua-language-server # Install distant binary and make sure its in a path for everyone -ARG distant_version=0.20.0-alpha.12 +ARG distant_version=0.20.0-alpha.13 ARG distant_host=x86_64-unknown-linux-musl RUN curl -L sh.distant.dev | sh -s -- --install-dir "$opt_bin_dir" --distant-version $distant_version --distant-host $distant_host --run-as-admin \ && ln -s "$opt_bin_dir/distant" /usr/local/bin/distant \ diff --git a/lua/distant-core/builder/init.lua b/lua/distant-core/builder/init.lua index 0d3d548..c1476f9 100644 --- a/lua/distant-core/builder/init.lua +++ b/lua/distant-core/builder/init.lua @@ -56,9 +56,10 @@ function M.shell(cmd) end --- @param cmd string|string[] +--- @param use_cmd_arg? boolean --- @return distant.core.builder.SpawnCmdBuilder -function M.spawn(cmd) - return DistantSpawnCmdBuilder:new(cmd) +function M.spawn(cmd, use_cmd_arg) + return DistantSpawnCmdBuilder:new(cmd, use_cmd_arg) end return M diff --git a/lua/distant-core/builder/spawn.lua b/lua/distant-core/builder/spawn.lua index 7ad661b..f5501b9 100644 --- a/lua/distant-core/builder/spawn.lua +++ b/lua/distant-core/builder/spawn.lua @@ -7,8 +7,9 @@ M.__index = M --- Creates a new `spawn` cmd --- @param cmd string|string[] #command to execute on the remote machine +--- @param use_cmd_arg? boolean # if true, will pass `cmd` as str via `--cmd ` instead of using `-- ` --- @return distant.core.builder.SpawnCmdBuilder -function M:new(cmd) +function M:new(cmd, use_cmd_arg) local instance = {} setmetatable(instance, M) @@ -22,6 +23,7 @@ function M:new(cmd) allowed = { 'config', 'cache', + 'cmd', 'connection', 'current-dir', 'environment', @@ -29,11 +31,18 @@ function M:new(cmd) 'log-level', 'lsp', 'pty', + 'shell', 'unix-socket', 'windows-pipe', } }) - :set_tail(cmd) + + if use_cmd_arg then + --- @cast instance distant.core.builder.SpawnCmdBuilder + instance = instance:set_cmd(cmd) + else + instance.cmd = instance.cmd:set_tail(cmd) + end return instance end @@ -64,6 +73,39 @@ function M:set_cache(path) return self end +--- Sets `--cmd ` +--- @param cmd string +--- @return distant.core.builder.SpawnCmdBuilder +function M:set_cmd(cmd) + vim.validate({ cmd = { cmd, 'string' } }) + + -- NOTE: Normally, when a string is set using the + -- builder, it is NOT quoted, which means that + -- cmd = "echo hello" would turn into + -- `--cmd echo hello`. So, we need to provide + -- quoting for the value. + -- + -- To that affect, we are going to check if the + -- trimmed command begins and ends with matching + -- single or double quotes. If not, we wrap it in + -- single quotes for now, unless using cmd.exe, + -- in which case we wrap in double quotes. + cmd = vim.trim(cmd) + if ( + not (vim.startswith(cmd, '"') and vim.endswith(cmd, '"')) + and not (vim.startswith(cmd, "'") and vim.endswith(cmd, "'")) + ) then + if vim.o.shell == 'cmd.exe' then + cmd = '"' .. cmd:gsub('"', '""') .. '"' + else + cmd = "'" .. cmd:gsub("'", "'\\''") .. "'" + end + end + + self.cmd:set('cmd', cmd) + return self +end + --- Sets `--connection ` --- @param id string --- @return distant.core.builder.SpawnCmdBuilder @@ -134,6 +176,19 @@ function M:set_pty() return self end +--- Sets `--shell` or `--shell ` +--- @param value boolean|string +--- @return distant.core.builder.SpawnCmdBuilder +function M:set_shell(value) + vim.validate({ value = { value, { 'boolean', 'string' } } }) + if value == true then + self.cmd:set('shell') + else + self.cmd:set('shell', value) + end + return self +end + --- Sets `--unix-socket ` --- @param path string --- @return distant.core.builder.SpawnCmdBuilder diff --git a/lua/distant-core/client.lua b/lua/distant-core/client.lua index b700766..dbda87c 100644 --- a/lua/distant-core/client.lua +++ b/lua/distant-core/client.lua @@ -1,5 +1,6 @@ local Api = require('distant-core.api') local builder = require('distant-core.builder') +local Job = require('distant-core.job') local log = require('distant-core.log') local utils = require('distant-core.utils') @@ -283,6 +284,44 @@ function M:spawn_shell(opts) return job_id, bufnr end +--- @class distant.core.client.SpawnWrapOpts +--- @field cmd string|string[] +--- @field cwd? string +--- @field env? table +--- @field shell? string|string[]|true + +--- Spawns a `cmd` created using the `wrap` method, +--- buffering stdout and stderr as it is received. +--- +--- @param opts distant.core.client.SpawnWrapOpts +--- @param cb? fun(err?:string, exit_status:distant.core.job.ExitStatus) +--- @return distant.core.Job +function M:spawn_wrap(opts, cb) + vim.validate({ + opts = { opts, 'table' }, + cb = { cb, validate_callable({ optional = true }) }, + }) + + local cmd = self:wrap({ + cmd = opts.cmd, + cwd = opts.cwd, + env = opts.env, + shell = opts.shell, + }) + + local job = Job:new({ buffer_stdout = true, buffer_stderr = true }) + + -- Start the job, passing in our optional callback when it's done, + -- and only explicitly error if we do not have a callback to + -- report the error + local ok = job:start({ cmd = cmd }, cb) + if not ok and not cb then + error('Failed to spawn cmd: ' .. vim.inspect(cmd)) + end + + return job +end + --- @class distant.core.client.WrapOpts --- @field cmd? string|string[] --- @field lsp? string|string[] @@ -291,10 +330,13 @@ end --- @field env? table --- @field scheme? string ---- Wraps cmd, lsp, or shell to be invoked via distant. Returns +--- Wraps cmd, lsp, or shell to be invoked via distant for this client. Returns --- a string if the input is a string, or a list if the input --- is a list. --- +--- If both `cmd` and `shell` are provided, then `spawn` is invoked with +--- the command and `shell` is used as the `--shell ` parameter. +--- --- @param opts distant.core.client.WrapOpts --- @return string|string[] function M:wrap(opts) @@ -305,9 +347,12 @@ function M:wrap(opts) local has_lsp = opts.lsp ~= nil local has_shell = opts.shell ~= nil + -- We require exactly one of "cmd", "lsp", or "shell" UNLESS + -- we are given "cmd" and "shell", in which case "shell" is used + -- as the `--shell ` parameter for the spawn command if not has_cmd and not has_lsp and not has_shell then error('Missing one of ["cmd", "lsp", "shell"] argument') - elseif (has_cmd and has_lsp) or (has_cmd and has_shell) or (has_lsp and has_shell) then + elseif (has_cmd and has_lsp) or (has_lsp and has_shell) then error('Can only have exactly one of ["cmd", "lsp", "shell"] argument') end @@ -315,7 +360,9 @@ function M:wrap(opts) local result = {} if has_cmd then - local cmd = builder.spawn(opts.cmd) + -- Prefer `--cmd '...'` over `-- ...` + local cmd = builder.spawn(opts.cmd, true) + :set_connection(tostring(self.id)) if type(opts.cwd) == 'string' then cmd = cmd:set_current_dir(opts.cwd) end @@ -323,10 +370,21 @@ function M:wrap(opts) cmd = cmd:set_environment(opts.env) end + -- If we were given a shell, then set it as a parameter here + local shell = opts.shell + if shell then + if type(shell) == 'table' then + shell = table.concat(shell, ' ') + end + cmd = cmd:set_shell(shell) + end + result = cmd:set_from_tbl(self.config.network):as_list() table.insert(result, 1, self.config.binary) elseif has_lsp then - local cmd = builder.spawn(opts.lsp):set_lsp(opts.scheme or true) + local cmd = builder.spawn(opts.lsp) + :set_connection(tostring(self.id)) + :set_lsp(opts.scheme or true) if opts.cwd then cmd = cmd:set_current_dir(opts.cwd) end @@ -339,6 +397,7 @@ function M:wrap(opts) elseif has_shell then -- Build with no explicit cmd by default (use $SHELL) local cmd = builder.shell() + :set_connection(tostring(self.id)) -- If provided a specific shell, use that instead of default if type(opts.shell) == 'string' or type(opts.shell) == 'table' then diff --git a/lua/distant-core/job.lua b/lua/distant-core/job.lua index 643a7d8..6be7195 100644 --- a/lua/distant-core/job.lua +++ b/lua/distant-core/job.lua @@ -15,6 +15,7 @@ local AuthHandler = require('distant-core.auth') --- @field private __stderr_lines string[]|nil # if indicated, will collect lines here --- @field private __on_stdout_line? distant.core.job.OnStdoutLine --- @field private __on_stderr_line? distant.core.job.OnStderrLine +--- @field private __exit_status distant.core.job.ExitStatus|nil # populated once finished local M = {} M.__index = M @@ -314,13 +315,18 @@ function M:start(opts, cb) on_exit = function(_, exit_code, _) local success = exit_code == 0 local signal = self.to_signal(exit_code) - cb(nil, { + local exit_status = { success = success, exit_code = exit_code, signal = signal, stdout = self.__stdout_lines or {}, stderr = self.__stderr_lines or {}, - }) + } + + -- Update our job to contain the status + -- and report the status in our callback + self.__exit_status = exit_status + cb(nil, exit_status) end, }) @@ -382,6 +388,12 @@ function M:stderr_lines() return self.__stderr_lines or {} end +--- Returns the exit status and other information once the job has finished, otherwise nil. +--- @return distant.core.job.ExitStatus|nil +function M:exit_status() + return self.__exit_status +end + --- @param data any --- @return integer # number of bytes written, or 0 if failed function M:write(data) diff --git a/lua/distant/init.lua b/lua/distant/init.lua index b56aacb..b83a93a 100644 --- a/lua/distant/init.lua +++ b/lua/distant/init.lua @@ -535,7 +535,7 @@ function M:setup(settings, opts) }, '\n')) return end - + if self:is_initialized() then log.warn(table.concat({ 'distant:setup() called more than once!', @@ -842,17 +842,58 @@ end -- WRAP API ------------------------------------------------------------------------------- + +--- @class distant.plugin.SpawnWrapOpts +--- @field client_id? distant.core.manager.ConnectionId # if provided, will wrap using the specified client +--- @field cmd? string|string[] # wraps a regular command +--- @field cwd? string # specifies the current working directory +--- @field env? table # specifies environment variables for the spawned process +--- @field shell? string|string[]|true # used to define that the process should be spawned in a shell + +--- Performs a client wrapping of the given `cmd`, `lsp`, or `shell` parameter. +--- +--- If both `cmd` and `shell` are provided, then `spawn` is invoked with +--- the command and `shell` is used as the `--shell ` parameter. +--- +--- If `client_id` is provided, will wrap using the given client; otherwise, +--- will use the active client. Will fail if the client is not available. +--- +--- Returns a job representing the spawned process. +--- +--- @param opts distant.plugin.SpawnWrapOpts +--- @param cb? fun(err?:string, exit_status:distant.core.job.ExitStatus) +--- @return distant.core.Job +function M:spawn_wrap(opts, cb) + log.fmt_trace('distant:spawn_wrap(%s)', opts) + self:__assert_initialized() + + local client = assert( + self:client(opts.client_id), + 'Client unavailable for spawning wrapped cmd' + ) + + return client:spawn_wrap({ + cmd = opts.cmd, + cwd = opts.cwd, + env = opts.env, + shell = opts.shell, + }, cb) +end + --- @class distant.plugin.WrapOpts --- @field client_id? distant.core.manager.ConnectionId # if provided, will wrap using the specified client --- @field cmd? string|string[] # wraps a regular command --- @field lsp? string|string[] # wraps an LSP server command ---- @field shell? string|string[]|true # wraps a shell, taking an optional shell command +--- @field shell? string|string[]|true # wraps a shell, taking an optional shell command (or --shell if using `cmd`) --- @field cwd? string # specifies the current working directory --- @field env? table # specifies environment variables for the spawned process --- @field scheme? string # if provided, uses this scheme instead of the default (lsp only) --- Performs a client wrapping of the given `cmd`, `lsp`, or `shell` parameter. --- +--- If both `cmd` and `shell` are provided, then `spawn` is invoked with +--- the command and `shell` is used as the `--shell ` parameter. +--- --- If `client_id` is provided, will wrap using the given client; otherwise, --- will use the active client. Will fail if the client is not available. ---