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
+105
View File
@@ -0,0 +1,105 @@
local fsmod = require('vimpack.fs')
describe('scan', function()
it('maps directories to their bucket', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/start/a/lua', 'p')
vim.fn.mkdir(root .. '/opt/b', 'p')
vim.fn.writefile({ '' }, root .. '/start/loose.txt')
local found = fsmod.scan(root)
assert_eq(false, found.a.opt)
assert_eq(true, found.b.opt)
assert_nil(found['loose.txt'])
end)
it('returns nothing for a missing root', function()
assert_eq({}, fsmod.scan(tmpdir() .. '/absent'))
end)
end)
describe('target_dir', function()
it('splits start and opt', function()
assert_eq('/r/start/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = false }, '/r'))
assert_eq('/r/opt/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = true }, '/r'))
end)
end)
describe('strip_top_level', function()
it('hoists a single wrapper directory', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/oil.nvim-2.11.0/lua', 'p')
assert_eq(root .. '/oil.nvim-2.11.0', fsmod.strip_top_level(root))
end)
it('leaves a flat archive alone', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/lua', 'p')
vim.fn.mkdir(root .. '/plugin', 'p')
assert_eq(root, fsmod.strip_top_level(root))
end)
end)
describe('swap', function()
it('replaces an existing install', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
vim.fn.mkdir(root .. '/staged', 'p')
vim.fn.writefile({ 'new' }, root .. '/staged/marker')
assert_true(fsmod.swap(root .. '/staged', root .. '/target'))
assert_eq({ 'new' }, vim.fn.readfile(root .. '/target/marker'))
assert_true(not fsmod.exists(root .. '/target.vimpack-old'))
end)
it('keeps the previous install when the staged tree is missing', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
local ok, err = fsmod.swap(root .. '/absent', root .. '/target')
assert_true(not ok)
assert_match('cannot move new install into place', err)
assert_eq({ 'old' }, vim.fn.readfile(root .. '/target/marker'))
end)
end)
describe('extract', function()
local function wait_extract(archive, dest)
local result
fsmod.extract(archive, dest, function(ok, err)
result = { ok = ok, err = err }
end)
vim.wait(10000, function()
return result ~= nil
end)
return result
end
it('unpacks a tarball', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')
vim.fn.writefile({ 'return {}' }, root .. '/src/plug/lua/plug.lua')
vim.fn.system({ 'tar', '-czf', root .. '/plug.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/plug.tar.gz', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/lua/plug.lua'))
assert_eq(root .. '/dest/plug', fsmod.strip_top_level(root .. '/dest'))
end)
it('rejects an unknown archive type', function()
local result = wait_extract(tmpdir() .. '/thing.rpm', tmpdir() .. '/dest')
assert_true(not result.ok)
assert_match('unsupported archive type', result.err)
end)
it('reports a corrupt tarball', function()
local root = tmpdir()
vim.fn.writefile({ 'definitely not a tarball' }, root .. '/bad.tar.gz')
local result = wait_extract(root .. '/bad.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('extraction failed', result.err)
end)
end)
+154
View File
@@ -0,0 +1,154 @@
-- End-to-end install over file:// URLs: real curl, real tar, real swap.
local checksum = require('vimpack.checksum')
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 function make_tarball(root, version, body)
local src = vim.fs.joinpath(root, 'src', 'demo.nvim-' .. version, 'lua')
vim.fn.mkdir(src, 'p')
vim.fn.writefile({ body }, vim.fs.joinpath(src, 'demo.lua'))
local archive = vim.fs.joinpath(root, 'demo-' .. version .. '.tar.gz')
vim.fn.system({ 'tar', '-czf', archive, '-C', vim.fs.joinpath(root, 'src'), 'demo.nvim-' .. version })
return archive
end
local function install(cfg, data, pack_root)
local built = plan.build(cfg, data, fsmod.scan(pack_root))
local items = vim.list_extend(vim.deepcopy(built.install), built.update)
local results
installer.run(items, {
manifest = data,
pack_root = pack_root,
timeout = 30000,
concurrency = 2,
}, function(r)
results = r
end)
vim.wait(30000, function()
return results ~= nil
end)
return results, items
end
describe('install end to end', function()
it('downloads, extracts, swaps and records the manifest', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local archive = make_tarball(root, '1.0.0', 'return 1')
local cfg = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } },
}))
local data = manifest.empty()
local results = install(cfg, data, pack_root)
assert_eq(1, #results)
assert_true(results[1].ok, tostring(results[1].err))
local installed = vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')
assert_true(fsmod.exists(installed), 'expected ' .. installed)
assert_eq({ 'return 1' }, vim.fn.readfile(installed))
local entry = manifest.get(data, 'demo.nvim')
assert_eq('1.0.0', entry.version)
assert_eq(assert(checksum.sha256_file(archive)), entry.sha256)
-- Second pass is a no-op: nothing left to install.
local _, items = install(cfg, data, pack_root)
assert_eq(0, #items)
end)
it('replaces the old tree on a version bump', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local v1 = make_tarball(root, '1.0.0', 'return 1')
local cfg1 = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } },
}))
assert_true(install(cfg1, data, pack_root)[1].ok)
local v2 = make_tarball(root, '2.0.0', 'return 2')
local cfg2 = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2 } },
}))
local results = install(cfg2, data, pack_root)
assert_true(results[1].ok, tostring(results[1].err))
assert_eq({ 'return 2' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')))
assert_eq('2.0.0', manifest.get(data, 'demo.nvim').version)
end)
it('aborts on a checksum mismatch and keeps the previous install', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local v1 = make_tarball(root, '1.0.0', 'return 1')
assert_true(install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } },
})),
data,
pack_root
)[1].ok)
local v2 = make_tarball(root, '2.0.0', 'return 2')
local results = install(
assert(config.normalize({
plugins = {
{ name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2, sha256 = string.rep('a', 64) },
},
})),
data,
pack_root
)
assert_true(not results[1].ok)
assert_match('checksum mismatch', results[1].err)
assert_eq({ 'return 1' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')))
assert_eq('1.0.0', manifest.get(data, 'demo.nvim').version)
end)
it('names the url when the download fails', function()
local root = tmpdir()
local missing = 'file://' .. vim.fs.joinpath(root, 'nope.tar.gz')
local results = install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = missing } },
})),
manifest.empty(),
vim.fs.joinpath(root, 'pack')
)
assert_true(not results[1].ok)
assert_match('download failed', results[1].err)
assert_match(missing, results[1].err)
end)
it('moves a plugin from start to opt', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local archive = make_tarball(root, '1.0.0', 'return 1')
assert_true(install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } },
})),
data,
pack_root
)[1].ok)
local results = install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive, opt = true } },
})),
data,
pack_root
)
assert_true(results[1].ok, tostring(results[1].err))
assert_true(fsmod.exists(vim.fs.joinpath(pack_root, 'opt', 'demo.nvim', 'lua', 'demo.lua')))
assert_true(not fsmod.exists(vim.fs.joinpath(pack_root, 'start', 'demo.nvim')))
end)
end)
+143
View File
@@ -0,0 +1,143 @@
local checksum = require('vimpack.checksum')
local config = require('vimpack.config')
local manifest = require('vimpack.manifest')
describe('manifest', function()
it('returns an empty manifest for a missing file', function()
local data = manifest.read(tmpdir() .. '/nope.json')
assert_eq(1, data.version)
assert_eq({}, data.plugins)
end)
it('round-trips through disk', function()
local path = tmpdir() .. '/manifest.json'
local data = manifest.empty()
manifest.set(data, 'oil.nvim', { version = '2.11.0', sha256 = 'abc', opt = false })
assert_true(manifest.write(path, data))
local read_back = manifest.read(path)
assert_eq('2.11.0', manifest.get(read_back, 'oil.nvim').version)
assert_eq('abc', read_back.plugins['oil.nvim'].sha256)
end)
it('survives a corrupt file', function()
local path = tmpdir() .. '/manifest.json'
vim.fn.writefile({ 'not json {' }, path)
local data, err = manifest.read(path)
assert_eq({}, data.plugins)
assert_match('not valid JSON', err)
end)
it('removes entries', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1' })
manifest.remove(data, 'a')
assert_nil(manifest.get(data, 'a'))
end)
end)
describe('sha256', function()
local sha256 = require('vimpack.sha256')
it('matches the NIST vectors', function()
assert_eq('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', sha256.hash(''))
assert_eq('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', sha256.hash('abc'))
assert_eq(
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1',
sha256.hash('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')
)
end)
it('handles binary data and multi-block input', function()
assert_eq(
'79eebf0b2aae05b2490234d0b884581935b2e28127f06a74a5b48e758f86bd25',
sha256.hash(string.char(0, 1, 2, 255, 0))
)
assert_eq('541b3e9daa09b20bf85fa273e5cbd3e80185aa4ec298e765db87742b70138a53', sha256.hash(string.rep('\0', 1000)))
assert_eq('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0', sha256.hash(string.rep('a', 1000000)))
end)
end)
describe('checksum', function()
local function write(contents)
local path = tmpdir() .. '/blob.bin'
vim.fn.writefile({ contents }, path, 'b')
return path
end
it('passes when the digest matches', function()
local path = write('hello vimpack')
local digest = assert(checksum.sha256_file(path))
assert_true(checksum.verify(path, digest))
assert_true(checksum.verify(path, digest:upper()))
end)
it('skips verification when no digest is configured', function()
assert_true(checksum.verify(write('x'), nil))
end)
it('names expected and got on mismatch', function()
local path = write('hello vimpack')
local ok, err = checksum.verify(path, string.rep('0', 64))
assert_true(not ok)
assert_match('checksum mismatch', err)
assert_match(string.rep('0', 64), err)
assert_match(assert(checksum.sha256_file(path)), err)
end)
it('reports an unreadable file', function()
local ok, err = checksum.verify(tmpdir() .. '/missing', string.rep('0', 64))
assert_true(not ok)
assert_match('checksum', err)
end)
end)
describe('config', function()
local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' }
it('normalizes strings and defaults', function()
local cfg = assert(config.normalize({
registry = reg,
plugins = { { name = 'oil.nvim', version = '2.11.0' } },
}))
assert_eq(false, cfg.plugins[1].opt)
assert_eq('oil.nvim', cfg.plugins[1].dir_name)
assert_eq(reg, cfg.plugins[1].registry)
end)
it('lets a plugin override the registry', function()
local other = { kind = 'template', url_template = 'https://other/${name}' }
local cfg = assert(config.normalize({
registry = reg,
plugins = { { name = 'a', version = '1', registry = other } },
}))
assert_eq('https://other/${name}', cfg.plugins[1].registry.url_template)
end)
it('requires a version unless a url is given', function()
local cfg, err = config.normalize({ registry = reg, plugins = { { name = 'a' } } })
assert_nil(cfg)
assert_match('version is required', err)
assert_true(config.normalize({ plugins = { { name = 'a', url = 'https://h/a.tar.gz' } } }) ~= nil)
end)
it('rejects duplicates and literal tokens', function()
local _, dup = config.normalize({
registry = reg,
plugins = { { name = 'a', version = '1' }, { name = 'a', version = '2' } },
})
assert_match('duplicate', dup)
local _, tok = config.normalize({
registry = vim.tbl_extend('force', reg, { auth = { token = 'literal' } }),
plugins = {},
})
assert_match('literal token', tok)
end)
it('rejects a plugin with no registry and no url', function()
local cfg, err = config.normalize({ plugins = { { name = 'a', version = '1' } } })
assert_nil(cfg)
assert_match('no registry', err)
end)
end)
+92
View File
@@ -0,0 +1,92 @@
-- Minimal busted-flavoured harness: no rocks, no vendored plugins.
-- Run with: nvim --headless --clean -l tests/minimal.lua tests/*_spec.lua
local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h')
vim.opt.runtimepath:prepend(root)
package.path = table.concat({ root .. '/lua/?.lua', root .. '/lua/?/init.lua', package.path }, ';')
local M = { passed = 0, failed = 0, failures = {} }
local stack = {}
local function describe(name, fn)
stack[#stack + 1] = name
fn()
stack[#stack] = nil
end
local function it(name, fn)
local label = table.concat(stack, ' ') .. ' ' .. name
local ok, err = pcall(fn)
if ok then
M.passed = M.passed + 1
io.write('ok ' .. label .. '\n')
else
M.failed = M.failed + 1
M.failures[#M.failures + 1] = label .. '\n ' .. tostring(err)
io.write('FAIL ' .. label .. '\n ' .. tostring(err) .. '\n')
end
end
local assert_eq = function(expected, got, msg)
if not vim.deep_equal(expected, got) then
error(
string.format('%sexpected %s, got %s', msg and (msg .. ': ') or '', vim.inspect(expected), vim.inspect(got)),
2
)
end
end
local assert_true = function(value, msg)
if not value then
error(msg or ('expected truthy, got ' .. vim.inspect(value)), 2)
end
end
local assert_nil = function(value, msg)
if value ~= nil then
error(msg or ('expected nil, got ' .. vim.inspect(value)), 2)
end
end
local assert_match = function(pattern, str, msg)
if type(str) ~= 'string' or not str:find(pattern, 1, true) then
error(string.format('%sexpected %s to contain %q', msg and (msg .. ': ') or '', vim.inspect(str), pattern), 2)
end
end
_G.describe = describe
_G.it = it
_G.assert_eq = assert_eq
_G.assert_true = assert_true
_G.assert_nil = assert_nil
_G.assert_match = assert_match
_G.tmpdir = function()
local dir = vim.fs.joinpath(vim.fn.tempname(), 'vimpack-test')
vim.fn.mkdir(dir, 'p')
return dir
end
local files = {}
for i = 1, #_G.arg do
files[#files + 1] = _G.arg[i]
end
if #files == 0 then
for name, kind in vim.fs.dir(root .. '/tests') do
if kind == 'file' and name:match('_spec%.lua$') then
files[#files + 1] = root .. '/tests/' .. name
end
end
table.sort(files)
end
for _, file in ipairs(files) do
dofile(file)
end
io.write(string.format('\n%d passed, %d failed\n', M.passed, M.failed))
if M.failed > 0 then
for _, failure in ipairs(M.failures) do
io.write('\n' .. failure .. '\n')
end
vim.cmd('cquit 1')
end
vim.cmd('quit')
+140
View File
@@ -0,0 +1,140 @@
local config = require('vimpack.config')
local installer = require('vimpack.installer')
local manifest = require('vimpack.manifest')
local plan = require('vimpack.plan')
local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' }
local function cfg_with(plugins)
return assert(config.normalize({ registry = reg, plugins = plugins }))
end
describe('plan', function()
it('installs what is not on disk', function()
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), {})
assert_eq(1, #built.install)
assert_eq('not installed', built.install[1].reason)
end)
it('skips an install that already matches', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'ff', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(0, #built.install)
assert_eq(0, #built.update)
assert_eq(1, #built.skip)
end)
it('updates on a version bump', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '2' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('1 -> 2', built.update[1].reason)
end)
it('updates when the spec sha256 differs from the installed one', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'aa', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', sha256 = 'bb' } }),
data,
{ a = { opt = false, dir = '/x/a' } }
)
assert_eq(1, #built.update)
assert_match('sha256', built.update[1].reason)
end)
it('updates when a plugin moves between start and opt', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', opt = true } }),
data,
{ a = { opt = false, dir = '/x/start/a' } }
)
assert_eq(1, #built.update)
assert_match('opt/', built.update[1].reason)
end)
it('updates a directory with no manifest entry', function()
local built =
plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('no manifest entry', built.update[1].reason)
end)
it('flags orphans on disk and in the manifest', function()
local data = manifest.empty()
manifest.set(data, 'ghost', { version = '9' })
local built = plan.build(
cfg_with({ { name = 'a', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, old = { opt = true, dir = '/x/opt/old' } }
)
assert_eq(2, #built.orphans)
assert_eq('ghost', built.orphans[1].name)
assert_eq('old', built.orphans[2].name)
assert_eq('/x/opt/old', built.orphans[2].dir)
end)
end)
describe('status_rows', function()
it('reports one row per plugin plus orphans', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local rows = plan.status_rows(
cfg_with({ { name = 'a', version = '2' }, { name = 'b', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, gone = { opt = false, dir = '/x/gone' } }
)
assert_eq(3, #rows)
assert_eq({ 'a', 'b', 'gone' }, { rows[1].name, rows[2].name, rows[3].name })
assert_eq({ 'drift', 'missing', 'orphan' }, { rows[1].state, rows[2].state, rows[3].state })
assert_eq('1', rows[1].installed)
assert_eq('2', rows[1].wanted)
end)
end)
describe('run_tasks', function()
it('runs every item and respects the concurrency cap', function()
local items = { 1, 2, 3, 4, 5 }
local inflight, peak, seen = 0, 0, {}
local done_results
installer.run_tasks(items, 2, function(item, cb)
inflight = inflight + 1
peak = math.max(peak, inflight)
vim.schedule(function()
seen[#seen + 1] = item
inflight = inflight - 1
cb(true)
end)
end, function(results)
done_results = results
end)
vim.wait(1000, function()
return done_results ~= nil
end)
assert_eq(5, #done_results)
assert_eq(5, #seen)
assert_true(peak <= 2, 'peak concurrency was ' .. peak)
end)
it('completes immediately on an empty list', function()
local called = false
installer.run_tasks({}, 4, function() end, function()
called = true
end)
assert_true(called)
end)
it('carries failures through', function()
local results
installer.run_tasks({ 'a' }, 1, function(_, cb)
cb(false, 'boom')
end, function(r)
results = r
end)
assert_eq('boom', results[1].err)
end)
end)
+136
View File
@@ -0,0 +1,136 @@
local registry = require('vimpack.registry')
local util = require('vimpack.util')
describe('interpolate', function()
it('substitutes every placeholder', function()
assert_eq(
'a/1.0/a.tar.gz',
(util.interpolate('${name}/${version}/${name}.${ext}', { name = 'a', version = '1.0', ext = 'tar.gz' }))
)
end)
it('reports an unknown placeholder', function()
local out, err = util.interpolate('${name}-${nope}', { name = 'a' })
assert_nil(out)
assert_match('${nope}', err)
end)
end)
describe('template resolver', function()
local reg = {
kind = 'template',
url_template = 'https://artifacts.example.com/nvim/${name}/${version}/${name}-${version}.${ext}',
}
it('renders name and version into the url', function()
local resolved = assert(registry.resolve({ name = 'telescope.nvim', version = '0.1.8' }, reg))
assert_eq('https://artifacts.example.com/nvim/telescope.nvim/0.1.8/telescope.nvim-0.1.8.tar.gz', resolved.url)
assert_eq('telescope.nvim-0.1.8.tar.gz', resolved.filename)
end)
it('honours a per-plugin ext override', function()
local resolved = assert(registry.resolve({ name = 'plenary.nvim', version = '1', ext = 'zip' }, reg))
assert_match('plenary.nvim-1.zip', resolved.url)
end)
it('fails loudly without a url_template', function()
local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'template' })
assert_nil(resolved)
assert_match('url_template', err)
end)
it('names the unknown kind', function()
local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'nosuch' })
assert_nil(resolved)
assert_match('nosuch', err)
end)
end)
describe('artifactapi resolver', function()
local reg = {
kind = 'artifactapi',
base_url = 'https://artifactapi.k8s.syd1.au.unkin.net/',
repo = 'nvim-plugins',
}
it('builds the generic-repo path', function()
local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg))
assert_eq(
'https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/nvim-plugins/files/oil.nvim/oil.nvim-2.11.0.tar.gz',
resolved.url
)
end)
it('accepts a path_template override', function()
local reg2 = vim.tbl_extend('force', reg, { path_template = '${version}/${slug}.${ext}' })
local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg2))
assert_match('/files/2.11.0/oil.nvim.tar.gz', resolved.url)
end)
end)
describe('url escape hatch', function()
it('bypasses the registry entirely', function()
local resolved = assert(registry.resolve({ name = 'x', version = '1', url = 'https://h/x.zip' }, nil))
assert_eq('https://h/x.zip', resolved.url)
assert_eq('x.zip', resolved.filename)
end)
end)
describe('auth headers', function()
it('reads a token from the environment', function()
vim.env.VIMPACK_TEST_TOKEN = 'sekret'
local headers = assert(registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } }))
assert_eq({ Authorization = 'Bearer sekret' }, headers)
vim.env.VIMPACK_TEST_TOKEN = nil
end)
it('reports an unset env var', function()
vim.env.VIMPACK_TEST_TOKEN = nil
local headers, err = registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } })
assert_nil(headers)
assert_match('VIMPACK_TEST_TOKEN', err)
end)
it('runs token_cmd and uses a custom header', function()
local headers = assert(registry.build_headers({
auth = { token_cmd = { 'printf', '%s', 'abc123' }, header = 'X-JFrog-Art-Api', prefix = '' },
}))
assert_eq({ ['X-JFrog-Art-Api'] = 'abc123' }, headers)
end)
it('rejects a literal token', function()
local headers, err = registry.build_headers({ auth = { token = 'oops' } })
assert_nil(headers)
assert_match('literal token', err)
end)
it('attaches auth to a resolved url', function()
vim.env.VIMPACK_TEST_TOKEN = 'sekret'
local resolved = assert(registry.resolve({ name = 'a', version = '1' }, {
kind = 'template',
url_template = 'https://h/${name}-${version}.tar.gz',
auth = { env = 'VIMPACK_TEST_TOKEN' },
}))
assert_eq('Bearer sekret', resolved.headers.Authorization)
vim.env.VIMPACK_TEST_TOKEN = nil
end)
end)
describe('custom resolvers', function()
it('are usable once registered', function()
registry.register('spec_custom', function(spec)
return { url = 'https://custom/' .. spec.name, headers = {}, filename = 'a.tar.gz' }
end)
local resolved = assert(registry.resolve({ name = 'z', version = '1' }, { kind = 'spec_custom' }))
assert_eq('https://custom/z', resolved.url)
end)
end)
describe('archive_kind', function()
it('classifies suffixes', function()
assert_eq('tar', util.archive_kind('a.tar.gz'))
assert_eq('tar', util.archive_kind('a.tgz'))
assert_eq('zip', util.archive_kind('A.ZIP'))
assert_nil(util.archive_kind('a.rpm'))
end)
end)