Merge pull request 'Add vimpack: archive-based Neovim plugin manager' (#1) from benvin/initial-plugin into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
+14
@@ -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' },
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,5 @@
|
||||
column_width = 120
|
||||
indent_type = "Spaces"
|
||||
indent_width = 2
|
||||
quote_style = "AutoPreferSingle"
|
||||
call_parentheses = "Always"
|
||||
@@ -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: default
|
||||
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: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -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: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -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: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user