Add vimpack: archive-based Neovim plugin manager
## Why
Cloning plugins from git ties the editor to GitHub uptime and moving branches; archives at a pinned version from our own registry are reproducible and reachable offline.
## How
- Add a registry resolver interface `(spec, registry) -> {url, headers, filename, sha256?}` with builtin `template` and `artifactapi` kinds, a per-plugin registry override and a `url` escape hatch.
- Take auth tokens only from an env var or a `token_cmd`; reject a literal token at setup.
- Download with curl, verify an optional sha256, extract to a staging dir and rename into `pack/vimpack/{start,opt}/<name>`, restoring the previous tree if the swap fails.
- Track installed name/version/sha256 in `stdpath('data')/vimpack/manifest.json` and skip plugins already at the wanted version.
- Add `:VimpackInstall`, `:VimpackUpdate`, `:VimpackClean` and `:VimpackStatus`, running downloads through `vim.system` under a concurrency cap.
- Hash with a pure-Lua SHA-256; `vim.fn.sha256()` rejects binary archives with E976.
- Add a dependency-free `tests/minimal.lua` harness and 57 specs covering resolution, auth, manifest, planning, checksum mismatch, orphan detection and end-to-end install.
- Add `.woodpecker/{test,lint,pre-commit}.yaml`, luacheck/stylua config and pre-commit hooks.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
local sha256 = require('vimpack.sha256')
|
||||
|
||||
local M = {}
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
--- Read a file whole. Archives are plugin-sized, so this stays cheap.
|
||||
local function read_file(path)
|
||||
local fd, open_err = uv.fs_open(path, 'r', 438)
|
||||
if not fd then
|
||||
return nil, open_err or ('cannot open ' .. path)
|
||||
end
|
||||
local stat, stat_err = uv.fs_fstat(fd)
|
||||
if not stat then
|
||||
uv.fs_close(fd)
|
||||
return nil, stat_err or ('cannot stat ' .. path)
|
||||
end
|
||||
local data, read_err = uv.fs_read(fd, stat.size, 0)
|
||||
uv.fs_close(fd)
|
||||
if not data then
|
||||
return nil, read_err or ('cannot read ' .. path)
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
function M.sha256_file(path)
|
||||
local data, err = read_file(path)
|
||||
if not data then
|
||||
return nil, err
|
||||
end
|
||||
return sha256.hash(data)
|
||||
end
|
||||
|
||||
--- Compare a file against an expected digest. Case-insensitive, and the error
|
||||
--- names both digests so a mismatch is diagnosable without re-running.
|
||||
function M.verify(path, expected)
|
||||
if expected == nil or expected == '' then
|
||||
return true
|
||||
end
|
||||
local got, err = M.sha256_file(path)
|
||||
if not got then
|
||||
return false, string.format('checksum: %s', err)
|
||||
end
|
||||
if got:lower() ~= expected:lower() then
|
||||
return false,
|
||||
string.format('checksum mismatch for %s\n expected: %s\n got: %s', path, expected:lower(), got:lower())
|
||||
end
|
||||
return true, nil, got
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,87 @@
|
||||
local M = {}
|
||||
|
||||
M.defaults = {
|
||||
registry = nil,
|
||||
plugins = {},
|
||||
concurrency = 4,
|
||||
timeout = 120000,
|
||||
}
|
||||
|
||||
local function normalize_registry(registry, where)
|
||||
if registry == nil then
|
||||
return nil
|
||||
end
|
||||
if type(registry) ~= 'table' then
|
||||
return nil, string.format('%s: registry must be a table', where)
|
||||
end
|
||||
if registry.auth and registry.auth.token ~= nil then
|
||||
return nil, string.format('%s: registry auth must not carry a literal token; use env or token_cmd', where)
|
||||
end
|
||||
return registry
|
||||
end
|
||||
|
||||
--- Validate and canonicalise user options into a config table.
|
||||
--- Every plugin ends up with name, version, opt, dir_name and its own registry.
|
||||
function M.normalize(opts)
|
||||
opts = opts or {}
|
||||
local cfg = vim.tbl_extend('force', vim.deepcopy(M.defaults), opts)
|
||||
|
||||
local default_registry, err = normalize_registry(cfg.registry, 'setup')
|
||||
if err then
|
||||
return nil, err
|
||||
end
|
||||
cfg.registry = default_registry
|
||||
|
||||
local seen = {}
|
||||
local plugins = {}
|
||||
for i, entry in ipairs(opts.plugins or {}) do
|
||||
local spec = entry
|
||||
if type(spec) == 'string' then
|
||||
spec = { name = spec }
|
||||
end
|
||||
if type(spec) ~= 'table' then
|
||||
return nil, string.format('plugins[%d]: expected table or string, got %s', i, type(entry))
|
||||
end
|
||||
spec = vim.deepcopy(spec)
|
||||
if type(spec.name) ~= 'string' or spec.name == '' then
|
||||
return nil, string.format('plugins[%d]: name is required', i)
|
||||
end
|
||||
if seen[spec.name] then
|
||||
return nil, string.format('plugins[%d]: duplicate plugin name %q', i, spec.name)
|
||||
end
|
||||
seen[spec.name] = true
|
||||
if not spec.url and (type(spec.version) ~= 'string' or spec.version == '') then
|
||||
return nil, string.format('plugin %q: version is required unless url is given', spec.name)
|
||||
end
|
||||
local reg, rerr = normalize_registry(spec.registry, string.format('plugin %q', spec.name))
|
||||
if rerr then
|
||||
return nil, rerr
|
||||
end
|
||||
spec.registry = reg or cfg.registry
|
||||
if not spec.registry and not spec.url then
|
||||
return nil, string.format('plugin %q: no registry configured and no url given', spec.name)
|
||||
end
|
||||
spec.opt = spec.opt == true
|
||||
spec.version = spec.version or 'url'
|
||||
spec.dir_name = spec.dir_name or require('vimpack.util').slug(spec.name)
|
||||
plugins[#plugins + 1] = spec
|
||||
end
|
||||
cfg.plugins = plugins
|
||||
|
||||
if type(cfg.concurrency) ~= 'number' or cfg.concurrency < 1 then
|
||||
return nil, 'concurrency must be a positive number'
|
||||
end
|
||||
|
||||
return cfg
|
||||
end
|
||||
|
||||
--- Index plugins by name for O(1) lookup.
|
||||
function M.by_name(cfg)
|
||||
local out = {}
|
||||
for _, spec in ipairs(cfg.plugins) do
|
||||
out[spec.name] = spec
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,180 @@
|
||||
local util = require('vimpack.util')
|
||||
|
||||
local M = {}
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
function M.pack_root()
|
||||
return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'vimpack')
|
||||
end
|
||||
|
||||
function M.cache_dir()
|
||||
return vim.fs.joinpath(vim.fn.stdpath('cache'), 'vimpack')
|
||||
end
|
||||
|
||||
function M.target_dir(spec, root)
|
||||
return vim.fs.joinpath(root or M.pack_root(), spec.opt and 'opt' or 'start', spec.dir_name or util.slug(spec.name))
|
||||
end
|
||||
|
||||
function M.exists(path)
|
||||
return uv.fs_stat(path) ~= nil
|
||||
end
|
||||
|
||||
--- Map plugin dir name -> { opt, dir } for everything under the pack root.
|
||||
function M.scan(root)
|
||||
root = root or M.pack_root()
|
||||
local out = {}
|
||||
for _, bucket in ipairs({ 'start', 'opt' }) do
|
||||
local dir = vim.fs.joinpath(root, bucket)
|
||||
if M.exists(dir) then
|
||||
for name, kind in vim.fs.dir(dir) do
|
||||
if kind == 'directory' then
|
||||
out[name] = { opt = bucket == 'opt', dir = vim.fs.joinpath(dir, name) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function M.rm_rf(path)
|
||||
if path and path ~= '' and M.exists(path) then
|
||||
vim.fn.delete(path, 'rf')
|
||||
end
|
||||
end
|
||||
|
||||
function M.mkdir_p(path)
|
||||
vim.fn.mkdir(path, 'p')
|
||||
return path
|
||||
end
|
||||
|
||||
--- Archives usually wrap everything in a single top-level directory; hoist it
|
||||
--- so pack/<name>/ holds lua/, plugin/, doc/ directly.
|
||||
function M.strip_top_level(dir)
|
||||
local entries = {}
|
||||
for name, kind in vim.fs.dir(dir) do
|
||||
entries[#entries + 1] = { name = name, kind = kind }
|
||||
if #entries > 1 then
|
||||
return dir
|
||||
end
|
||||
end
|
||||
if #entries == 1 and entries[1].kind == 'directory' then
|
||||
return vim.fs.joinpath(dir, entries[1].name)
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
--- Replace `target` with `staged` without ever leaving a half-written plugin
|
||||
--- behind: the old tree is parked aside first and restored on failure.
|
||||
function M.swap(staged, target)
|
||||
M.mkdir_p(vim.fs.dirname(target))
|
||||
local backup = target .. '.vimpack-old'
|
||||
M.rm_rf(backup)
|
||||
|
||||
local had_previous = M.exists(target)
|
||||
if had_previous then
|
||||
local ok, err = uv.fs_rename(target, backup)
|
||||
if not ok then
|
||||
return false, string.format('cannot move existing install aside (%s): %s', target, err)
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = uv.fs_rename(staged, target)
|
||||
if not ok then
|
||||
if had_previous then
|
||||
uv.fs_rename(backup, target)
|
||||
end
|
||||
return false, string.format('cannot move new install into place (%s): %s', target, err)
|
||||
end
|
||||
|
||||
M.rm_rf(backup)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Extract an archive into `dest` asynchronously. Calls cb(ok, err).
|
||||
function M.extract(archive, dest, cb)
|
||||
M.mkdir_p(dest)
|
||||
local kind = util.archive_kind(archive)
|
||||
local cmd
|
||||
if kind == 'tar' then
|
||||
cmd = { 'tar', '-xf', archive, '-C', dest }
|
||||
elseif kind == 'zip' then
|
||||
cmd = { 'unzip', '-q', '-o', archive, '-d', dest }
|
||||
else
|
||||
cb(false, string.format('unsupported archive type for %s (expected .tar.*, .tgz or .zip)', archive))
|
||||
return
|
||||
end
|
||||
|
||||
local ok, handle = pcall(vim.system, cmd, { text = true }, function(res)
|
||||
vim.schedule(function()
|
||||
if res.code ~= 0 then
|
||||
cb(
|
||||
false,
|
||||
string.format(
|
||||
'extraction failed for %s (%s exited %d): %s',
|
||||
archive,
|
||||
cmd[1],
|
||||
res.code,
|
||||
vim.trim(res.stderr or '')
|
||||
)
|
||||
)
|
||||
else
|
||||
cb(true)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
if not ok then
|
||||
cb(false, string.format('cannot run %s: %s (is it installed?)', cmd[1], handle))
|
||||
end
|
||||
end
|
||||
|
||||
--- Download a URL to `out` asynchronously with curl. Calls cb(ok, err).
|
||||
function M.download(url, out, headers, timeout, cb)
|
||||
M.mkdir_p(vim.fs.dirname(out))
|
||||
local cmd = {
|
||||
'curl',
|
||||
'--fail-with-body',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--write-out',
|
||||
'%{http_code}',
|
||||
'--max-time',
|
||||
tostring(math.floor((timeout or 120000) / 1000)),
|
||||
'--output',
|
||||
out,
|
||||
url,
|
||||
}
|
||||
for header, value in pairs(headers or {}) do
|
||||
cmd[#cmd + 1] = '--header'
|
||||
cmd[#cmd + 1] = header .. ': ' .. value
|
||||
end
|
||||
|
||||
-- on_exit runs in a libuv callback where Vimscript functions are banned;
|
||||
-- hop back to the main loop before touching vim.fn.
|
||||
local ok, handle = pcall(vim.system, cmd, { text = true }, function(res)
|
||||
vim.schedule(function()
|
||||
if res.code ~= 0 then
|
||||
M.rm_rf(out)
|
||||
local status = vim.trim(res.stdout or '')
|
||||
cb(
|
||||
false,
|
||||
string.format(
|
||||
'download failed (HTTP %s, curl exited %d)\n url: %s\n %s',
|
||||
status ~= '' and status or '?',
|
||||
res.code,
|
||||
url,
|
||||
vim.trim(res.stderr or '')
|
||||
)
|
||||
)
|
||||
else
|
||||
cb(true)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
if not ok then
|
||||
cb(false, string.format('cannot run curl: %s (vimpack needs curl on PATH)', handle))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,204 @@
|
||||
local config = require('vimpack.config')
|
||||
local fsmod = require('vimpack.fs')
|
||||
local installer = require('vimpack.installer')
|
||||
local manifest = require('vimpack.manifest')
|
||||
local plan = require('vimpack.plan')
|
||||
local registry = require('vimpack.registry')
|
||||
local util = require('vimpack.util')
|
||||
|
||||
local M = {}
|
||||
|
||||
M.config = nil
|
||||
|
||||
--- Expose the resolver registry so users can plug in their own.
|
||||
M.register_resolver = registry.register
|
||||
|
||||
function M.setup(opts)
|
||||
local cfg, err = config.normalize(opts)
|
||||
if not cfg then
|
||||
util.err(err)
|
||||
return nil, err
|
||||
end
|
||||
M.config = cfg
|
||||
return cfg
|
||||
end
|
||||
|
||||
local function require_config()
|
||||
if not M.config then
|
||||
util.err('not configured; call require("vimpack").setup{...} first')
|
||||
return nil
|
||||
end
|
||||
return M.config
|
||||
end
|
||||
|
||||
local function context(cfg, data)
|
||||
return {
|
||||
manifest = data,
|
||||
pack_root = fsmod.pack_root(),
|
||||
timeout = cfg.timeout,
|
||||
concurrency = cfg.concurrency,
|
||||
}
|
||||
end
|
||||
|
||||
--- Disk scan keyed by plugin name rather than directory name.
|
||||
local function installed_by_name(cfg)
|
||||
local by_dir = fsmod.scan()
|
||||
local out = {}
|
||||
local claimed = {}
|
||||
for _, spec in ipairs(cfg.plugins) do
|
||||
local rec = by_dir[spec.dir_name]
|
||||
if rec then
|
||||
out[spec.name] = rec
|
||||
claimed[spec.dir_name] = true
|
||||
end
|
||||
end
|
||||
for dir_name, rec in pairs(by_dir) do
|
||||
if not claimed[dir_name] then
|
||||
out[dir_name] = rec
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function report(results, data, verb)
|
||||
local ok_count, failures = 0, {}
|
||||
for _, res in ipairs(results) do
|
||||
if res.ok then
|
||||
ok_count = ok_count + 1
|
||||
if not res.item.opt then
|
||||
pcall(vim.cmd.packadd, res.item.dir_name)
|
||||
end
|
||||
else
|
||||
failures[#failures + 1] = res.err
|
||||
end
|
||||
end
|
||||
local wrote, werr = manifest.write(nil, data)
|
||||
if not wrote then
|
||||
failures[#failures + 1] = werr
|
||||
end
|
||||
if #failures > 0 then
|
||||
util.err(string.format('%s: %d ok, %d failed\n%s', verb, ok_count, #failures, table.concat(failures, '\n')))
|
||||
else
|
||||
util.notify(string.format('%s: %d plugin(s)', verb, ok_count))
|
||||
end
|
||||
end
|
||||
|
||||
local function select_specs(items, names)
|
||||
if not names or #names == 0 then
|
||||
return items
|
||||
end
|
||||
local wanted = {}
|
||||
for _, name in ipairs(names) do
|
||||
wanted[name] = true
|
||||
end
|
||||
local out = {}
|
||||
for _, item in ipairs(items) do
|
||||
if wanted[item.spec.name] then
|
||||
out[#out + 1] = item
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
--- Install anything missing or drifted. Never touches up-to-date plugins.
|
||||
function M.install(names)
|
||||
local cfg = require_config()
|
||||
if not cfg then
|
||||
return
|
||||
end
|
||||
local data = manifest.read()
|
||||
local built = plan.build(cfg, data, installed_by_name(cfg))
|
||||
local items = select_specs(vim.list_extend(vim.deepcopy(built.install), built.update), names)
|
||||
if #items == 0 then
|
||||
util.notify('nothing to install')
|
||||
return
|
||||
end
|
||||
installer.run(items, context(cfg, data), function(results)
|
||||
report(results, data, 'install')
|
||||
end)
|
||||
end
|
||||
|
||||
--- Re-fetch plugins even when the manifest says they are current.
|
||||
function M.update(names)
|
||||
local cfg = require_config()
|
||||
if not cfg then
|
||||
return
|
||||
end
|
||||
local data = manifest.read()
|
||||
local items = {}
|
||||
for _, spec in ipairs(cfg.plugins) do
|
||||
items[#items + 1] = { spec = spec, reason = 'forced update' }
|
||||
end
|
||||
items = select_specs(items, names)
|
||||
if #items == 0 then
|
||||
util.err(string.format('no plugin named %s in the spec', table.concat(names or {}, ', ')))
|
||||
return
|
||||
end
|
||||
installer.run(items, context(cfg, data), function(results)
|
||||
report(results, data, 'update')
|
||||
end)
|
||||
end
|
||||
|
||||
--- Remove installed plugins that are no longer in the spec.
|
||||
function M.clean()
|
||||
local cfg = require_config()
|
||||
if not cfg then
|
||||
return
|
||||
end
|
||||
local data = manifest.read()
|
||||
local built = plan.build(cfg, data, installed_by_name(cfg))
|
||||
if #built.orphans == 0 then
|
||||
util.notify('nothing to clean')
|
||||
return
|
||||
end
|
||||
local removed = {}
|
||||
for _, orphan in ipairs(built.orphans) do
|
||||
if orphan.dir then
|
||||
fsmod.rm_rf(orphan.dir)
|
||||
end
|
||||
manifest.remove(data, orphan.name)
|
||||
removed[#removed + 1] = orphan.name
|
||||
end
|
||||
local wrote, werr = manifest.write(nil, data)
|
||||
if not wrote then
|
||||
util.err(werr)
|
||||
return
|
||||
end
|
||||
util.notify('removed ' .. table.concat(removed, ', '))
|
||||
end
|
||||
|
||||
function M.status_rows()
|
||||
local cfg = require_config()
|
||||
if not cfg then
|
||||
return {}
|
||||
end
|
||||
return plan.status_rows(cfg, manifest.read(), installed_by_name(cfg))
|
||||
end
|
||||
|
||||
function M.status()
|
||||
local rows = M.status_rows()
|
||||
if #rows == 0 then
|
||||
util.notify('no plugins configured')
|
||||
return
|
||||
end
|
||||
local lines = { string.format('%-32s %-14s %-14s %s', 'PLUGIN', 'WANTED', 'INSTALLED', 'STATE') }
|
||||
for _, row in ipairs(rows) do
|
||||
lines[#lines + 1] = string.format('%-32s %-14s %-14s %s', row.name, row.wanted, row.installed, row.state)
|
||||
end
|
||||
vim.api.nvim_echo({ { table.concat(lines, '\n') } }, false, {})
|
||||
end
|
||||
|
||||
function M.plugin_names()
|
||||
local cfg = M.config
|
||||
if not cfg then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
for _, spec in ipairs(cfg.plugins) do
|
||||
out[#out + 1] = spec.name
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,132 @@
|
||||
local checksum = require('vimpack.checksum')
|
||||
local fsmod = require('vimpack.fs')
|
||||
local registry = require('vimpack.registry')
|
||||
local util = require('vimpack.util')
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Run `worker(item, cb)` over items with at most `concurrency` in flight.
|
||||
--- cb(ok, err) per item; `done(results)` once every item has reported.
|
||||
function M.run_tasks(items, concurrency, worker, done)
|
||||
local total = #items
|
||||
local results = {}
|
||||
if total == 0 then
|
||||
done(results)
|
||||
return
|
||||
end
|
||||
|
||||
local next_index = 1
|
||||
local finished = 0
|
||||
local limit = math.min(math.max(concurrency, 1), total)
|
||||
|
||||
local function start()
|
||||
local index = next_index
|
||||
if index > total then
|
||||
return
|
||||
end
|
||||
next_index = index + 1
|
||||
worker(items[index], function(ok, err)
|
||||
results[index] = { item = items[index], ok = ok, err = err }
|
||||
finished = finished + 1
|
||||
if finished == total then
|
||||
done(results)
|
||||
else
|
||||
start()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for _ = 1, limit do
|
||||
start()
|
||||
end
|
||||
end
|
||||
|
||||
local function stage_path(spec)
|
||||
return vim.fs.joinpath(
|
||||
fsmod.cache_dir(),
|
||||
'stage',
|
||||
string.format('%s.%d', util.slug(spec.name), vim.uv.hrtime() % 1000000)
|
||||
)
|
||||
end
|
||||
|
||||
--- Download, verify, extract and swap a single plugin into place.
|
||||
--- On any failure the previous install is left untouched.
|
||||
function M.install_one(spec, ctx, cb)
|
||||
local resolved, err = registry.resolve(spec, spec.registry)
|
||||
if not resolved then
|
||||
cb(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
local archive = vim.fs.joinpath(
|
||||
fsmod.cache_dir(),
|
||||
'archives',
|
||||
util.slug(spec.name) .. '-' .. spec.version .. '-' .. resolved.filename
|
||||
)
|
||||
local target = fsmod.target_dir(spec, ctx.pack_root)
|
||||
local staged = stage_path(spec)
|
||||
|
||||
local function fail(message)
|
||||
fsmod.rm_rf(staged)
|
||||
cb(false, string.format('%s: %s', spec.name, message))
|
||||
end
|
||||
|
||||
fsmod.download(resolved.url, archive, resolved.headers, ctx.timeout, function(ok, derr)
|
||||
if not ok then
|
||||
fail(derr)
|
||||
return
|
||||
end
|
||||
|
||||
local verified, verr, digest = checksum.verify(archive, resolved.sha256)
|
||||
if not verified then
|
||||
fsmod.rm_rf(archive)
|
||||
fail(verr)
|
||||
return
|
||||
end
|
||||
digest = digest or select(1, checksum.sha256_file(archive))
|
||||
|
||||
fsmod.rm_rf(staged)
|
||||
fsmod.extract(archive, staged, function(eok, eerr)
|
||||
if not eok then
|
||||
fail(eerr)
|
||||
return
|
||||
end
|
||||
|
||||
local source = fsmod.strip_top_level(staged)
|
||||
|
||||
-- The other bucket may still hold this plugin after an opt/start flip.
|
||||
local other = fsmod.target_dir(vim.tbl_extend('force', spec, { opt = not spec.opt }), ctx.pack_root)
|
||||
|
||||
local swapped, serr = fsmod.swap(source, target)
|
||||
if not swapped then
|
||||
fail(serr)
|
||||
return
|
||||
end
|
||||
fsmod.rm_rf(staged)
|
||||
fsmod.rm_rf(other)
|
||||
|
||||
ctx.manifest.plugins[spec.name] = {
|
||||
version = spec.version,
|
||||
sha256 = digest,
|
||||
opt = spec.opt,
|
||||
url = resolved.url,
|
||||
dir = target,
|
||||
installed_at = os.time(),
|
||||
}
|
||||
cb(true)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Install every item in `items` ({ spec = ..., reason = ... }) concurrently.
|
||||
function M.run(items, ctx, done)
|
||||
local specs = {}
|
||||
for _, item in ipairs(items) do
|
||||
specs[#specs + 1] = item.spec
|
||||
end
|
||||
M.run_tasks(specs, ctx.concurrency or 4, function(spec, cb)
|
||||
M.install_one(spec, ctx, cb)
|
||||
end, done)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,70 @@
|
||||
local M = {}
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
M.VERSION = 1
|
||||
|
||||
function M.empty()
|
||||
return { version = M.VERSION, plugins = {} }
|
||||
end
|
||||
|
||||
function M.path()
|
||||
return vim.fs.joinpath(vim.fn.stdpath('data'), 'vimpack', 'manifest.json')
|
||||
end
|
||||
|
||||
function M.read(path)
|
||||
path = path or M.path()
|
||||
local fd = uv.fs_open(path, 'r', 438)
|
||||
if not fd then
|
||||
return M.empty()
|
||||
end
|
||||
local stat = uv.fs_fstat(fd)
|
||||
local data = stat and uv.fs_read(fd, stat.size, 0) or nil
|
||||
uv.fs_close(fd)
|
||||
if not data or data == '' then
|
||||
return M.empty()
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, data)
|
||||
if not ok or type(decoded) ~= 'table' then
|
||||
return M.empty(), string.format('manifest %s is not valid JSON, starting fresh', path)
|
||||
end
|
||||
decoded.version = decoded.version or M.VERSION
|
||||
if type(decoded.plugins) ~= 'table' then
|
||||
decoded.plugins = {}
|
||||
end
|
||||
return decoded
|
||||
end
|
||||
|
||||
function M.write(path, data)
|
||||
path = path or M.path()
|
||||
local dir = vim.fs.dirname(path)
|
||||
vim.fn.mkdir(dir, 'p')
|
||||
local fd, err = uv.fs_open(path .. '.tmp', 'w', 420)
|
||||
if not fd then
|
||||
return false, string.format('cannot write manifest %s: %s', path, err)
|
||||
end
|
||||
local encoded = vim.json.encode(data)
|
||||
uv.fs_write(fd, encoded, 0)
|
||||
uv.fs_close(fd)
|
||||
local ok, rerr = uv.fs_rename(path .. '.tmp', path)
|
||||
if not ok then
|
||||
return false, string.format('cannot rename manifest into place: %s', rerr)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function M.get(data, name)
|
||||
return data.plugins[name]
|
||||
end
|
||||
|
||||
function M.set(data, name, entry)
|
||||
data.plugins[name] = entry
|
||||
return data
|
||||
end
|
||||
|
||||
function M.remove(data, name)
|
||||
data.plugins[name] = nil
|
||||
return data
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,94 @@
|
||||
local M = {}
|
||||
|
||||
--- Decide what a single plugin needs. `installed` is the on-disk record for the
|
||||
--- plugin name: { opt = boolean } or nil when nothing is on disk.
|
||||
--- Returns 'install' | 'update' | 'skip' plus a human reason.
|
||||
function M.decide(spec, entry, installed)
|
||||
if not installed then
|
||||
return 'install', 'not installed'
|
||||
end
|
||||
if not entry then
|
||||
return 'update', 'no manifest entry for an existing directory'
|
||||
end
|
||||
if entry.version ~= spec.version then
|
||||
return 'update', string.format('version %s -> %s', entry.version, spec.version)
|
||||
end
|
||||
if spec.sha256 and entry.sha256 and entry.sha256:lower() ~= spec.sha256:lower() then
|
||||
return 'update', 'sha256 in spec differs from installed'
|
||||
end
|
||||
if installed.opt ~= spec.opt then
|
||||
return 'update', string.format('moves to %s/', spec.opt and 'opt' or 'start')
|
||||
end
|
||||
if spec.url and entry.url and entry.url ~= spec.url then
|
||||
return 'update', 'url changed'
|
||||
end
|
||||
return 'skip', string.format('%s already at %s', spec.name, spec.version)
|
||||
end
|
||||
|
||||
--- Build the full plan for a config against the manifest and the disk scan.
|
||||
--- `installed` maps plugin name -> { opt = boolean, dir = string }.
|
||||
function M.build(cfg, manifest, installed)
|
||||
installed = installed or {}
|
||||
local plan = { install = {}, update = {}, skip = {}, orphans = {} }
|
||||
local wanted = {}
|
||||
|
||||
for _, spec in ipairs(cfg.plugins) do
|
||||
wanted[spec.name] = true
|
||||
local action, reason = M.decide(spec, manifest.plugins[spec.name], installed[spec.name])
|
||||
plan[action][#plan[action] + 1] = { spec = spec, reason = reason }
|
||||
end
|
||||
|
||||
for name, rec in pairs(installed) do
|
||||
if not wanted[name] then
|
||||
plan.orphans[#plan.orphans + 1] = { name = name, dir = rec.dir, opt = rec.opt }
|
||||
end
|
||||
end
|
||||
for name in pairs(manifest.plugins) do
|
||||
if not wanted[name] and not installed[name] then
|
||||
plan.orphans[#plan.orphans + 1] = { name = name, dir = nil, opt = false }
|
||||
end
|
||||
end
|
||||
table.sort(plan.orphans, function(a, b)
|
||||
return a.name < b.name
|
||||
end)
|
||||
|
||||
return plan
|
||||
end
|
||||
|
||||
--- Rows for :VimpackStatus, sorted by name.
|
||||
function M.status_rows(cfg, manifest, installed)
|
||||
installed = installed or {}
|
||||
local plan = M.build(cfg, manifest, installed)
|
||||
local rows = {}
|
||||
local function push(list, state)
|
||||
for _, item in ipairs(list) do
|
||||
local entry = manifest.plugins[item.spec.name]
|
||||
rows[#rows + 1] = {
|
||||
name = item.spec.name,
|
||||
wanted = item.spec.version,
|
||||
installed = entry and entry.version or '-',
|
||||
state = state,
|
||||
reason = item.reason,
|
||||
}
|
||||
end
|
||||
end
|
||||
push(plan.skip, 'ok')
|
||||
push(plan.install, 'missing')
|
||||
push(plan.update, 'drift')
|
||||
for _, orphan in ipairs(plan.orphans) do
|
||||
local entry = manifest.plugins[orphan.name]
|
||||
rows[#rows + 1] = {
|
||||
name = orphan.name,
|
||||
wanted = '-',
|
||||
installed = entry and entry.version or 'unknown',
|
||||
state = 'orphan',
|
||||
reason = 'not in spec',
|
||||
}
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
return a.name < b.name
|
||||
end)
|
||||
return rows
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,166 @@
|
||||
local util = require('vimpack.util')
|
||||
|
||||
local M = {}
|
||||
|
||||
M.DEFAULT_EXT = 'tar.gz'
|
||||
|
||||
--- @class VimpackResolved
|
||||
--- @field url string fully qualified archive URL
|
||||
--- @field headers table HTTP headers to send (may be empty)
|
||||
--- @field filename string name to store the downloaded archive under
|
||||
--- @field sha256 string|nil digest advertised by the registry, if any
|
||||
|
||||
--- @alias VimpackResolver fun(spec: table, registry: table): VimpackResolved|nil, string|nil
|
||||
|
||||
--- @type table<string, VimpackResolver>
|
||||
M.resolvers = {}
|
||||
|
||||
--- Register a resolver. A resolver is a plain function
|
||||
--- `(spec, registry) -> resolved|nil, err` where `resolved` carries url,
|
||||
--- headers, filename and an optional sha256.
|
||||
function M.register(name, fn)
|
||||
if type(name) ~= 'string' or name == '' then
|
||||
error('vimpack: resolver name must be a non-empty string')
|
||||
end
|
||||
if type(fn) ~= 'function' then
|
||||
error('vimpack: resolver ' .. name .. ' must be a function')
|
||||
end
|
||||
M.resolvers[name] = fn
|
||||
end
|
||||
|
||||
local function template_vars(spec, registry)
|
||||
return {
|
||||
name = spec.name,
|
||||
version = spec.version,
|
||||
ext = spec.ext or registry.ext or M.DEFAULT_EXT,
|
||||
slug = util.slug(spec.name),
|
||||
repo = registry.repo,
|
||||
base_url = registry.base_url and registry.base_url:gsub('/+$', '') or nil,
|
||||
}
|
||||
end
|
||||
|
||||
--- Generic templated-URL resolver: the user supplies `url_template` using
|
||||
--- ${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url}.
|
||||
M.register('template', function(spec, registry)
|
||||
if not registry.url_template then
|
||||
return nil, 'registry kind "template" requires a url_template'
|
||||
end
|
||||
local vars = template_vars(spec, registry)
|
||||
local url, err = util.interpolate(registry.url_template, vars)
|
||||
if not url then
|
||||
return nil, err
|
||||
end
|
||||
local filename = registry.filename_template
|
||||
if filename then
|
||||
local rendered, ferr = util.interpolate(filename, vars)
|
||||
if not rendered then
|
||||
return nil, ferr
|
||||
end
|
||||
filename = rendered
|
||||
end
|
||||
return { url = url, headers = {}, filename = filename or util.url_basename(url) }
|
||||
end)
|
||||
|
||||
--- artifactapi generic-repo layout:
|
||||
--- {base_url}/api/v2/remotes/{repo}/files/{path_template}
|
||||
--- path_template defaults to ${name}/${name}-${version}.${ext}.
|
||||
M.register('artifactapi', function(spec, registry)
|
||||
if not registry.base_url then
|
||||
return nil, 'registry kind "artifactapi" requires a base_url'
|
||||
end
|
||||
if not registry.repo then
|
||||
return nil, 'registry kind "artifactapi" requires a repo'
|
||||
end
|
||||
local vars = template_vars(spec, registry)
|
||||
local path, err = util.interpolate(registry.path_template or '${name}/${name}-${version}.${ext}', vars)
|
||||
if not path then
|
||||
return nil, err
|
||||
end
|
||||
local url = string.format('%s/api/v2/remotes/%s/files/%s', vars.base_url, registry.repo, path)
|
||||
return { url = url, headers = {}, filename = util.url_basename(url) }
|
||||
end)
|
||||
|
||||
--- Resolve the auth header for a registry. Tokens come from the environment or
|
||||
--- from a command that prints one; a literal token in config is rejected.
|
||||
function M.build_headers(registry)
|
||||
local auth = registry.auth
|
||||
if not auth then
|
||||
return {}
|
||||
end
|
||||
if auth.token ~= nil then
|
||||
return nil, 'registry auth must not carry a literal token; use env or token_cmd'
|
||||
end
|
||||
local token
|
||||
if auth.env then
|
||||
token = vim.env[auth.env]
|
||||
if token == nil or token == '' then
|
||||
return nil, string.format('registry auth env %s is unset or empty', auth.env)
|
||||
end
|
||||
elseif auth.token_cmd then
|
||||
local cmd = auth.token_cmd
|
||||
if type(cmd) == 'string' then
|
||||
cmd = { 'sh', '-c', cmd }
|
||||
end
|
||||
local ok, res = pcall(function()
|
||||
return vim.system(cmd, { text = true }):wait()
|
||||
end)
|
||||
if not ok then
|
||||
return nil, string.format('registry auth token_cmd failed to start: %s', res)
|
||||
end
|
||||
if res.code ~= 0 then
|
||||
return nil, string.format('registry auth token_cmd exited %d: %s', res.code, vim.trim(res.stderr or ''))
|
||||
end
|
||||
token = vim.trim(res.stdout or '')
|
||||
if token == '' then
|
||||
return nil, 'registry auth token_cmd printed nothing'
|
||||
end
|
||||
else
|
||||
return nil, 'registry auth needs either env or token_cmd'
|
||||
end
|
||||
local header = auth.header or 'Authorization'
|
||||
local prefix = auth.prefix
|
||||
if prefix == nil and header:lower() == 'authorization' then
|
||||
prefix = 'Bearer '
|
||||
end
|
||||
return { [header] = (prefix or '') .. token }
|
||||
end
|
||||
|
||||
--- Turn a plugin spec plus its registry into a download target.
|
||||
--- A spec-level `url` short-circuits registry resolution entirely.
|
||||
function M.resolve(spec, registry)
|
||||
if spec.url then
|
||||
return {
|
||||
url = spec.url,
|
||||
headers = registry and select(1, M.build_headers(registry)) or {},
|
||||
filename = spec.filename or util.url_basename(spec.url),
|
||||
sha256 = spec.sha256,
|
||||
}
|
||||
end
|
||||
|
||||
if not registry then
|
||||
return nil, string.format('plugin %q has no registry and no url', spec.name)
|
||||
end
|
||||
|
||||
local kind = registry.kind or 'template'
|
||||
local resolver = M.resolvers[kind]
|
||||
if not resolver then
|
||||
return nil,
|
||||
string.format('unknown registry kind %q (known: %s)', kind, table.concat(vim.tbl_keys(M.resolvers), ', '))
|
||||
end
|
||||
|
||||
local resolved, err = resolver(spec, registry)
|
||||
if not resolved then
|
||||
return nil, string.format('plugin %q: %s', spec.name, err or 'resolver returned nothing')
|
||||
end
|
||||
|
||||
local headers, herr = M.build_headers(registry)
|
||||
if not headers then
|
||||
return nil, string.format('plugin %q: %s', spec.name, herr)
|
||||
end
|
||||
resolved.headers = vim.tbl_extend('force', headers, resolved.headers or {})
|
||||
resolved.sha256 = spec.sha256 or resolved.sha256
|
||||
resolved.filename = resolved.filename or util.url_basename(resolved.url)
|
||||
return resolved
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,81 @@
|
||||
-- SHA-256 over LuaJIT bit ops. vim.fn.sha256() rejects binary input (E976:
|
||||
-- Using a Blob as a String), and archives are binary, so vimpack hashes here.
|
||||
local bit = require('bit')
|
||||
|
||||
local band, bxor, bnot = bit.band, bit.bxor, bit.bnot
|
||||
local rshift, rol, tobit, tohex = bit.rshift, bit.rol, bit.tobit, bit.tohex
|
||||
local byte, rep, sub = string.byte, string.rep, string.sub
|
||||
|
||||
-- stylua: ignore
|
||||
local K = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
}
|
||||
for i = 1, 64 do
|
||||
K[i] = tobit(K[i])
|
||||
end
|
||||
|
||||
local function pad(msg)
|
||||
local len = #msg
|
||||
local bits = len * 8
|
||||
local pad_len = (55 - len) % 64
|
||||
local tail = {}
|
||||
-- 64-bit big-endian bit length; the top word stays 0 below 512 MiB.
|
||||
local hi = math.floor(bits / 4294967296)
|
||||
local lo = bits % 4294967296
|
||||
for shift = 24, 0, -8 do
|
||||
tail[#tail + 1] = string.char(band(rshift(tobit(hi), shift), 0xff))
|
||||
end
|
||||
for shift = 24, 0, -8 do
|
||||
tail[#tail + 1] = string.char(band(rshift(tobit(lo), shift), 0xff))
|
||||
end
|
||||
return msg .. '\128' .. rep('\0', pad_len) .. table.concat(tail)
|
||||
end
|
||||
|
||||
--- Hex digest of an arbitrary (binary-safe) string.
|
||||
local function sha256(msg)
|
||||
local h0, h1, h2, h3 = tobit(0x6a09e667), tobit(0xbb67ae85), tobit(0x3c6ef372), tobit(0xa54ff53a)
|
||||
local h4, h5, h6, h7 = tobit(0x510e527f), tobit(0x9b05688c), tobit(0x1f83d9ab), tobit(0x5be0cd19)
|
||||
|
||||
local data = pad(msg)
|
||||
local w = {}
|
||||
|
||||
for offset = 1, #data, 64 do
|
||||
local chunk = sub(data, offset, offset + 63)
|
||||
for i = 1, 16 do
|
||||
local b1, b2, b3, b4 = byte(chunk, i * 4 - 3, i * 4)
|
||||
w[i] = tobit(b1 * 16777216 + b2 * 65536 + b3 * 256 + b4)
|
||||
end
|
||||
for i = 17, 64 do
|
||||
local x, y = w[i - 15], w[i - 2]
|
||||
local s0 = bxor(rol(x, 25), rol(x, 14), rshift(x, 3))
|
||||
local s1 = bxor(rol(y, 15), rol(y, 13), rshift(y, 10))
|
||||
w[i] = tobit(w[i - 16] + s0 + w[i - 7] + s1)
|
||||
end
|
||||
|
||||
local a, b, c, d, e, f, g, h = h0, h1, h2, h3, h4, h5, h6, h7
|
||||
for i = 1, 64 do
|
||||
local S1 = bxor(rol(e, 26), rol(e, 21), rol(e, 7))
|
||||
local ch = bxor(band(e, f), band(bnot(e), g))
|
||||
local t1 = tobit(h + S1 + ch + K[i] + w[i])
|
||||
local S0 = bxor(rol(a, 30), rol(a, 19), rol(a, 10))
|
||||
local maj = bxor(band(a, b), band(a, c), band(b, c))
|
||||
local t2 = tobit(S0 + maj)
|
||||
h, g, f, e = g, f, e, tobit(d + t1)
|
||||
d, c, b, a = c, b, a, tobit(t1 + t2)
|
||||
end
|
||||
|
||||
h0, h1, h2, h3 = tobit(h0 + a), tobit(h1 + b), tobit(h2 + c), tobit(h3 + d)
|
||||
h4, h5, h6, h7 = tobit(h4 + e), tobit(h5 + f), tobit(h6 + g), tobit(h7 + h)
|
||||
end
|
||||
|
||||
return tohex(h0) .. tohex(h1) .. tohex(h2) .. tohex(h3) .. tohex(h4) .. tohex(h5) .. tohex(h6) .. tohex(h7)
|
||||
end
|
||||
|
||||
return { hash = sha256 }
|
||||
@@ -0,0 +1,63 @@
|
||||
local M = {}
|
||||
|
||||
M.PLACEHOLDER = '%${([%w_]+)}'
|
||||
|
||||
--- Substitute ${key} placeholders. Returns nil plus the offending key when a
|
||||
--- placeholder has no value, so callers can report the exact template fault.
|
||||
function M.interpolate(template, vars)
|
||||
if type(template) ~= 'string' then
|
||||
return nil, 'template must be a string, got ' .. type(template)
|
||||
end
|
||||
local missing
|
||||
local out = template:gsub(M.PLACEHOLDER, function(key)
|
||||
local value = vars[key]
|
||||
if value == nil then
|
||||
missing = missing or key
|
||||
return ''
|
||||
end
|
||||
return tostring(value)
|
||||
end)
|
||||
if missing then
|
||||
return nil, string.format('template %q references unknown placeholder ${%s}', template, missing)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local TAR_SUFFIXES = { '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tar' }
|
||||
|
||||
--- Classify an archive by filename suffix: 'tar', 'zip' or nil.
|
||||
function M.archive_kind(filename)
|
||||
local lower = filename:lower()
|
||||
if lower:sub(-4) == '.zip' then
|
||||
return 'zip'
|
||||
end
|
||||
for _, suffix in ipairs(TAR_SUFFIXES) do
|
||||
if lower:sub(-#suffix) == suffix then
|
||||
return 'tar'
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Filename component of a URL, query string and fragment stripped.
|
||||
function M.url_basename(url)
|
||||
local path = url:gsub('[?#].*$', '')
|
||||
return path:match('([^/]+)$') or path
|
||||
end
|
||||
|
||||
--- Path-safe form of a plugin name (telescope.nvim stays readable).
|
||||
function M.slug(name)
|
||||
return (name:gsub('[^%w%.%-_]', '_'))
|
||||
end
|
||||
|
||||
function M.notify(msg, level)
|
||||
vim.schedule(function()
|
||||
vim.notify('[vimpack] ' .. msg, level or vim.log.levels.INFO)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.err(msg)
|
||||
M.notify(msg, vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user