# 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: ```sh 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: ```lua { '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 ```lua 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/`, or `.../opt/` 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: - `:ArtiPackSync` installs a branch plugin that is missing, that moved between `start` and `opt`, or whose `branch` you 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. - `:ArtiPackUpdate` refetches 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 `. 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 ``` ///archive/refs/tags/. ///archive/refs/heads/. ``` 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 `-` 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 ```sh make test ```