Add vimpack: archive-based Neovim plugin manager
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed

## Why
Cloning plugins from git ties the editor to GitHub uptime and moving branches; archives at a pinned version from our own registry are reproducible and reachable offline.

## How
- Add a registry resolver interface `(spec, registry) -> {url, headers, filename, sha256?}` with builtin `template` and `artifactapi` kinds, a per-plugin registry override and a `url` escape hatch.
- Take auth tokens only from an env var or a `token_cmd`; reject a literal token at setup.
- Download with curl, verify an optional sha256, extract to a staging dir and rename into `pack/vimpack/{start,opt}/<name>`, restoring the previous tree if the swap fails.
- Track installed name/version/sha256 in `stdpath('data')/vimpack/manifest.json` and skip plugins already at the wanted version.
- Add `:VimpackInstall`, `:VimpackUpdate`, `:VimpackClean` and `:VimpackStatus`, running downloads through `vim.system` under a concurrency cap.
- Hash with a pure-Lua SHA-256; `vim.fn.sha256()` rejects binary archives with E976.
- Add a dependency-free `tests/minimal.lua` harness and 57 specs covering resolution, auth, manifest, planning, checksum mismatch, orphan detection and end-to-end install.
- Add `.woodpecker/{test,lint,pre-commit}.yaml`, luacheck/stylua config and pre-commit hooks.
This commit is contained in:
2026-09-05 13:28:53 +10:00
parent e510cdec23
commit 35b9df9eba
25 changed files with 2370 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
std = 'luajit'
cache = true
max_line_length = 140
-- vim.g / vim.env are written to, so `vim` is not read-only here.
globals = { 'vim' }
files['tests/'] = {
read_globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' },
}
files['tests/minimal.lua'] = {
globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' },
}
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=500']
- id: check-toml
- id: check-yaml
- id: detect-private-key
- id: mixed-line-ending
args: ['--fix=lf']
- repo: local
hooks:
- id: luacheck
name: luacheck
entry: luacheck
language: system
types: [lua]
- id: stylua
name: stylua
entry: stylua --check
language: system
types: [lua]
+5
View File
@@ -0,0 +1,5 @@
column_width = 120
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
call_parentheses = "Always"
+35
View File
@@ -0,0 +1,35 @@
when:
- event: pull_request
steps:
- name: luacheck
image: alpine:3.21
commands:
- apk add --no-cache luacheck
- luacheck lua plugin tests
backend_options:
kubernetes:
serviceAccountName: vimpack-ci
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: stylua
image: alpine:3.21
commands:
- apk add --no-cache stylua
- stylua --check lua plugin tests
backend_options:
kubernetes:
serviceAccountName: vimpack-ci
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+20
View File
@@ -0,0 +1,20 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: alpine:3.21
commands:
- apk add --no-cache pre-commit git luacheck stylua
- git config --global --add safe.directory "$CI_WORKSPACE"
- pre-commit run --all-files --show-diff-on-failure
backend_options:
kubernetes:
serviceAccountName: vimpack-ci
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: test
image: alpine:3.21
commands:
- apk add --no-cache neovim tar curl unzip
- nvim --headless --clean -l tests/minimal.lua
backend_options:
kubernetes:
serviceAccountName: vimpack-ci
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+155 -1
View File
@@ -1,3 +1,157 @@
# vimpack
Neovim plugin manager installing plugins from tagged archives on artifactapi/Artifactory instead of git clones
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. Extract into `stdpath('cache')/vimpack/stage/`, hoisting a single wrapper directory.
5. Rename into `stdpath('data')/site/pack/vimpack/{start,opt}/<name>`, parking the old
tree aside first and restoring it if the rename fails.
6. 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.
## Tests
```sh
nvim --headless --clean -l tests/minimal.lua
luacheck lua plugin tests
stylua --check lua plugin tests
```
+172
View File
@@ -0,0 +1,172 @@
*vimpack.txt* Install Neovim plugins from archives on an artifact registry
==============================================================================
CONTENTS *vimpack-contents*
1. Introduction ............................. |vimpack-introduction|
2. Setup .................................... |vimpack-setup|
3. Plugin spec .............................. |vimpack-spec|
4. Registries ............................... |vimpack-registries|
5. Auth ..................................... |vimpack-auth|
6. Commands ................................. |vimpack-commands|
7. Layout ................................... |vimpack-layout|
8. Lua API .................................. |vimpack-api|
==============================================================================
1. INTRODUCTION *vimpack-introduction*
vimpack installs Neovim plugins from tarballs or zips served by an artifact
registry, at an explicit version, rather than by cloning git repositories.
Downloads are checksum-verified and swapped into place atomically, so a failed
install never leaves a half-written plugin behind.
Requirements: Neovim 0.10+, `curl`, and `tar` (plus `unzip` for .zip
archives).
==============================================================================
2. SETUP *vimpack-setup*
>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 = 'vim-fugitive', version = '3.7', opt = true },
},
concurrency = 4,
timeout = 120000,
})
<
`setup()` validates the spec and returns the normalised config, or nil plus an
error message. Nothing is downloaded until |:VimpackInstall|.
Top-level options ~
registry default registry for every plugin (table, optional)
plugins list of plugin specs (list, default {})
concurrency maximum simultaneous downloads (number, default 4)
timeout per-download timeout in milliseconds (number, default 120000)
==============================================================================
3. PLUGIN SPEC *vimpack-spec*
name Plugin name. Required. Also the directory name under
pack/vimpack/{start,opt}/ unless dir_name overrides it.
version Required unless `url` is given. Passed to the resolver.
sha256 Expected digest of the archive. A mismatch aborts the install
and reports both the expected and the actual digest.
opt When true the plugin lands in opt/ for |:packadd|.
url Fully explicit archive URL; registry resolution is skipped.
ext Archive extension, default "tar.gz".
registry Per-plugin registry, overriding the top-level one.
dir_name On-disk directory name.
==============================================================================
4. REGISTRIES *vimpack-registries*
A registry maps (name, version) to a download URL. `kind` selects the
resolver; the default kind is "template".
template ~
Substitutes ${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url} into
`url_template`. An unrecognised placeholder is reported as an error.
`filename_template` optionally names the cached archive.
>lua
registry = {
kind = 'template',
url_template = 'https://artifactory.example.com/artifactory/nvim-generic/'
.. '${name}/${version}/${name}-${version}.${ext}',
}
<
artifactapi ~
Builds ${base_url}/api/v2/remotes/${repo}/files/${path}. `path_template`
defaults to "${name}/${name}-${version}.${ext}".
>lua
registry = {
kind = 'artifactapi',
base_url = 'https://artifactapi.k8s.syd1.au.unkin.net',
repo = 'nvim-plugins',
}
<
custom ~
A resolver is `(spec, registry) -> resolved | nil, err`, where `resolved` is
`{ url, headers, filename, sha256? }`.
>lua
require('vimpack').register_resolver('mine', function(spec, registry)
return { url = registry.base_url .. '/' .. spec.name .. '-'
.. spec.version .. '.tar.gz', headers = {} }
end)
<
==============================================================================
5. AUTH *vimpack-auth*
Tokens are never stored in the config. `registry.auth` accepts:
env name of an environment variable holding the token
token_cmd command (list or shell string) that prints the token
header header name, default "Authorization"
prefix value prefix, default "Bearer " for Authorization
A literal `token` key is rejected at |vimpack-setup| time.
>lua
auth = { header = 'X-JFrog-Art-Api', prefix = '',
token_cmd = { 'pass', 'show', 'artifactory/api-key' } }
<
==============================================================================
6. COMMANDS *vimpack-commands*
*:VimpackInstall*
:VimpackInstall [{name}...]
Install plugins that are missing or whose installed version, digest or
start/opt placement has drifted from the spec. Up-to-date plugins are
skipped. With names, restrict the work to those plugins.
*:VimpackUpdate*
:VimpackUpdate [{name}...]
Re-fetch plugins whether or not the manifest says they are current.
*:VimpackClean*
:VimpackClean
Remove installed plugins and manifest entries that are no longer in the
spec.
*:VimpackStatus*
:VimpackStatus
Print wanted versus installed version per plugin, with a state of "ok",
"missing", "drift" or "orphan".
==============================================================================
7. LAYOUT *vimpack-layout*
stdpath('cache')/vimpack/archives/ downloaded archives
stdpath('cache')/vimpack/stage/ extraction staging area
stdpath('data')/vimpack/manifest.json installed name/version/sha256
stdpath('data')/site/pack/vimpack/start/<name>
stdpath('data')/site/pack/vimpack/opt/<name>
An install extracts to the staging area, hoists a single wrapper directory if
the archive has one, then renames into place after parking any previous tree
aside. If the rename fails the previous tree is restored.
Lazy loading is out of scope: `opt = true` only chooses the opt/ bucket.
==============================================================================
8. LUA API *vimpack-api*
require('vimpack').setup(opts) configure; returns cfg or nil,err
require('vimpack').install(names?) same as |:VimpackInstall|
require('vimpack').update(names?) same as |:VimpackUpdate|
require('vimpack').clean() same as |:VimpackClean|
require('vimpack').status() same as |:VimpackStatus|
require('vimpack').status_rows() status as a list of tables
require('vimpack').register_resolver(n, fn) add a registry resolver
vim:tw=78:ts=8:ft=help:norl:
+51
View File
@@ -0,0 +1,51 @@
local sha256 = require('vimpack.sha256')
local M = {}
local uv = vim.uv or vim.loop
--- Read a file whole. Archives are plugin-sized, so this stays cheap.
local function read_file(path)
local fd, open_err = uv.fs_open(path, 'r', 438)
if not fd then
return nil, open_err or ('cannot open ' .. path)
end
local stat, stat_err = uv.fs_fstat(fd)
if not stat then
uv.fs_close(fd)
return nil, stat_err or ('cannot stat ' .. path)
end
local data, read_err = uv.fs_read(fd, stat.size, 0)
uv.fs_close(fd)
if not data then
return nil, read_err or ('cannot read ' .. path)
end
return data
end
function M.sha256_file(path)
local data, err = read_file(path)
if not data then
return nil, err
end
return sha256.hash(data)
end
--- Compare a file against an expected digest. Case-insensitive, and the error
--- names both digests so a mismatch is diagnosable without re-running.
function M.verify(path, expected)
if expected == nil or expected == '' then
return true
end
local got, err = M.sha256_file(path)
if not got then
return false, string.format('checksum: %s', err)
end
if got:lower() ~= expected:lower() then
return false,
string.format('checksum mismatch for %s\n expected: %s\n got: %s', path, expected:lower(), got:lower())
end
return true, nil, got
end
return M
+87
View File
@@ -0,0 +1,87 @@
local M = {}
M.defaults = {
registry = nil,
plugins = {},
concurrency = 4,
timeout = 120000,
}
local function normalize_registry(registry, where)
if registry == nil then
return nil
end
if type(registry) ~= 'table' then
return nil, string.format('%s: registry must be a table', where)
end
if registry.auth and registry.auth.token ~= nil then
return nil, string.format('%s: registry auth must not carry a literal token; use env or token_cmd', where)
end
return registry
end
--- Validate and canonicalise user options into a config table.
--- Every plugin ends up with name, version, opt, dir_name and its own registry.
function M.normalize(opts)
opts = opts or {}
local cfg = vim.tbl_extend('force', vim.deepcopy(M.defaults), opts)
local default_registry, err = normalize_registry(cfg.registry, 'setup')
if err then
return nil, err
end
cfg.registry = default_registry
local seen = {}
local plugins = {}
for i, entry in ipairs(opts.plugins or {}) do
local spec = entry
if type(spec) == 'string' then
spec = { name = spec }
end
if type(spec) ~= 'table' then
return nil, string.format('plugins[%d]: expected table or string, got %s', i, type(entry))
end
spec = vim.deepcopy(spec)
if type(spec.name) ~= 'string' or spec.name == '' then
return nil, string.format('plugins[%d]: name is required', i)
end
if seen[spec.name] then
return nil, string.format('plugins[%d]: duplicate plugin name %q', i, spec.name)
end
seen[spec.name] = true
if not spec.url and (type(spec.version) ~= 'string' or spec.version == '') then
return nil, string.format('plugin %q: version is required unless url is given', spec.name)
end
local reg, rerr = normalize_registry(spec.registry, string.format('plugin %q', spec.name))
if rerr then
return nil, rerr
end
spec.registry = reg or cfg.registry
if not spec.registry and not spec.url then
return nil, string.format('plugin %q: no registry configured and no url given', spec.name)
end
spec.opt = spec.opt == true
spec.version = spec.version or 'url'
spec.dir_name = spec.dir_name or require('vimpack.util').slug(spec.name)
plugins[#plugins + 1] = spec
end
cfg.plugins = plugins
if type(cfg.concurrency) ~= 'number' or cfg.concurrency < 1 then
return nil, 'concurrency must be a positive number'
end
return cfg
end
--- Index plugins by name for O(1) lookup.
function M.by_name(cfg)
local out = {}
for _, spec in ipairs(cfg.plugins) do
out[spec.name] = spec
end
return out
end
return M
+180
View File
@@ -0,0 +1,180 @@
local util = require('vimpack.util')
local M = {}
local uv = vim.uv or vim.loop
function M.pack_root()
return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'vimpack')
end
function M.cache_dir()
return vim.fs.joinpath(vim.fn.stdpath('cache'), 'vimpack')
end
function M.target_dir(spec, root)
return vim.fs.joinpath(root or M.pack_root(), spec.opt and 'opt' or 'start', spec.dir_name or util.slug(spec.name))
end
function M.exists(path)
return uv.fs_stat(path) ~= nil
end
--- Map plugin dir name -> { opt, dir } for everything under the pack root.
function M.scan(root)
root = root or M.pack_root()
local out = {}
for _, bucket in ipairs({ 'start', 'opt' }) do
local dir = vim.fs.joinpath(root, bucket)
if M.exists(dir) then
for name, kind in vim.fs.dir(dir) do
if kind == 'directory' then
out[name] = { opt = bucket == 'opt', dir = vim.fs.joinpath(dir, name) }
end
end
end
end
return out
end
function M.rm_rf(path)
if path and path ~= '' and M.exists(path) then
vim.fn.delete(path, 'rf')
end
end
function M.mkdir_p(path)
vim.fn.mkdir(path, 'p')
return path
end
--- Archives usually wrap everything in a single top-level directory; hoist it
--- so pack/<name>/ holds lua/, plugin/, doc/ directly.
function M.strip_top_level(dir)
local entries = {}
for name, kind in vim.fs.dir(dir) do
entries[#entries + 1] = { name = name, kind = kind }
if #entries > 1 then
return dir
end
end
if #entries == 1 and entries[1].kind == 'directory' then
return vim.fs.joinpath(dir, entries[1].name)
end
return dir
end
--- Replace `target` with `staged` without ever leaving a half-written plugin
--- behind: the old tree is parked aside first and restored on failure.
function M.swap(staged, target)
M.mkdir_p(vim.fs.dirname(target))
local backup = target .. '.vimpack-old'
M.rm_rf(backup)
local had_previous = M.exists(target)
if had_previous then
local ok, err = uv.fs_rename(target, backup)
if not ok then
return false, string.format('cannot move existing install aside (%s): %s', target, err)
end
end
local ok, err = uv.fs_rename(staged, target)
if not ok then
if had_previous then
uv.fs_rename(backup, target)
end
return false, string.format('cannot move new install into place (%s): %s', target, err)
end
M.rm_rf(backup)
return true
end
--- Extract an archive into `dest` asynchronously. Calls cb(ok, err).
function M.extract(archive, dest, cb)
M.mkdir_p(dest)
local kind = util.archive_kind(archive)
local cmd
if kind == 'tar' then
cmd = { 'tar', '-xf', archive, '-C', dest }
elseif kind == 'zip' then
cmd = { 'unzip', '-q', '-o', archive, '-d', dest }
else
cb(false, string.format('unsupported archive type for %s (expected .tar.*, .tgz or .zip)', archive))
return
end
local ok, handle = pcall(vim.system, cmd, { text = true }, function(res)
vim.schedule(function()
if res.code ~= 0 then
cb(
false,
string.format(
'extraction failed for %s (%s exited %d): %s',
archive,
cmd[1],
res.code,
vim.trim(res.stderr or '')
)
)
else
cb(true)
end
end)
end)
if not ok then
cb(false, string.format('cannot run %s: %s (is it installed?)', cmd[1], handle))
end
end
--- Download a URL to `out` asynchronously with curl. Calls cb(ok, err).
function M.download(url, out, headers, timeout, cb)
M.mkdir_p(vim.fs.dirname(out))
local cmd = {
'curl',
'--fail-with-body',
'--location',
'--silent',
'--show-error',
'--write-out',
'%{http_code}',
'--max-time',
tostring(math.floor((timeout or 120000) / 1000)),
'--output',
out,
url,
}
for header, value in pairs(headers or {}) do
cmd[#cmd + 1] = '--header'
cmd[#cmd + 1] = header .. ': ' .. value
end
-- on_exit runs in a libuv callback where Vimscript functions are banned;
-- hop back to the main loop before touching vim.fn.
local ok, handle = pcall(vim.system, cmd, { text = true }, function(res)
vim.schedule(function()
if res.code ~= 0 then
M.rm_rf(out)
local status = vim.trim(res.stdout or '')
cb(
false,
string.format(
'download failed (HTTP %s, curl exited %d)\n url: %s\n %s',
status ~= '' and status or '?',
res.code,
url,
vim.trim(res.stderr or '')
)
)
else
cb(true)
end
end)
end)
if not ok then
cb(false, string.format('cannot run curl: %s (vimpack needs curl on PATH)', handle))
end
end
return M
+204
View File
@@ -0,0 +1,204 @@
local config = require('vimpack.config')
local fsmod = require('vimpack.fs')
local installer = require('vimpack.installer')
local manifest = require('vimpack.manifest')
local plan = require('vimpack.plan')
local registry = require('vimpack.registry')
local util = require('vimpack.util')
local M = {}
M.config = nil
--- Expose the resolver registry so users can plug in their own.
M.register_resolver = registry.register
function M.setup(opts)
local cfg, err = config.normalize(opts)
if not cfg then
util.err(err)
return nil, err
end
M.config = cfg
return cfg
end
local function require_config()
if not M.config then
util.err('not configured; call require("vimpack").setup{...} first')
return nil
end
return M.config
end
local function context(cfg, data)
return {
manifest = data,
pack_root = fsmod.pack_root(),
timeout = cfg.timeout,
concurrency = cfg.concurrency,
}
end
--- Disk scan keyed by plugin name rather than directory name.
local function installed_by_name(cfg)
local by_dir = fsmod.scan()
local out = {}
local claimed = {}
for _, spec in ipairs(cfg.plugins) do
local rec = by_dir[spec.dir_name]
if rec then
out[spec.name] = rec
claimed[spec.dir_name] = true
end
end
for dir_name, rec in pairs(by_dir) do
if not claimed[dir_name] then
out[dir_name] = rec
end
end
return out
end
local function report(results, data, verb)
local ok_count, failures = 0, {}
for _, res in ipairs(results) do
if res.ok then
ok_count = ok_count + 1
if not res.item.opt then
pcall(vim.cmd.packadd, res.item.dir_name)
end
else
failures[#failures + 1] = res.err
end
end
local wrote, werr = manifest.write(nil, data)
if not wrote then
failures[#failures + 1] = werr
end
if #failures > 0 then
util.err(string.format('%s: %d ok, %d failed\n%s', verb, ok_count, #failures, table.concat(failures, '\n')))
else
util.notify(string.format('%s: %d plugin(s)', verb, ok_count))
end
end
local function select_specs(items, names)
if not names or #names == 0 then
return items
end
local wanted = {}
for _, name in ipairs(names) do
wanted[name] = true
end
local out = {}
for _, item in ipairs(items) do
if wanted[item.spec.name] then
out[#out + 1] = item
end
end
return out
end
--- Install anything missing or drifted. Never touches up-to-date plugins.
function M.install(names)
local cfg = require_config()
if not cfg then
return
end
local data = manifest.read()
local built = plan.build(cfg, data, installed_by_name(cfg))
local items = select_specs(vim.list_extend(vim.deepcopy(built.install), built.update), names)
if #items == 0 then
util.notify('nothing to install')
return
end
installer.run(items, context(cfg, data), function(results)
report(results, data, 'install')
end)
end
--- Re-fetch plugins even when the manifest says they are current.
function M.update(names)
local cfg = require_config()
if not cfg then
return
end
local data = manifest.read()
local items = {}
for _, spec in ipairs(cfg.plugins) do
items[#items + 1] = { spec = spec, reason = 'forced update' }
end
items = select_specs(items, names)
if #items == 0 then
util.err(string.format('no plugin named %s in the spec', table.concat(names or {}, ', ')))
return
end
installer.run(items, context(cfg, data), function(results)
report(results, data, 'update')
end)
end
--- Remove installed plugins that are no longer in the spec.
function M.clean()
local cfg = require_config()
if not cfg then
return
end
local data = manifest.read()
local built = plan.build(cfg, data, installed_by_name(cfg))
if #built.orphans == 0 then
util.notify('nothing to clean')
return
end
local removed = {}
for _, orphan in ipairs(built.orphans) do
if orphan.dir then
fsmod.rm_rf(orphan.dir)
end
manifest.remove(data, orphan.name)
removed[#removed + 1] = orphan.name
end
local wrote, werr = manifest.write(nil, data)
if not wrote then
util.err(werr)
return
end
util.notify('removed ' .. table.concat(removed, ', '))
end
function M.status_rows()
local cfg = require_config()
if not cfg then
return {}
end
return plan.status_rows(cfg, manifest.read(), installed_by_name(cfg))
end
function M.status()
local rows = M.status_rows()
if #rows == 0 then
util.notify('no plugins configured')
return
end
local lines = { string.format('%-32s %-14s %-14s %s', 'PLUGIN', 'WANTED', 'INSTALLED', 'STATE') }
for _, row in ipairs(rows) do
lines[#lines + 1] = string.format('%-32s %-14s %-14s %s', row.name, row.wanted, row.installed, row.state)
end
vim.api.nvim_echo({ { table.concat(lines, '\n') } }, false, {})
end
function M.plugin_names()
local cfg = M.config
if not cfg then
return {}
end
local out = {}
for _, spec in ipairs(cfg.plugins) do
out[#out + 1] = spec.name
end
table.sort(out)
return out
end
return M
+132
View File
@@ -0,0 +1,132 @@
local checksum = require('vimpack.checksum')
local fsmod = require('vimpack.fs')
local registry = require('vimpack.registry')
local util = require('vimpack.util')
local M = {}
--- Run `worker(item, cb)` over items with at most `concurrency` in flight.
--- cb(ok, err) per item; `done(results)` once every item has reported.
function M.run_tasks(items, concurrency, worker, done)
local total = #items
local results = {}
if total == 0 then
done(results)
return
end
local next_index = 1
local finished = 0
local limit = math.min(math.max(concurrency, 1), total)
local function start()
local index = next_index
if index > total then
return
end
next_index = index + 1
worker(items[index], function(ok, err)
results[index] = { item = items[index], ok = ok, err = err }
finished = finished + 1
if finished == total then
done(results)
else
start()
end
end)
end
for _ = 1, limit do
start()
end
end
local function stage_path(spec)
return vim.fs.joinpath(
fsmod.cache_dir(),
'stage',
string.format('%s.%d', util.slug(spec.name), vim.uv.hrtime() % 1000000)
)
end
--- Download, verify, extract and swap a single plugin into place.
--- On any failure the previous install is left untouched.
function M.install_one(spec, ctx, cb)
local resolved, err = registry.resolve(spec, spec.registry)
if not resolved then
cb(false, err)
return
end
local archive = vim.fs.joinpath(
fsmod.cache_dir(),
'archives',
util.slug(spec.name) .. '-' .. spec.version .. '-' .. resolved.filename
)
local target = fsmod.target_dir(spec, ctx.pack_root)
local staged = stage_path(spec)
local function fail(message)
fsmod.rm_rf(staged)
cb(false, string.format('%s: %s', spec.name, message))
end
fsmod.download(resolved.url, archive, resolved.headers, ctx.timeout, function(ok, derr)
if not ok then
fail(derr)
return
end
local verified, verr, digest = checksum.verify(archive, resolved.sha256)
if not verified then
fsmod.rm_rf(archive)
fail(verr)
return
end
digest = digest or select(1, checksum.sha256_file(archive))
fsmod.rm_rf(staged)
fsmod.extract(archive, staged, function(eok, eerr)
if not eok then
fail(eerr)
return
end
local source = fsmod.strip_top_level(staged)
-- The other bucket may still hold this plugin after an opt/start flip.
local other = fsmod.target_dir(vim.tbl_extend('force', spec, { opt = not spec.opt }), ctx.pack_root)
local swapped, serr = fsmod.swap(source, target)
if not swapped then
fail(serr)
return
end
fsmod.rm_rf(staged)
fsmod.rm_rf(other)
ctx.manifest.plugins[spec.name] = {
version = spec.version,
sha256 = digest,
opt = spec.opt,
url = resolved.url,
dir = target,
installed_at = os.time(),
}
cb(true)
end)
end)
end
--- Install every item in `items` ({ spec = ..., reason = ... }) concurrently.
function M.run(items, ctx, done)
local specs = {}
for _, item in ipairs(items) do
specs[#specs + 1] = item.spec
end
M.run_tasks(specs, ctx.concurrency or 4, function(spec, cb)
M.install_one(spec, ctx, cb)
end, done)
end
return M
+70
View File
@@ -0,0 +1,70 @@
local M = {}
local uv = vim.uv or vim.loop
M.VERSION = 1
function M.empty()
return { version = M.VERSION, plugins = {} }
end
function M.path()
return vim.fs.joinpath(vim.fn.stdpath('data'), 'vimpack', 'manifest.json')
end
function M.read(path)
path = path or M.path()
local fd = uv.fs_open(path, 'r', 438)
if not fd then
return M.empty()
end
local stat = uv.fs_fstat(fd)
local data = stat and uv.fs_read(fd, stat.size, 0) or nil
uv.fs_close(fd)
if not data or data == '' then
return M.empty()
end
local ok, decoded = pcall(vim.json.decode, data)
if not ok or type(decoded) ~= 'table' then
return M.empty(), string.format('manifest %s is not valid JSON, starting fresh', path)
end
decoded.version = decoded.version or M.VERSION
if type(decoded.plugins) ~= 'table' then
decoded.plugins = {}
end
return decoded
end
function M.write(path, data)
path = path or M.path()
local dir = vim.fs.dirname(path)
vim.fn.mkdir(dir, 'p')
local fd, err = uv.fs_open(path .. '.tmp', 'w', 420)
if not fd then
return false, string.format('cannot write manifest %s: %s', path, err)
end
local encoded = vim.json.encode(data)
uv.fs_write(fd, encoded, 0)
uv.fs_close(fd)
local ok, rerr = uv.fs_rename(path .. '.tmp', path)
if not ok then
return false, string.format('cannot rename manifest into place: %s', rerr)
end
return true
end
function M.get(data, name)
return data.plugins[name]
end
function M.set(data, name, entry)
data.plugins[name] = entry
return data
end
function M.remove(data, name)
data.plugins[name] = nil
return data
end
return M
+94
View File
@@ -0,0 +1,94 @@
local M = {}
--- Decide what a single plugin needs. `installed` is the on-disk record for the
--- plugin name: { opt = boolean } or nil when nothing is on disk.
--- Returns 'install' | 'update' | 'skip' plus a human reason.
function M.decide(spec, entry, installed)
if not installed then
return 'install', 'not installed'
end
if not entry then
return 'update', 'no manifest entry for an existing directory'
end
if entry.version ~= spec.version then
return 'update', string.format('version %s -> %s', entry.version, spec.version)
end
if spec.sha256 and entry.sha256 and entry.sha256:lower() ~= spec.sha256:lower() then
return 'update', 'sha256 in spec differs from installed'
end
if installed.opt ~= spec.opt then
return 'update', string.format('moves to %s/', spec.opt and 'opt' or 'start')
end
if spec.url and entry.url and entry.url ~= spec.url then
return 'update', 'url changed'
end
return 'skip', string.format('%s already at %s', spec.name, spec.version)
end
--- Build the full plan for a config against the manifest and the disk scan.
--- `installed` maps plugin name -> { opt = boolean, dir = string }.
function M.build(cfg, manifest, installed)
installed = installed or {}
local plan = { install = {}, update = {}, skip = {}, orphans = {} }
local wanted = {}
for _, spec in ipairs(cfg.plugins) do
wanted[spec.name] = true
local action, reason = M.decide(spec, manifest.plugins[spec.name], installed[spec.name])
plan[action][#plan[action] + 1] = { spec = spec, reason = reason }
end
for name, rec in pairs(installed) do
if not wanted[name] then
plan.orphans[#plan.orphans + 1] = { name = name, dir = rec.dir, opt = rec.opt }
end
end
for name in pairs(manifest.plugins) do
if not wanted[name] and not installed[name] then
plan.orphans[#plan.orphans + 1] = { name = name, dir = nil, opt = false }
end
end
table.sort(plan.orphans, function(a, b)
return a.name < b.name
end)
return plan
end
--- Rows for :VimpackStatus, sorted by name.
function M.status_rows(cfg, manifest, installed)
installed = installed or {}
local plan = M.build(cfg, manifest, installed)
local rows = {}
local function push(list, state)
for _, item in ipairs(list) do
local entry = manifest.plugins[item.spec.name]
rows[#rows + 1] = {
name = item.spec.name,
wanted = item.spec.version,
installed = entry and entry.version or '-',
state = state,
reason = item.reason,
}
end
end
push(plan.skip, 'ok')
push(plan.install, 'missing')
push(plan.update, 'drift')
for _, orphan in ipairs(plan.orphans) do
local entry = manifest.plugins[orphan.name]
rows[#rows + 1] = {
name = orphan.name,
wanted = '-',
installed = entry and entry.version or 'unknown',
state = 'orphan',
reason = 'not in spec',
}
end
table.sort(rows, function(a, b)
return a.name < b.name
end)
return rows
end
return M
+166
View File
@@ -0,0 +1,166 @@
local util = require('vimpack.util')
local M = {}
M.DEFAULT_EXT = 'tar.gz'
--- @class VimpackResolved
--- @field url string fully qualified archive URL
--- @field headers table HTTP headers to send (may be empty)
--- @field filename string name to store the downloaded archive under
--- @field sha256 string|nil digest advertised by the registry, if any
--- @alias VimpackResolver fun(spec: table, registry: table): VimpackResolved|nil, string|nil
--- @type table<string, VimpackResolver>
M.resolvers = {}
--- Register a resolver. A resolver is a plain function
--- `(spec, registry) -> resolved|nil, err` where `resolved` carries url,
--- headers, filename and an optional sha256.
function M.register(name, fn)
if type(name) ~= 'string' or name == '' then
error('vimpack: resolver name must be a non-empty string')
end
if type(fn) ~= 'function' then
error('vimpack: resolver ' .. name .. ' must be a function')
end
M.resolvers[name] = fn
end
local function template_vars(spec, registry)
return {
name = spec.name,
version = spec.version,
ext = spec.ext or registry.ext or M.DEFAULT_EXT,
slug = util.slug(spec.name),
repo = registry.repo,
base_url = registry.base_url and registry.base_url:gsub('/+$', '') or nil,
}
end
--- Generic templated-URL resolver: the user supplies `url_template` using
--- ${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url}.
M.register('template', function(spec, registry)
if not registry.url_template then
return nil, 'registry kind "template" requires a url_template'
end
local vars = template_vars(spec, registry)
local url, err = util.interpolate(registry.url_template, vars)
if not url then
return nil, err
end
local filename = registry.filename_template
if filename then
local rendered, ferr = util.interpolate(filename, vars)
if not rendered then
return nil, ferr
end
filename = rendered
end
return { url = url, headers = {}, filename = filename or util.url_basename(url) }
end)
--- artifactapi generic-repo layout:
--- {base_url}/api/v2/remotes/{repo}/files/{path_template}
--- path_template defaults to ${name}/${name}-${version}.${ext}.
M.register('artifactapi', function(spec, registry)
if not registry.base_url then
return nil, 'registry kind "artifactapi" requires a base_url'
end
if not registry.repo then
return nil, 'registry kind "artifactapi" requires a repo'
end
local vars = template_vars(spec, registry)
local path, err = util.interpolate(registry.path_template or '${name}/${name}-${version}.${ext}', vars)
if not path then
return nil, err
end
local url = string.format('%s/api/v2/remotes/%s/files/%s', vars.base_url, registry.repo, path)
return { url = url, headers = {}, filename = util.url_basename(url) }
end)
--- Resolve the auth header for a registry. Tokens come from the environment or
--- from a command that prints one; a literal token in config is rejected.
function M.build_headers(registry)
local auth = registry.auth
if not auth then
return {}
end
if auth.token ~= nil then
return nil, 'registry auth must not carry a literal token; use env or token_cmd'
end
local token
if auth.env then
token = vim.env[auth.env]
if token == nil or token == '' then
return nil, string.format('registry auth env %s is unset or empty', auth.env)
end
elseif auth.token_cmd then
local cmd = auth.token_cmd
if type(cmd) == 'string' then
cmd = { 'sh', '-c', cmd }
end
local ok, res = pcall(function()
return vim.system(cmd, { text = true }):wait()
end)
if not ok then
return nil, string.format('registry auth token_cmd failed to start: %s', res)
end
if res.code ~= 0 then
return nil, string.format('registry auth token_cmd exited %d: %s', res.code, vim.trim(res.stderr or ''))
end
token = vim.trim(res.stdout or '')
if token == '' then
return nil, 'registry auth token_cmd printed nothing'
end
else
return nil, 'registry auth needs either env or token_cmd'
end
local header = auth.header or 'Authorization'
local prefix = auth.prefix
if prefix == nil and header:lower() == 'authorization' then
prefix = 'Bearer '
end
return { [header] = (prefix or '') .. token }
end
--- Turn a plugin spec plus its registry into a download target.
--- A spec-level `url` short-circuits registry resolution entirely.
function M.resolve(spec, registry)
if spec.url then
return {
url = spec.url,
headers = registry and select(1, M.build_headers(registry)) or {},
filename = spec.filename or util.url_basename(spec.url),
sha256 = spec.sha256,
}
end
if not registry then
return nil, string.format('plugin %q has no registry and no url', spec.name)
end
local kind = registry.kind or 'template'
local resolver = M.resolvers[kind]
if not resolver then
return nil,
string.format('unknown registry kind %q (known: %s)', kind, table.concat(vim.tbl_keys(M.resolvers), ', '))
end
local resolved, err = resolver(spec, registry)
if not resolved then
return nil, string.format('plugin %q: %s', spec.name, err or 'resolver returned nothing')
end
local headers, herr = M.build_headers(registry)
if not headers then
return nil, string.format('plugin %q: %s', spec.name, herr)
end
resolved.headers = vim.tbl_extend('force', headers, resolved.headers or {})
resolved.sha256 = spec.sha256 or resolved.sha256
resolved.filename = resolved.filename or util.url_basename(resolved.url)
return resolved
end
return M
+81
View File
@@ -0,0 +1,81 @@
-- SHA-256 over LuaJIT bit ops. vim.fn.sha256() rejects binary input (E976:
-- Using a Blob as a String), and archives are binary, so vimpack hashes here.
local bit = require('bit')
local band, bxor, bnot = bit.band, bit.bxor, bit.bnot
local rshift, rol, tobit, tohex = bit.rshift, bit.rol, bit.tobit, bit.tohex
local byte, rep, sub = string.byte, string.rep, string.sub
-- stylua: ignore
local K = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
for i = 1, 64 do
K[i] = tobit(K[i])
end
local function pad(msg)
local len = #msg
local bits = len * 8
local pad_len = (55 - len) % 64
local tail = {}
-- 64-bit big-endian bit length; the top word stays 0 below 512 MiB.
local hi = math.floor(bits / 4294967296)
local lo = bits % 4294967296
for shift = 24, 0, -8 do
tail[#tail + 1] = string.char(band(rshift(tobit(hi), shift), 0xff))
end
for shift = 24, 0, -8 do
tail[#tail + 1] = string.char(band(rshift(tobit(lo), shift), 0xff))
end
return msg .. '\128' .. rep('\0', pad_len) .. table.concat(tail)
end
--- Hex digest of an arbitrary (binary-safe) string.
local function sha256(msg)
local h0, h1, h2, h3 = tobit(0x6a09e667), tobit(0xbb67ae85), tobit(0x3c6ef372), tobit(0xa54ff53a)
local h4, h5, h6, h7 = tobit(0x510e527f), tobit(0x9b05688c), tobit(0x1f83d9ab), tobit(0x5be0cd19)
local data = pad(msg)
local w = {}
for offset = 1, #data, 64 do
local chunk = sub(data, offset, offset + 63)
for i = 1, 16 do
local b1, b2, b3, b4 = byte(chunk, i * 4 - 3, i * 4)
w[i] = tobit(b1 * 16777216 + b2 * 65536 + b3 * 256 + b4)
end
for i = 17, 64 do
local x, y = w[i - 15], w[i - 2]
local s0 = bxor(rol(x, 25), rol(x, 14), rshift(x, 3))
local s1 = bxor(rol(y, 15), rol(y, 13), rshift(y, 10))
w[i] = tobit(w[i - 16] + s0 + w[i - 7] + s1)
end
local a, b, c, d, e, f, g, h = h0, h1, h2, h3, h4, h5, h6, h7
for i = 1, 64 do
local S1 = bxor(rol(e, 26), rol(e, 21), rol(e, 7))
local ch = bxor(band(e, f), band(bnot(e), g))
local t1 = tobit(h + S1 + ch + K[i] + w[i])
local S0 = bxor(rol(a, 30), rol(a, 19), rol(a, 10))
local maj = bxor(band(a, b), band(a, c), band(b, c))
local t2 = tobit(S0 + maj)
h, g, f, e = g, f, e, tobit(d + t1)
d, c, b, a = c, b, a, tobit(t1 + t2)
end
h0, h1, h2, h3 = tobit(h0 + a), tobit(h1 + b), tobit(h2 + c), tobit(h3 + d)
h4, h5, h6, h7 = tobit(h4 + e), tobit(h5 + f), tobit(h6 + g), tobit(h7 + h)
end
return tohex(h0) .. tohex(h1) .. tohex(h2) .. tohex(h3) .. tohex(h4) .. tohex(h5) .. tohex(h6) .. tohex(h7)
end
return { hash = sha256 }
+63
View File
@@ -0,0 +1,63 @@
local M = {}
M.PLACEHOLDER = '%${([%w_]+)}'
--- Substitute ${key} placeholders. Returns nil plus the offending key when a
--- placeholder has no value, so callers can report the exact template fault.
function M.interpolate(template, vars)
if type(template) ~= 'string' then
return nil, 'template must be a string, got ' .. type(template)
end
local missing
local out = template:gsub(M.PLACEHOLDER, function(key)
local value = vars[key]
if value == nil then
missing = missing or key
return ''
end
return tostring(value)
end)
if missing then
return nil, string.format('template %q references unknown placeholder ${%s}', template, missing)
end
return out
end
local TAR_SUFFIXES = { '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tar' }
--- Classify an archive by filename suffix: 'tar', 'zip' or nil.
function M.archive_kind(filename)
local lower = filename:lower()
if lower:sub(-4) == '.zip' then
return 'zip'
end
for _, suffix in ipairs(TAR_SUFFIXES) do
if lower:sub(-#suffix) == suffix then
return 'tar'
end
end
return nil
end
--- Filename component of a URL, query string and fragment stripped.
function M.url_basename(url)
local path = url:gsub('[?#].*$', '')
return path:match('([^/]+)$') or path
end
--- Path-safe form of a plugin name (telescope.nvim stays readable).
function M.slug(name)
return (name:gsub('[^%w%.%-_]', '_'))
end
function M.notify(msg, level)
vim.schedule(function()
vim.notify('[vimpack] ' .. msg, level or vim.log.levels.INFO)
end)
end
function M.err(msg)
M.notify(msg, vim.log.levels.ERROR)
end
return M
+24
View File
@@ -0,0 +1,24 @@
if vim.g.loaded_vimpack then
return
end
vim.g.loaded_vimpack = true
local function complete()
return require('vimpack').plugin_names()
end
vim.api.nvim_create_user_command('VimpackInstall', function(opts)
require('vimpack').install(opts.fargs)
end, { nargs = '*', complete = complete, desc = 'Install missing or drifted vimpack plugins' })
vim.api.nvim_create_user_command('VimpackUpdate', function(opts)
require('vimpack').update(opts.fargs)
end, { nargs = '*', complete = complete, desc = 'Re-fetch vimpack plugins' })
vim.api.nvim_create_user_command('VimpackClean', function()
require('vimpack').clean()
end, { nargs = 0, desc = 'Remove installed plugins no longer in the spec' })
vim.api.nvim_create_user_command('VimpackStatus', function()
require('vimpack').status()
end, { nargs = 0, desc = 'Show installed vs wanted plugin versions' })
+105
View File
@@ -0,0 +1,105 @@
local fsmod = require('vimpack.fs')
describe('scan', function()
it('maps directories to their bucket', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/start/a/lua', 'p')
vim.fn.mkdir(root .. '/opt/b', 'p')
vim.fn.writefile({ '' }, root .. '/start/loose.txt')
local found = fsmod.scan(root)
assert_eq(false, found.a.opt)
assert_eq(true, found.b.opt)
assert_nil(found['loose.txt'])
end)
it('returns nothing for a missing root', function()
assert_eq({}, fsmod.scan(tmpdir() .. '/absent'))
end)
end)
describe('target_dir', function()
it('splits start and opt', function()
assert_eq('/r/start/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = false }, '/r'))
assert_eq('/r/opt/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = true }, '/r'))
end)
end)
describe('strip_top_level', function()
it('hoists a single wrapper directory', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/oil.nvim-2.11.0/lua', 'p')
assert_eq(root .. '/oil.nvim-2.11.0', fsmod.strip_top_level(root))
end)
it('leaves a flat archive alone', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/lua', 'p')
vim.fn.mkdir(root .. '/plugin', 'p')
assert_eq(root, fsmod.strip_top_level(root))
end)
end)
describe('swap', function()
it('replaces an existing install', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
vim.fn.mkdir(root .. '/staged', 'p')
vim.fn.writefile({ 'new' }, root .. '/staged/marker')
assert_true(fsmod.swap(root .. '/staged', root .. '/target'))
assert_eq({ 'new' }, vim.fn.readfile(root .. '/target/marker'))
assert_true(not fsmod.exists(root .. '/target.vimpack-old'))
end)
it('keeps the previous install when the staged tree is missing', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
local ok, err = fsmod.swap(root .. '/absent', root .. '/target')
assert_true(not ok)
assert_match('cannot move new install into place', err)
assert_eq({ 'old' }, vim.fn.readfile(root .. '/target/marker'))
end)
end)
describe('extract', function()
local function wait_extract(archive, dest)
local result
fsmod.extract(archive, dest, function(ok, err)
result = { ok = ok, err = err }
end)
vim.wait(10000, function()
return result ~= nil
end)
return result
end
it('unpacks a tarball', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')
vim.fn.writefile({ 'return {}' }, root .. '/src/plug/lua/plug.lua')
vim.fn.system({ 'tar', '-czf', root .. '/plug.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/plug.tar.gz', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/lua/plug.lua'))
assert_eq(root .. '/dest/plug', fsmod.strip_top_level(root .. '/dest'))
end)
it('rejects an unknown archive type', function()
local result = wait_extract(tmpdir() .. '/thing.rpm', tmpdir() .. '/dest')
assert_true(not result.ok)
assert_match('unsupported archive type', result.err)
end)
it('reports a corrupt tarball', function()
local root = tmpdir()
vim.fn.writefile({ 'definitely not a tarball' }, root .. '/bad.tar.gz')
local result = wait_extract(root .. '/bad.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('extraction failed', result.err)
end)
end)
+154
View File
@@ -0,0 +1,154 @@
-- End-to-end install over file:// URLs: real curl, real tar, real swap.
local checksum = require('vimpack.checksum')
local config = require('vimpack.config')
local fsmod = require('vimpack.fs')
local installer = require('vimpack.installer')
local manifest = require('vimpack.manifest')
local plan = require('vimpack.plan')
local function make_tarball(root, version, body)
local src = vim.fs.joinpath(root, 'src', 'demo.nvim-' .. version, 'lua')
vim.fn.mkdir(src, 'p')
vim.fn.writefile({ body }, vim.fs.joinpath(src, 'demo.lua'))
local archive = vim.fs.joinpath(root, 'demo-' .. version .. '.tar.gz')
vim.fn.system({ 'tar', '-czf', archive, '-C', vim.fs.joinpath(root, 'src'), 'demo.nvim-' .. version })
return archive
end
local function install(cfg, data, pack_root)
local built = plan.build(cfg, data, fsmod.scan(pack_root))
local items = vim.list_extend(vim.deepcopy(built.install), built.update)
local results
installer.run(items, {
manifest = data,
pack_root = pack_root,
timeout = 30000,
concurrency = 2,
}, function(r)
results = r
end)
vim.wait(30000, function()
return results ~= nil
end)
return results, items
end
describe('install end to end', function()
it('downloads, extracts, swaps and records the manifest', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local archive = make_tarball(root, '1.0.0', 'return 1')
local cfg = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } },
}))
local data = manifest.empty()
local results = install(cfg, data, pack_root)
assert_eq(1, #results)
assert_true(results[1].ok, tostring(results[1].err))
local installed = vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')
assert_true(fsmod.exists(installed), 'expected ' .. installed)
assert_eq({ 'return 1' }, vim.fn.readfile(installed))
local entry = manifest.get(data, 'demo.nvim')
assert_eq('1.0.0', entry.version)
assert_eq(assert(checksum.sha256_file(archive)), entry.sha256)
-- Second pass is a no-op: nothing left to install.
local _, items = install(cfg, data, pack_root)
assert_eq(0, #items)
end)
it('replaces the old tree on a version bump', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local v1 = make_tarball(root, '1.0.0', 'return 1')
local cfg1 = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } },
}))
assert_true(install(cfg1, data, pack_root)[1].ok)
local v2 = make_tarball(root, '2.0.0', 'return 2')
local cfg2 = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2 } },
}))
local results = install(cfg2, data, pack_root)
assert_true(results[1].ok, tostring(results[1].err))
assert_eq({ 'return 2' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')))
assert_eq('2.0.0', manifest.get(data, 'demo.nvim').version)
end)
it('aborts on a checksum mismatch and keeps the previous install', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local v1 = make_tarball(root, '1.0.0', 'return 1')
assert_true(install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } },
})),
data,
pack_root
)[1].ok)
local v2 = make_tarball(root, '2.0.0', 'return 2')
local results = install(
assert(config.normalize({
plugins = {
{ name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2, sha256 = string.rep('a', 64) },
},
})),
data,
pack_root
)
assert_true(not results[1].ok)
assert_match('checksum mismatch', results[1].err)
assert_eq({ 'return 1' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')))
assert_eq('1.0.0', manifest.get(data, 'demo.nvim').version)
end)
it('names the url when the download fails', function()
local root = tmpdir()
local missing = 'file://' .. vim.fs.joinpath(root, 'nope.tar.gz')
local results = install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = missing } },
})),
manifest.empty(),
vim.fs.joinpath(root, 'pack')
)
assert_true(not results[1].ok)
assert_match('download failed', results[1].err)
assert_match(missing, results[1].err)
end)
it('moves a plugin from start to opt', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local data = manifest.empty()
local archive = make_tarball(root, '1.0.0', 'return 1')
assert_true(install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } },
})),
data,
pack_root
)[1].ok)
local results = install(
assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive, opt = true } },
})),
data,
pack_root
)
assert_true(results[1].ok, tostring(results[1].err))
assert_true(fsmod.exists(vim.fs.joinpath(pack_root, 'opt', 'demo.nvim', 'lua', 'demo.lua')))
assert_true(not fsmod.exists(vim.fs.joinpath(pack_root, 'start', 'demo.nvim')))
end)
end)
+143
View File
@@ -0,0 +1,143 @@
local checksum = require('vimpack.checksum')
local config = require('vimpack.config')
local manifest = require('vimpack.manifest')
describe('manifest', function()
it('returns an empty manifest for a missing file', function()
local data = manifest.read(tmpdir() .. '/nope.json')
assert_eq(1, data.version)
assert_eq({}, data.plugins)
end)
it('round-trips through disk', function()
local path = tmpdir() .. '/manifest.json'
local data = manifest.empty()
manifest.set(data, 'oil.nvim', { version = '2.11.0', sha256 = 'abc', opt = false })
assert_true(manifest.write(path, data))
local read_back = manifest.read(path)
assert_eq('2.11.0', manifest.get(read_back, 'oil.nvim').version)
assert_eq('abc', read_back.plugins['oil.nvim'].sha256)
end)
it('survives a corrupt file', function()
local path = tmpdir() .. '/manifest.json'
vim.fn.writefile({ 'not json {' }, path)
local data, err = manifest.read(path)
assert_eq({}, data.plugins)
assert_match('not valid JSON', err)
end)
it('removes entries', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1' })
manifest.remove(data, 'a')
assert_nil(manifest.get(data, 'a'))
end)
end)
describe('sha256', function()
local sha256 = require('vimpack.sha256')
it('matches the NIST vectors', function()
assert_eq('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', sha256.hash(''))
assert_eq('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', sha256.hash('abc'))
assert_eq(
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1',
sha256.hash('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')
)
end)
it('handles binary data and multi-block input', function()
assert_eq(
'79eebf0b2aae05b2490234d0b884581935b2e28127f06a74a5b48e758f86bd25',
sha256.hash(string.char(0, 1, 2, 255, 0))
)
assert_eq('541b3e9daa09b20bf85fa273e5cbd3e80185aa4ec298e765db87742b70138a53', sha256.hash(string.rep('\0', 1000)))
assert_eq('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0', sha256.hash(string.rep('a', 1000000)))
end)
end)
describe('checksum', function()
local function write(contents)
local path = tmpdir() .. '/blob.bin'
vim.fn.writefile({ contents }, path, 'b')
return path
end
it('passes when the digest matches', function()
local path = write('hello vimpack')
local digest = assert(checksum.sha256_file(path))
assert_true(checksum.verify(path, digest))
assert_true(checksum.verify(path, digest:upper()))
end)
it('skips verification when no digest is configured', function()
assert_true(checksum.verify(write('x'), nil))
end)
it('names expected and got on mismatch', function()
local path = write('hello vimpack')
local ok, err = checksum.verify(path, string.rep('0', 64))
assert_true(not ok)
assert_match('checksum mismatch', err)
assert_match(string.rep('0', 64), err)
assert_match(assert(checksum.sha256_file(path)), err)
end)
it('reports an unreadable file', function()
local ok, err = checksum.verify(tmpdir() .. '/missing', string.rep('0', 64))
assert_true(not ok)
assert_match('checksum', err)
end)
end)
describe('config', function()
local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' }
it('normalizes strings and defaults', function()
local cfg = assert(config.normalize({
registry = reg,
plugins = { { name = 'oil.nvim', version = '2.11.0' } },
}))
assert_eq(false, cfg.plugins[1].opt)
assert_eq('oil.nvim', cfg.plugins[1].dir_name)
assert_eq(reg, cfg.plugins[1].registry)
end)
it('lets a plugin override the registry', function()
local other = { kind = 'template', url_template = 'https://other/${name}' }
local cfg = assert(config.normalize({
registry = reg,
plugins = { { name = 'a', version = '1', registry = other } },
}))
assert_eq('https://other/${name}', cfg.plugins[1].registry.url_template)
end)
it('requires a version unless a url is given', function()
local cfg, err = config.normalize({ registry = reg, plugins = { { name = 'a' } } })
assert_nil(cfg)
assert_match('version is required', err)
assert_true(config.normalize({ plugins = { { name = 'a', url = 'https://h/a.tar.gz' } } }) ~= nil)
end)
it('rejects duplicates and literal tokens', function()
local _, dup = config.normalize({
registry = reg,
plugins = { { name = 'a', version = '1' }, { name = 'a', version = '2' } },
})
assert_match('duplicate', dup)
local _, tok = config.normalize({
registry = vim.tbl_extend('force', reg, { auth = { token = 'literal' } }),
plugins = {},
})
assert_match('literal token', tok)
end)
it('rejects a plugin with no registry and no url', function()
local cfg, err = config.normalize({ plugins = { { name = 'a', version = '1' } } })
assert_nil(cfg)
assert_match('no registry', err)
end)
end)
+92
View File
@@ -0,0 +1,92 @@
-- Minimal busted-flavoured harness: no rocks, no vendored plugins.
-- Run with: nvim --headless --clean -l tests/minimal.lua tests/*_spec.lua
local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h')
vim.opt.runtimepath:prepend(root)
package.path = table.concat({ root .. '/lua/?.lua', root .. '/lua/?/init.lua', package.path }, ';')
local M = { passed = 0, failed = 0, failures = {} }
local stack = {}
local function describe(name, fn)
stack[#stack + 1] = name
fn()
stack[#stack] = nil
end
local function it(name, fn)
local label = table.concat(stack, ' ') .. ' ' .. name
local ok, err = pcall(fn)
if ok then
M.passed = M.passed + 1
io.write('ok ' .. label .. '\n')
else
M.failed = M.failed + 1
M.failures[#M.failures + 1] = label .. '\n ' .. tostring(err)
io.write('FAIL ' .. label .. '\n ' .. tostring(err) .. '\n')
end
end
local assert_eq = function(expected, got, msg)
if not vim.deep_equal(expected, got) then
error(
string.format('%sexpected %s, got %s', msg and (msg .. ': ') or '', vim.inspect(expected), vim.inspect(got)),
2
)
end
end
local assert_true = function(value, msg)
if not value then
error(msg or ('expected truthy, got ' .. vim.inspect(value)), 2)
end
end
local assert_nil = function(value, msg)
if value ~= nil then
error(msg or ('expected nil, got ' .. vim.inspect(value)), 2)
end
end
local assert_match = function(pattern, str, msg)
if type(str) ~= 'string' or not str:find(pattern, 1, true) then
error(string.format('%sexpected %s to contain %q', msg and (msg .. ': ') or '', vim.inspect(str), pattern), 2)
end
end
_G.describe = describe
_G.it = it
_G.assert_eq = assert_eq
_G.assert_true = assert_true
_G.assert_nil = assert_nil
_G.assert_match = assert_match
_G.tmpdir = function()
local dir = vim.fs.joinpath(vim.fn.tempname(), 'vimpack-test')
vim.fn.mkdir(dir, 'p')
return dir
end
local files = {}
for i = 1, #_G.arg do
files[#files + 1] = _G.arg[i]
end
if #files == 0 then
for name, kind in vim.fs.dir(root .. '/tests') do
if kind == 'file' and name:match('_spec%.lua$') then
files[#files + 1] = root .. '/tests/' .. name
end
end
table.sort(files)
end
for _, file in ipairs(files) do
dofile(file)
end
io.write(string.format('\n%d passed, %d failed\n', M.passed, M.failed))
if M.failed > 0 then
for _, failure in ipairs(M.failures) do
io.write('\n' .. failure .. '\n')
end
vim.cmd('cquit 1')
end
vim.cmd('quit')
+140
View File
@@ -0,0 +1,140 @@
local config = require('vimpack.config')
local installer = require('vimpack.installer')
local manifest = require('vimpack.manifest')
local plan = require('vimpack.plan')
local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' }
local function cfg_with(plugins)
return assert(config.normalize({ registry = reg, plugins = plugins }))
end
describe('plan', function()
it('installs what is not on disk', function()
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), {})
assert_eq(1, #built.install)
assert_eq('not installed', built.install[1].reason)
end)
it('skips an install that already matches', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'ff', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(0, #built.install)
assert_eq(0, #built.update)
assert_eq(1, #built.skip)
end)
it('updates on a version bump', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '2' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('1 -> 2', built.update[1].reason)
end)
it('updates when the spec sha256 differs from the installed one', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'aa', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', sha256 = 'bb' } }),
data,
{ a = { opt = false, dir = '/x/a' } }
)
assert_eq(1, #built.update)
assert_match('sha256', built.update[1].reason)
end)
it('updates when a plugin moves between start and opt', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', opt = true } }),
data,
{ a = { opt = false, dir = '/x/start/a' } }
)
assert_eq(1, #built.update)
assert_match('opt/', built.update[1].reason)
end)
it('updates a directory with no manifest entry', function()
local built =
plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('no manifest entry', built.update[1].reason)
end)
it('flags orphans on disk and in the manifest', function()
local data = manifest.empty()
manifest.set(data, 'ghost', { version = '9' })
local built = plan.build(
cfg_with({ { name = 'a', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, old = { opt = true, dir = '/x/opt/old' } }
)
assert_eq(2, #built.orphans)
assert_eq('ghost', built.orphans[1].name)
assert_eq('old', built.orphans[2].name)
assert_eq('/x/opt/old', built.orphans[2].dir)
end)
end)
describe('status_rows', function()
it('reports one row per plugin plus orphans', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local rows = plan.status_rows(
cfg_with({ { name = 'a', version = '2' }, { name = 'b', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, gone = { opt = false, dir = '/x/gone' } }
)
assert_eq(3, #rows)
assert_eq({ 'a', 'b', 'gone' }, { rows[1].name, rows[2].name, rows[3].name })
assert_eq({ 'drift', 'missing', 'orphan' }, { rows[1].state, rows[2].state, rows[3].state })
assert_eq('1', rows[1].installed)
assert_eq('2', rows[1].wanted)
end)
end)
describe('run_tasks', function()
it('runs every item and respects the concurrency cap', function()
local items = { 1, 2, 3, 4, 5 }
local inflight, peak, seen = 0, 0, {}
local done_results
installer.run_tasks(items, 2, function(item, cb)
inflight = inflight + 1
peak = math.max(peak, inflight)
vim.schedule(function()
seen[#seen + 1] = item
inflight = inflight - 1
cb(true)
end)
end, function(results)
done_results = results
end)
vim.wait(1000, function()
return done_results ~= nil
end)
assert_eq(5, #done_results)
assert_eq(5, #seen)
assert_true(peak <= 2, 'peak concurrency was ' .. peak)
end)
it('completes immediately on an empty list', function()
local called = false
installer.run_tasks({}, 4, function() end, function()
called = true
end)
assert_true(called)
end)
it('carries failures through', function()
local results
installer.run_tasks({ 'a' }, 1, function(_, cb)
cb(false, 'boom')
end, function(r)
results = r
end)
assert_eq('boom', results[1].err)
end)
end)
+136
View File
@@ -0,0 +1,136 @@
local registry = require('vimpack.registry')
local util = require('vimpack.util')
describe('interpolate', function()
it('substitutes every placeholder', function()
assert_eq(
'a/1.0/a.tar.gz',
(util.interpolate('${name}/${version}/${name}.${ext}', { name = 'a', version = '1.0', ext = 'tar.gz' }))
)
end)
it('reports an unknown placeholder', function()
local out, err = util.interpolate('${name}-${nope}', { name = 'a' })
assert_nil(out)
assert_match('${nope}', err)
end)
end)
describe('template resolver', function()
local reg = {
kind = 'template',
url_template = 'https://artifacts.example.com/nvim/${name}/${version}/${name}-${version}.${ext}',
}
it('renders name and version into the url', function()
local resolved = assert(registry.resolve({ name = 'telescope.nvim', version = '0.1.8' }, reg))
assert_eq('https://artifacts.example.com/nvim/telescope.nvim/0.1.8/telescope.nvim-0.1.8.tar.gz', resolved.url)
assert_eq('telescope.nvim-0.1.8.tar.gz', resolved.filename)
end)
it('honours a per-plugin ext override', function()
local resolved = assert(registry.resolve({ name = 'plenary.nvim', version = '1', ext = 'zip' }, reg))
assert_match('plenary.nvim-1.zip', resolved.url)
end)
it('fails loudly without a url_template', function()
local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'template' })
assert_nil(resolved)
assert_match('url_template', err)
end)
it('names the unknown kind', function()
local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'nosuch' })
assert_nil(resolved)
assert_match('nosuch', err)
end)
end)
describe('artifactapi resolver', function()
local reg = {
kind = 'artifactapi',
base_url = 'https://artifactapi.k8s.syd1.au.unkin.net/',
repo = 'nvim-plugins',
}
it('builds the generic-repo path', function()
local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg))
assert_eq(
'https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/nvim-plugins/files/oil.nvim/oil.nvim-2.11.0.tar.gz',
resolved.url
)
end)
it('accepts a path_template override', function()
local reg2 = vim.tbl_extend('force', reg, { path_template = '${version}/${slug}.${ext}' })
local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg2))
assert_match('/files/2.11.0/oil.nvim.tar.gz', resolved.url)
end)
end)
describe('url escape hatch', function()
it('bypasses the registry entirely', function()
local resolved = assert(registry.resolve({ name = 'x', version = '1', url = 'https://h/x.zip' }, nil))
assert_eq('https://h/x.zip', resolved.url)
assert_eq('x.zip', resolved.filename)
end)
end)
describe('auth headers', function()
it('reads a token from the environment', function()
vim.env.VIMPACK_TEST_TOKEN = 'sekret'
local headers = assert(registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } }))
assert_eq({ Authorization = 'Bearer sekret' }, headers)
vim.env.VIMPACK_TEST_TOKEN = nil
end)
it('reports an unset env var', function()
vim.env.VIMPACK_TEST_TOKEN = nil
local headers, err = registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } })
assert_nil(headers)
assert_match('VIMPACK_TEST_TOKEN', err)
end)
it('runs token_cmd and uses a custom header', function()
local headers = assert(registry.build_headers({
auth = { token_cmd = { 'printf', '%s', 'abc123' }, header = 'X-JFrog-Art-Api', prefix = '' },
}))
assert_eq({ ['X-JFrog-Art-Api'] = 'abc123' }, headers)
end)
it('rejects a literal token', function()
local headers, err = registry.build_headers({ auth = { token = 'oops' } })
assert_nil(headers)
assert_match('literal token', err)
end)
it('attaches auth to a resolved url', function()
vim.env.VIMPACK_TEST_TOKEN = 'sekret'
local resolved = assert(registry.resolve({ name = 'a', version = '1' }, {
kind = 'template',
url_template = 'https://h/${name}-${version}.tar.gz',
auth = { env = 'VIMPACK_TEST_TOKEN' },
}))
assert_eq('Bearer sekret', resolved.headers.Authorization)
vim.env.VIMPACK_TEST_TOKEN = nil
end)
end)
describe('custom resolvers', function()
it('are usable once registered', function()
registry.register('spec_custom', function(spec)
return { url = 'https://custom/' .. spec.name, headers = {}, filename = 'a.tar.gz' }
end)
local resolved = assert(registry.resolve({ name = 'z', version = '1' }, { kind = 'spec_custom' }))
assert_eq('https://custom/z', resolved.url)
end)
end)
describe('archive_kind', function()
it('classifies suffixes', function()
assert_eq('tar', util.archive_kind('a.tar.gz'))
assert_eq('tar', util.archive_kind('a.tgz'))
assert_eq('zip', util.archive_kind('A.ZIP'))
assert_nil(util.archive_kind('a.rpm'))
end)
end)