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,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)
|
||||
Reference in New Issue
Block a user