- Reject archive members that are absolute, escape via .., are special files, or link outside the destination, before anything is written - Stage under the pack root so the swap is a same-filesystem rename, with an EXDEV copy fallback; propagate registry auth errors and validate dir_name
vimpack
A vim.pack-shaped Neovim plugin manager that installs plugins from archives on an
artifact registry instead of git clones.
Why
Git-cloning plugins at startup makes an editor depend on GitHub being up, on git being
on PATH, and on whatever a moving branch happens to contain today. vimpack pulls a
tarball or zip at an explicit version from whatever HTTP registry you already run —
artifactapi, Artifactory, Nexus, S3 behind a CDN, a plain nginx directory — verifies it
against a checksum, and unpacks it into pack/vimpack/. The result is reproducible,
auditable, and works on hosts that cannot reach github.com.
Pure Lua. No dependencies beyond Neovim 0.10+ (vim.system, vim.uv, vim.json) plus
curl and tar/unzip for the fetch and unpack.
Install
vimpack manages plugins but does not manage itself. Drop it on your runtimepath — a
submodule, an archive from your registry, or a clone:
~/.config/nvim/pack/bootstrap/start/vimpack/
Configure
require('vimpack').setup({
registry = {
kind = 'artifactapi',
base_url = 'https://artifactapi.k8s.syd1.au.unkin.net',
repo = 'nvim-plugins',
auth = { env = 'ARTIFACTAPI_TOKEN' },
},
plugins = {
{ name = 'telescope.nvim', version = '0.1.8' },
{ name = 'oil.nvim', version = '2.11.0', sha256 = 'd9e2...' },
{ name = 'vim-fugitive', version = '3.7', opt = true },
-- explicit URL, registry bypassed entirely
{ name = 'plenary.nvim', version = '0.1.4', url = 'https://example.com/plenary-0.1.4.tar.gz' },
},
concurrency = 4,
timeout = 120000,
})
Then :VimpackInstall.
Plugin spec
| Field | Meaning |
|---|---|
name |
required; also the directory name under pack/vimpack/{start,opt}/ |
version |
required unless url is given; fed to the registry resolver |
sha256 |
optional; the download is rejected on mismatch |
opt |
true places the plugin in opt/ for :packadd |
url |
fully explicit archive URL; skips resolution |
ext |
archive extension, default tar.gz |
registry |
per-plugin registry override |
dir_name |
override the on-disk directory name |
Registries
A registry turns (name, version) into a download URL plus optional auth headers. A
resolver is a plain function:
--- (spec, registry) -> resolved | nil, err
--- resolved = { url = string, headers = table, filename = string, sha256 = string? }
require('vimpack').register_resolver('mine', function(spec, registry)
return {
url = registry.base_url .. '/' .. spec.name .. '@' .. spec.version .. '.tar.gz',
headers = {},
filename = spec.name .. '.tar.gz',
}
end)
Two resolvers ship built in.
template — any HTTP server
${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url} are substituted.
An unknown placeholder is an error, not an empty string.
Artifactory generic repo:
registry = {
kind = 'template',
url_template = 'https://artifactory.example.com/artifactory/nvim-generic/${name}/${version}/${name}-${version}.${ext}',
auth = { header = 'X-JFrog-Art-Api', prefix = '', token_cmd = { 'pass', 'show', 'artifactory/api-key' } },
}
artifactapi — the estate's registry
Resolves to ${base_url}/api/v2/remotes/${repo}/files/${path}, where path defaults to
${name}/${name}-${version}.${ext} and is overridable via path_template.
registry = {
kind = 'artifactapi',
base_url = 'https://artifactapi.k8s.syd1.au.unkin.net',
repo = 'nvim-plugins',
auth = { env = 'ARTIFACTAPI_TOKEN' },
}
Auth
Tokens are never written into config. auth takes either:
env = 'VAR'— read from the environment, ortoken_cmd = { 'pass', 'show', 'path' }(or a shell string) — a command that prints the token.
header defaults to Authorization and prefix to Bearer for that header. A literal
token key is rejected at setup().
Commands
| Command | Effect |
|---|---|
:VimpackInstall [name...] |
install missing plugins and reconcile drift; up-to-date plugins are skipped |
:VimpackUpdate [name...] |
re-fetch regardless of the manifest |
:VimpackClean |
remove installed plugins no longer in the spec |
:VimpackStatus |
table of wanted vs installed version and state (ok, missing, drift, orphan) |
Downloads and extraction run through vim.system, capped at concurrency in flight, so
the UI stays responsive.
How it installs
- Resolve
(name, version)to a URL and headers. curlthe archive intostdpath('cache')/vimpack/archives/.- Verify
sha256when the spec carries one; a mismatch aborts with expected and got. - List every archive member and reject the archive outright if any entry is absolute,
escapes the destination via
.., is a device or fifo, or is a link pointing outside the destination. Nothing is written until the whole listing is clean. - Extract into
<pack root>/.vimpack-stage/, hoisting a single wrapper directory. Staging lives under the pack root so the swap below is a same-filesystem rename;stdpath('cache')andstdpath('data')are frequently separate mounts. - Rename into
stdpath('data')/site/pack/vimpack/{start,opt}/<name>, parking the old tree aside first and restoring it if the rename fails. Should the two ever land on different filesystems anyway, the staged tree is copied beside the target and that copy is renamed into place, so the target is never half-written. - Record name, version, sha256 and bucket in
stdpath('data')/vimpack/manifest.json.
A failure at any step leaves the previous install untouched.
Out of scope (v1)
Lazy loading. opt = true places a plugin under opt/ for :packadd; there is no
event, filetype or command trigger machinery.
Concurrent Neovim instances. The manifest is rewritten wholesale at the end of a run, so
two :VimpackInstall runs overlapping across processes are last-writer-wins and one set
of manifest updates is lost; the installed trees themselves stay consistent.
Tests
nvim --headless --clean -l tests/minimal.lua
luacheck lua plugin tests
stylua --check lua plugin tests