Add vimpack: archive-based Neovim plugin manager
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed

## 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:
2026-09-05 13:28:53 +10:00
parent e510cdec23
commit 35b9df9eba
25 changed files with 2370 additions and 1 deletions
+51
View File
@@ -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
+87
View File
@@ -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
+180
View File
@@ -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
+204
View File
@@ -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
+132
View File
@@ -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
+70
View File
@@ -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
+94
View File
@@ -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
+166
View File
@@ -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
+81
View File
@@ -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 }
+63
View File
@@ -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