Files
vimpack/README.md
T
unkin-agent 949fe00f6a 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 auth errors, validate dir_name
2026-09-05 16:25:49 +10:00

169 lines
6.0 KiB
Markdown

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