Reviewed-on: #9
arti-pack
A Neovim plugin manager that installs plugins from an artifact repository
instead of git. It downloads upstream source archives over HTTP — a tag for
anything that publishes one, a branch for anything that does not — and unpacks
them into Neovim's native package path — see :h packages.
It works with any HTTP artifact repository that serves upstream tag and branch archives at a stable URL — a JFrog Artifactory remote GitHub repository, or any caching proxy with the same path shape. Nothing is cloned, no git binary is needed, and the editor never has to reach GitHub: every plugin arrives as a cached artifact from the same store your builds already pull from.
Requirements
- Neovim 0.10+
curl
Archives are unpacked in pure Lua, so no unzip or tar binary is needed.
Entry paths are validated and every entry's CRC32 is checked before it lands on
disk.
Install
Each tag is published as a source zip under the same tag-archive path shape arti-pack expects, so it installs the same way as everything it manages — mirror it into your own artifact repository and fetch it from there:
curl -fsSL -o /tmp/arti-pack.zip \
https://artifacts.example.com/artifactory/nvim-plugins/unkin/arti-pack/archive/refs/tags/v0.1.0.zip
mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/nvim/site/pack/arti-pack/start"
unzip -q /tmp/arti-pack.zip -d /tmp/arti-pack
mv /tmp/arti-pack/arti-pack-0.1.0 \
"${XDG_DATA_HOME:-$HOME/.local/share}/nvim/site/pack/arti-pack/start/arti-pack"
Once installed it can carry itself, since url bypasses the registry:
{
'unkin/arti-pack',
tag = 'v0.1.0',
url = 'https://artifacts.example.com/artifactory/nvim-plugins/unkin/arti-pack/archive/refs/tags/v0.1.0.zip',
}
Usage
require('arti-pack').setup({
registry = 'https://artifacts.example.com/artifactory/github-remote',
plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
{ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' },
{ 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' },
{ 'hrsh7th/cmp-path', branch = 'main' },
},
})
registry is required and has no default: it is the URL prefix your own
artifact repository serves upstream tag archives under, so there is no sensible
value to ship. It must be an http:// or https:// URL; surrounding
whitespace is trimmed and anything else is rejected. Until you set it,
:ArtiPackSync refuses to run and says so:
[arti-pack] registry is not configured; set it in require('arti-pack').setup{ registry = 'https://artifacts.example.com/artifactory/github-remote' }
:ArtiPackClean and :ArtiPackList never resolve a URL, so they work without
it, as do plugins that carry an explicit url.
Every plugin names an explicit ref — either a tag or a branch, never both
and never neither. Plugins install to
stdpath('data')/site/pack/arti-pack/start/<name>, or .../opt/<name> when
opt = true.
Spec fields
| Field | Default | Meaning |
|---|---|---|
[1] |
required | owner/repo |
tag |
one of | Upstream tag, used verbatim in the archive path |
branch |
one of | Upstream branch to track, used verbatim in the archive path |
opt |
false |
Install under opt/ for :packadd instead of start/ |
as |
repo name | Directory name to install as |
ext |
'zip' |
zip or tar.gz |
url |
— | Full archive URL, bypassing the registry (no whitespace) |
tag and branch are mutually exclusive: a spec setting both, or neither, is
rejected with an error naming the plugin and nothing is installed for it. Both
are one path segment of the archive URL, so both are trimmed of surrounding
whitespace and rejected if they are . or .., or contain inner whitespace,
/, \, or any of #, ?, %, [, ], {, } — each of those would
make curl fetch something other than the ref you named. A branch whose name
contains / therefore needs an explicit url.
Options
| Option | Default | Meaning |
|---|---|---|
registry |
required | Archive URL prefix placed in front of owner/repo |
auto_sync |
true |
Sync missing and changed plugins on startup |
concurrency |
4 |
Plugins downloaded in parallel |
timeout |
120 |
Per-download timeout in seconds |
Commands
| Command | Effect |
|---|---|
:ArtiPackSync |
Install missing plugins and apply changed refs |
:ArtiPackSync! |
Reinstall everything, ignoring recorded state |
:ArtiPackUpdate |
Refetch every branch-tracking plugin |
:ArtiPackClean |
Remove installed plugins no longer in the spec |
:ArtiPackList |
Show every configured plugin and its state |
Tracking a branch
Plenty of upstreams publish no tags at all, so a branch spec installs from the
branch archive instead. A branch is mutable, and that changes when arti-pack
fetches:
:ArtiPackSyncinstalls a branch plugin that is missing, that moved betweenstartandopt, or whosebranchyou changed — and otherwise leaves it alone. It says how many branch plugins it did not recheck. Startup cost and the load on your artifact store stay the same as a tag-only setup, and your editor does not change under you on a restart.:ArtiPackUpdaterefetches every branch plugin, whether or not upstream moved. This is the only command that goes looking for new branch content.:ArtiPackSync!still reinstalls everything.
Branch archives are never served from the local archive cache, so an update always sees what the store has now.
The lockfile records the branch name, a digest of the archive that was actually
installed, and when it was fetched — never a version, because a branch is not
one. :ArtiPackList reports that instead of guessing:
hrsh7th/cmp-path @main start tracking branch main, fetched 2026-09-08 10:14 (7db8f64a)
nvim-lua/plenary.nvim v0.1.4 start installed
The digest is a content identity, not a file checksum: Neovim's sha256()
refuses binary input, so it hashes the archive's base64 form. It will not match
sha256sum <archive>.
A branch plugin is never reported as up to date: nothing on disk can know
whether upstream has moved. After an update, an unchanged digest is reported as
unchanged rather than as an update that changed nothing.
How it resolves an archive
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
<registry>/<owner>/<repo>/archive/refs/heads/<branch>.<ext>
which for the examples above are
https://artifacts.example.com/artifactory/github-remote/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip
https://artifacts.example.com/artifactory/github-remote/hrsh7th/cmp-path/archive/refs/heads/main.zip
That is GitHub's own archive path shape, so a remote repository proxying
github.com resolves it unchanged.
Archives are cached under stdpath('cache')/arti-pack, checked for a real zip
or gzip magic header before being trusted, and unpacked into a staging
directory. Source archives wrap their contents in a single <repo>-<ref>
directory, which is stripped on the way in. Installs are recorded in
stdpath('data')/arti-pack/lock.json, so a sync with nothing to do costs
nothing. Helptags are regenerated per plugin after install.
A failed plugin is reported and skipped; the rest of the run continues.
Tests
make test