Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions lua/shelltime/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ local defaults = {
heartbeat_interval = 120000, -- 2 minutes in ms
debounce_interval = 30000, -- 30 seconds in ms
debug = false,
api_endpoint = nil, -- API endpoint for version check
web_endpoint = nil, -- Web endpoint for update command
}

-- Default config file path
Expand All @@ -20,6 +22,7 @@ local default_config_path = '~/.shelltime/config.yaml'
local cached_config = nil
local cached_mtime = nil
local config_path = nil
local test_mode = false

--- Expand tilde in path
---@param path string Path with possible tilde
Expand Down Expand Up @@ -89,6 +92,15 @@ local function merge_config(file_config)
config.debounce_interval = file_config.debounceInterval
end

-- API and web endpoints for version check
if file_config.apiEndpoint then
config.api_endpoint = file_config.apiEndpoint
end

if file_config.webEndpoint then
config.web_endpoint = file_config.webEndpoint
end

return config
end

Expand All @@ -101,11 +113,17 @@ function M.setup(opts)
-- Force reload
cached_config = nil
cached_mtime = nil
test_mode = false
end

--- Get merged configuration
---@return table Configuration
function M.get_config()
-- In test mode, always return cached config
if test_mode and cached_config then
return cached_config
end

local path = expand_path(config_path or default_config_path)
local mtime = get_mtime(path)

Expand Down Expand Up @@ -142,4 +160,11 @@ function M.get_config_path()
return config_path or default_config_path
end

--- Set config values directly (for testing only)
---@param config_values table Config values to set
function M._set_for_testing(config_values)
cached_config = vim.tbl_deep_extend('force', {}, defaults, config_values)
test_mode = true
end

return M
12 changes: 11 additions & 1 deletion lua/shelltime/sender.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
local config = require('shelltime.config')
local socket = require('shelltime.socket')
local heartbeat = require('shelltime.heartbeat')
local version = require('shelltime.version')

local M = {}

Expand Down Expand Up @@ -63,9 +64,18 @@ function M.start()
end)
end)

-- Check initial connection status
-- Check initial connection status and CLI version
vim.schedule(function()
is_connected = socket.is_connected_sync()

-- Check CLI version in background (non-blocking)
if is_connected then
socket.get_status(function(status, err)
if status and status.version then
version.check_version(status.version)
end
Comment on lines +74 to +76

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error returned from socket.get_status is not handled. If the call fails, the version check is silently skipped. While this prevents a crash, it would be beneficial to log the error when in debug mode to aid in troubleshooting connection issues. This would make the behavior consistent with other error handling in the codebase.

        if status and status.version then
          version.check_version(status.version)
        elseif err and config.get('debug') then
          vim.notify('[shelltime] Failed to get status for version check: ' .. err, vim.log.levels.DEBUG)
        end

end)
end
end)
end

Expand Down
146 changes: 146 additions & 0 deletions lua/shelltime/version.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
-- Version checker for shelltime

local config = require('shelltime.config')

local M = {}

-- Track if warning has been shown this session
local has_shown_warning = false

-- Version check API endpoint path
local VERSION_CHECK_ENDPOINT = '/api/v1/cli/version-check'

--- URL encode a string
---@param str string String to encode
---@return string Encoded string
local function url_encode(str)
if str then
str = string.gsub(str, '\n', '\r\n')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The handling of newline characters (\n) is incorrect for URL encoding. Newlines should be percent-encoded as %0A, not converted to \r\n which is used for HTTP headers. While version strings are unlikely to contain newlines, this is still a bug in the implementation that could cause issues.

    str = string.gsub(str, '\n', '%%0A')

str = string.gsub(str, '([^%w _%%%-%.~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
str = string.gsub(str, ' ', '+')
end
return str
end

--- Parse JSON response using Neovim's built-in JSON decoder
---@param json_str string JSON string
---@return table|nil Parsed table or nil on error
local function parse_json(json_str)
local ok, result = pcall(vim.json.decode, json_str)
if not ok or type(result) ~= 'table' then
return nil
end
return result
end
Comment on lines 30 to 36

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The custom JSON parser implemented here is very fragile as it relies on string matching with regular expressions. This can easily break if the API response format changes in ways not anticipated by the regex (e.g., different value types, number formats). Neovim provides a robust built-in JSON decoder, vim.json.decode, which is already used in other parts of this project (e.g., socket.lua). It's highly recommended to use vim.json.decode for parsing the response to improve robustness and maintainability.

local function parse_json(json_str)
  local ok, result = pcall(vim.json.decode, json_str)
  if ok and type(result) == 'table' and result.isLatest ~= nil and result.latestVersion and result.version then
    return result
  end
  return nil
end


--- Check CLI version against server
---@param daemon_version string Current daemon version
---@param callback function|nil Optional callback(result, error)
function M.check_version(daemon_version, callback)
local api_endpoint = config.get('api_endpoint')
local web_endpoint = config.get('web_endpoint')

if not api_endpoint or not web_endpoint then
if config.get('debug') then
vim.notify('[shelltime] No API/web endpoint configured, skipping version check', vim.log.levels.DEBUG)
end
if callback then
callback(nil, 'No endpoint configured')
end
return
end

if has_shown_warning then
if config.get('debug') then
vim.notify('[shelltime] Version warning already shown this session', vim.log.levels.DEBUG)
end
if callback then
callback(nil, 'Already shown')
end
return
end

local url = api_endpoint .. VERSION_CHECK_ENDPOINT .. '?version=' .. url_encode(daemon_version)

if config.get('debug') then
vim.notify('[shelltime] Checking version at: ' .. url, vim.log.levels.DEBUG)
end

-- Use curl asynchronously via vim.fn.jobstart
local stdout_data = {}

vim.fn.jobstart({ 'curl', '-sSL', '-m', '5', '-H', 'Accept: application/json', url }, {
stdout_buffered = true,
on_stdout = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= '' then
table.insert(stdout_data, line)
end
end
end
end,
on_exit = function(_, exit_code)
vim.schedule(function()
if exit_code ~= 0 then
if config.get('debug') then
vim.notify('[shelltime] Version check failed with exit code: ' .. exit_code, vim.log.levels.DEBUG)
end
if callback then
callback(nil, 'curl failed')
end
return
end

local response = table.concat(stdout_data, '')
local result = parse_json(response)

if result then
if not result.isLatest then
has_shown_warning = true
M.show_update_warning(daemon_version, result.latestVersion, web_endpoint)
elseif config.get('debug') then
vim.notify('[shelltime] CLI version ' .. daemon_version .. ' is up to date', vim.log.levels.DEBUG)
end

if callback then
callback(result, nil)
end
else
if config.get('debug') then
vim.notify('[shelltime] Failed to parse version check response', vim.log.levels.DEBUG)
end
if callback then
callback(nil, 'Parse error')
end
end
end)
end,
})
end

--- Show update warning notification
---@param current_version string Current version
---@param latest_version string Latest available version
---@param web_endpoint string Web endpoint for update command
function M.show_update_warning(current_version, latest_version, web_endpoint)
local update_command = 'curl -sSL ' .. web_endpoint .. '/i | bash'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Piping the output of curl directly into bash is a significant security risk. This pattern can lead to remote code execution if the download URL is compromised, or if the user is on a malicious network (e.g., via DNS spoofing). The script is executed without any opportunity for the user to inspect it. While this is a common installation method, it's dangerous to promote within an editor plugin, especially by copying the command to the clipboard which encourages blind execution. Consider providing just the URL to the user and advising them to inspect the script before running it.

local message = string.format(
'[shelltime] CLI update available: %s -> %s\n\nRun: %s',
current_version,
latest_version,
update_command
)

vim.notify(message, vim.log.levels.WARN)

-- Also copy to clipboard if available
if vim.fn.has('clipboard') == 1 then
vim.fn.setreg('+', update_command)
vim.notify('[shelltime] Update command copied to clipboard', vim.log.levels.INFO)
end
end

return M
1 change: 1 addition & 0 deletions tests/helpers/reset.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function M.reset_all()
'shelltime.heartbeat',
'shelltime.sender',
'shelltime.socket',
'shelltime.version',
'shelltime.utils',
'shelltime.utils.system',
'shelltime.utils.git',
Expand Down
Loading