Files
unkin-agent e5a260ad25
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
Fetch the ref and url the spec actually names
A ref goes into the archive URL verbatim and an explicit url goes to curl
as a positional, so both could name one thing and fetch another.

- reject "#", "?", "%", "[", "]", "{" and "}" in a tag or branch, which
  curl or the server would turn into a different path
- pass "--" before the url so curl cannot read it as an option
- document the ref rules in README and the help file
2026-09-12 01:04:53 +10:00

186 lines
7.9 KiB
Markdown

# 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/<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:
- `: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 <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
```sh
make test
```