unkin-agent ca55c9dd9f
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Validate archive entries and stage inside the pack root
- 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
2026-09-05 16:32:47 +10:00

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, or
  • token_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

  1. Resolve (name, version) to a URL and headers.
  2. curl the archive into stdpath('cache')/vimpack/archives/.
  3. Verify sha256 when the spec carries one; a mismatch aborts with expected and got.
  4. 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.
  5. 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') and stdpath('data') are frequently separate mounts.
  6. 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.
  7. 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
S
Description
Neovim plugin manager installing plugins from tagged archives on artifactapi/Artifactory instead of git clones
Readme 99 KiB
Languages
Lua 100%