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