# 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 ```lua 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: ```lua --- (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: ```lua 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`. ```lua 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 `/.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}/`, 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 ```sh nvim --headless --clean -l tests/minimal.lua luacheck lua plugin tests stylua --check lua plugin tests ```