unkin-agent ac7d086623
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Run CI under the default service account
The vimpack-ci ServiceAccount does not exist in argocd-apps, so every step
failed at pod creation. Other non-Go repos in the estate use default.
2026-09-05 13:30:17 +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. Extract into stdpath('cache')/vimpack/stage/, hoisting a single wrapper directory.
  5. Rename into stdpath('data')/site/pack/vimpack/{start,opt}/<name>, parking the old tree aside first and restoring it if the rename fails.
  6. 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.

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%