Add arti-pack plugin manager scaffold #1

Merged
benvin merged 6 commits from benvin/scaffold into main 2026-09-09 23:19:48 +10:00
24 changed files with 2255 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.lua]
indent_style = space
indent_size = 2
+1
View File
@@ -0,0 +1 @@
doc/tags
+3
View File
@@ -0,0 +1,3 @@
std = "lua51"
globals = { "vim" }
max_line_length = 120
+10
View File
@@ -0,0 +1,10 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
+4
View File
@@ -0,0 +1,4 @@
column_width = 120
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: lint
image: alpine:3.22
commands:
- apk add --no-cache luacheck make
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
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.22
commands:
- apk add --no-cache neovim curl unzip tar zip make
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+36
View File
@@ -0,0 +1,36 @@
.PHONY: test lint fmt pre-commit patch minor major _tag
test:
nvim --headless -l tests/run.lua
lint:
luacheck lua tests
fmt:
stylua lua tests
pre-commit:
@uvx pre-commit run --all-files
# Bump helpers — read the latest semver tag and create the next one.
# If no tag exists yet, start from v0.0.0.
_LATEST = $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE = $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+114 -1
View File
@@ -1,3 +1,116 @@
# arti-pack
Neovim plugin manager that installs plugins from tagged archives proxied through artifactapi into nvim's native package path
A Neovim plugin manager that installs plugins from an artifact repository
instead of git. It downloads tagged upstream source archives over HTTP and
unpacks them into Neovim's native package path — see `:h packages`.
It works with any HTTP artifact repository that serves an upstream tag archive
at a stable URL — a JFrog Artifactory remote GitHub repository, or any caching
proxy with the same path shape. Nothing is cloned, no git binary is needed, and
the editor never has to reach GitHub: every plugin arrives as a cached,
immutable, versioned artifact from the same store your builds already pull
from.
## Requirements
- Neovim 0.10+
- `curl`, and `unzip` (or `tar` when using `ext = 'tar.gz'`)
## Install
arti-pack manages itself least awkwardly as a plain clone into the package path:
```sh
git clone https://git.unkin.net/unkin/arti-pack \
"${XDG_DATA_HOME:-$HOME/.local/share}/nvim/site/pack/arti-pack/start/arti-pack"
```
## Usage
```lua
require('arti-pack').setup({
registry = 'https://artifacts.example.com/artifactory/github-remote',
plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
{ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' },
{ 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' },
},
})
```
`registry` is required and has no default: it is the URL prefix your own
artifact repository serves upstream tag archives under, so there is no sensible
value to ship. It must be an `http://` or `https://` URL; surrounding
whitespace is trimmed and anything else is rejected. Until you set it,
`:ArtiPackSync` refuses to run and says so:
```
[arti-pack] registry is not configured; set it in require('arti-pack').setup{ registry = 'https://artifacts.example.com/artifactory/github-remote' }
```
`:ArtiPackClean` and `:ArtiPackList` never resolve a URL, so they work without
it, as do plugins that carry an explicit `url`.
Every plugin needs an explicit `tag` — that is the point. Plugins install to
`stdpath('data')/site/pack/arti-pack/start/<name>`, or `.../opt/<name>` when
`opt = true`.
### Spec fields
| Field | Default | Meaning |
| ------ | ---------- | ---------------------------------------------------- |
| `[1]` | *required* | `owner/repo` |
| `tag` | *required* | Upstream tag, used verbatim in the archive path |
| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` |
| `as` | repo name | Directory name to install as |
| `ext` | `'zip'` | `zip` or `tar.gz` |
| `url` | — | Full archive URL, bypassing the registry (no whitespace) |
### Options
| Option | Default | Meaning |
| ------------- | ------- | ------------------------------------------- |
| `registry` | *required* | Archive URL prefix placed in front of `owner/repo` |
| `auto_sync` | `true` | Sync missing and changed plugins on startup |
| `concurrency` | `4` | Plugins downloaded in parallel |
| `timeout` | `120` | Per-download timeout in seconds |
### Commands
| Command | Effect |
| ---------------- | ---------------------------------------------------------- |
| `:ArtiPackSync` | Install missing plugins and update ones whose tag changed |
| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state |
| `:ArtiPackClean` | Remove installed plugins no longer in the spec |
| `:ArtiPackList` | Show every configured plugin and its state |
## How it resolves an archive
```
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
```
which for the example above is
```
https://artifacts.example.com/artifactory/github-remote/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip
```
That is GitHub's own tag-archive path shape, so a remote repository proxying
`github.com` resolves it unchanged.
Archives are cached under `stdpath('cache')/arti-pack`, checked for a real zip
or gzip magic header before being trusted, and unpacked into a staging
directory. Tag archives wrap their contents in a single `<repo>-<version>`
directory, which is stripped on the way in. Installed tags are recorded in
`stdpath('data')/arti-pack/lock.json`, so a sync with nothing to do costs
nothing. Helptags are regenerated per plugin after install.
A failed plugin is reported and skipped; the rest of the run continues.
## Tests
```sh
make test
```
+96
View File
@@ -0,0 +1,96 @@
*arti-pack.txt* Install Neovim plugins from an artifact store
==============================================================================
CONTENTS *arti-pack-contents*
1. Introduction ......................... |arti-pack-introduction|
2. Setup ................................ |arti-pack-setup|
3. Plugin specs ......................... |arti-pack-specs|
4. Commands ............................. |arti-pack-commands|
5. Layout ............................... |arti-pack-layout|
==============================================================================
1. INTRODUCTION *arti-pack-introduction*
arti-pack installs plugins from tagged upstream source archives served over
HTTP by an artifact repository, and unpacks them into Neovim's native package
path. See |packages| for how Neovim loads what arti-pack writes.
Any artifact repository that serves an upstream tag archive at a stable URL
works — a JFrog Artifactory remote GitHub repository, or any caching proxy
with the same path shape. Nothing is cloned and the editor never reaches
GitHub itself.
Requires curl, and unzip or tar.
==============================================================================
2. SETUP *arti-pack-setup*
*arti-pack.setup()*
>lua
require('arti-pack').setup({
registry = 'https://artifacts.example.com/artifactory/github-remote',
plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
},
})
<
Options:
registry Required, no default. Archive URL prefix placed in front of
"owner/repo", pointing at your own artifact repository. It
must be an http:// or https:// URL; surrounding whitespace
is trimmed and anything else is rejected at setup, so a
stray space never reaches the download. Until it is set,
|:ArtiPackSync| refuses to run and says which option to
set. |:ArtiPackClean| and |:ArtiPackList| never resolve a
URL and work without it, as do plugin specs carrying an
explicit "url".
plugins List of plugin specs. See |arti-pack-specs|.
auto_sync Sync missing and changed plugins on startup. Default true.
concurrency Plugins downloaded in parallel. Default 4.
timeout Per-download timeout in seconds. Default 120.
==============================================================================
3. PLUGIN SPECS *arti-pack-specs*
Each entry is a table whose first element is "owner/repo".
tag Required. Upstream tag, used verbatim in the archive path.
opt Install under opt/ for |:packadd| instead of start/. Default false.
as Directory name to install as. Defaults to the repo name.
ext "zip" or "tar.gz". Default "zip".
url Full archive URL, bypassing the registry. Trimmed; it may not
contain whitespace.
The archive URL is built as: >
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
<
==============================================================================
4. COMMANDS *arti-pack-commands*
*:ArtiPackSync*
:ArtiPackSync Install missing plugins and update ones whose tag changed.
:ArtiPackSync! Reinstall every configured plugin, ignoring recorded state.
*:ArtiPackClean*
:ArtiPackClean Remove installed plugins that are no longer in the spec.
*:ArtiPackList*
:ArtiPackList Show every configured plugin and its state.
==============================================================================
5. LAYOUT *arti-pack-layout*
stdpath('data')/site/pack/arti-pack/start/<name> loaded at startup
stdpath('data')/site/pack/arti-pack/opt/<name> loaded by |:packadd|
stdpath('data')/arti-pack/lock.json recorded tags
stdpath('cache')/arti-pack downloaded archives
Downloads are checked for a zip or gzip magic header before being trusted, and
the single "<repo>-<version>" directory that tag archives wrap their contents
in is stripped on the way in. A plugin that fails is reported and skipped; the
rest of the run continues.
vim:tw=78:ts=8:noet:ft=help:norl:
+88
View File
@@ -0,0 +1,88 @@
local uv = vim.uv or vim.loop
local M = {}
local ZIP_MAGIC = 'PK\3\4'
local GZIP_MAGIC = '\31\139'
function M.join(...)
return table.concat({ ... }, '/')
end
function M.mkdirp(path)
vim.fn.mkdir(path, 'p')
return path
end
function M.rm_rf(path)
if uv.fs_stat(path) then
vim.fn.delete(path, 'rf')
end
end
function M.exists(path)
return uv.fs_stat(path) ~= nil
end
function M.entries(dir)
local handle = uv.fs_scandir(dir)
if not handle then
return {}
end
local out = {}
while true do
local name, kind = uv.fs_scandir_next(handle)
if not name then
break
end
table.insert(out, { name = name, kind = kind })
end
return out
end
-- Tag archives wrap everything in a single "<repo>-<tag>" directory. Detect it
-- rather than reconstructing the name, since the leading "v" is dropped.
function M.archive_root(dir)
local entries = M.entries(dir)
if #entries == 1 and entries[1].kind == 'directory' then
return M.join(dir, entries[1].name)
end
return dir
end
function M.read_head(path, bytes)
local fd = io.open(path, 'rb')
if not fd then
return nil
end
local head = fd:read(bytes)
fd:close()
return head
end
function M.verify_archive(path, ext)
local stat = uv.fs_stat(path)
if not stat or stat.size == 0 then
return false, 'downloaded archive is empty'
end
local magic = ext == 'zip' and ZIP_MAGIC or GZIP_MAGIC
local head = M.read_head(path, #magic)
if head ~= magic then
return false, string.format('downloaded archive is not a %s', ext)
end
return true
end
function M.extract_cmd(archive, dest, ext)
if ext == 'zip' then
return { 'unzip', '-q', '-o', archive, '-d', dest }
end
return { 'tar', '-xzf', archive, '-C', dest }
end
function M.rename(from, to)
return uv.fs_rename(from, to)
end
return M
+253
View File
@@ -0,0 +1,253 @@
local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec')
local M = {}
-- registry has no default: it is site-specific, so any shipped value would point
-- every install at a host it cannot reach. See M.setup.
local DEFAULTS = {
plugins = {},
auto_sync = true,
concurrency = 4,
timeout = 120,
}
M.config = vim.deepcopy(DEFAULTS)
local running = false
local function paths()
local data = vim.fn.stdpath('data')
return {
pack_dir = fs.join(data, 'site', 'pack', 'arti-pack'),
lock_path = fs.join(data, 'arti-pack', 'lock.json'),
cache_dir = fs.join(vim.fn.stdpath('cache'), 'arti-pack'),
}
end
local function notify(msg, level)
vim.notify('[arti-pack] ' .. msg, level or vim.log.levels.INFO)
end
local function context()
local p = paths()
return {
registry = M.config.registry,
pack_dir = p.pack_dir,
cache_dir = p.cache_dir,
concurrency = M.config.concurrency,
timeout = M.config.timeout,
lock = Lock.new(p.lock_path):load(),
}
end
-- A spec carrying its own url never touches the registry, so only demand one
-- when something in this run would have to be resolved against it.
local function registry_error(specs, registry)
for _, spec in ipairs(specs) do
if not spec.url then
local _, err = spec_mod.check_registry(registry)
return err
end
end
end
local function installed_dir(ctx, name, opt)
return fs.join(ctx.pack_dir, opt and 'opt' or 'start', name)
end
local function finalise(ctx, results)
local counts = { installed = 0, updated = 0, failed = 0 }
for _, res in ipairs(results) do
counts[res.status] = (counts[res.status] or 0) + 1
if res.status == 'failed' then
notify(string.format('%s: %s', res.name, res.err), vim.log.levels.ERROR)
end
end
ctx.lock:save()
for _, res in ipairs(results) do
if res.status ~= 'failed' then
local entry = ctx.lock:get(res.name)
local dir = installed_dir(ctx, res.name, entry and entry.opt)
local doc = fs.join(dir, 'doc')
if fs.exists(doc) then
pcall(vim.cmd, 'helptags ' .. vim.fn.fnameescape(doc))
end
if not (entry and entry.opt) then
pcall(vim.cmd.packadd, res.name)
end
end
end
local parts = {}
for _, key in ipairs({ 'installed', 'updated', 'failed' }) do
if counts[key] > 0 then
table.insert(parts, string.format('%d %s', counts[key], key))
end
end
if #parts == 0 then
notify('everything up to date')
else
notify(table.concat(parts, ', '), counts.failed > 0 and vim.log.levels.WARN or vim.log.levels.INFO)
end
end
function M.sync(opts)
opts = opts or {}
if running then
return notify('a sync is already running', vim.log.levels.WARN)
end
local specs, errors = spec_mod.parse_all(M.config.plugins)
for _, err in ipairs(errors) do
notify(err, vim.log.levels.ERROR)
end
local ctx = context()
local pending = {}
for _, spec in ipairs(specs) do
local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt))
if opts.force or not current then
table.insert(pending, spec)
end
end
if #pending == 0 then
return notify('everything up to date')
end
local reg_err = registry_error(pending, ctx.registry)
if reg_err then
return notify(reg_err, vim.log.levels.ERROR)
end
running = true
notify(string.format('syncing %d plugin(s)', #pending))
installer.run(ctx, pending, function(results)
running = false
finalise(ctx, results)
end)
end
function M.clean()
local specs, errors = spec_mod.parse_all(M.config.plugins)
for _, err in ipairs(errors) do
notify(err, vim.log.levels.ERROR)
end
-- An unparseable entry still names a plugin the user wants, so the set of
-- wanted names is incomplete; deleting from it would remove a live install.
if #errors > 0 then
return notify('clean aborted, no plugin spec may be in error', vim.log.levels.WARN)
end
local wanted = {}
for _, spec in ipairs(specs) do
wanted[spec.name] = true
end
local ctx = context()
local removed = {}
for _, sub in ipairs({ 'start', 'opt' }) do
local root = fs.join(ctx.pack_dir, sub)
for _, entry in ipairs(fs.entries(root)) do
if not wanted[entry.name] then
fs.rm_rf(fs.join(root, entry.name))
table.insert(removed, entry.name)
end
end
end
for _, name in ipairs(ctx.lock:names()) do
if not wanted[name] then
ctx.lock:remove(name)
end
end
ctx.lock:save()
if #removed == 0 then
notify('nothing to clean')
else
table.sort(removed)
notify('removed ' .. table.concat(removed, ', '))
end
end
function M.list()
local specs, errors = spec_mod.parse_all(M.config.plugins)
local ctx = context()
local lines = {}
for _, spec in ipairs(specs) do
local entry = ctx.lock:get(spec.name)
local state
if not entry then
state = 'missing'
elseif not ctx.lock:is_current(spec) then
state = 'outdated (' .. tostring(entry.tag) .. ')'
elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then
state = 'gone from disk'
else
state = 'installed'
end
table.insert(
lines,
string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state)
)
end
for _, name in ipairs(ctx.lock:names()) do
local known = false
for _, spec in ipairs(specs) do
known = known or spec.name == name
end
if not known then
table.insert(lines, string.format('%-32s %-14s %-5s %s', name, '-', '-', 'orphaned'))
end
end
for _, err in ipairs(errors) do
table.insert(lines, 'error: ' .. err)
end
if #lines == 0 then
lines = { 'no plugins configured' }
end
notify(table.concat(lines, '\n'))
return lines
end
function M.setup(opts)
opts = opts or {}
M.config = vim.tbl_deep_extend('force', vim.deepcopy(DEFAULTS), opts)
-- tbl_deep_extend merges lists element-wise, which would splice defaults into
-- a user's plugin list; the caller's list is always authoritative.
M.config.plugins = opts.plugins or {}
if opts.registry ~= nil then
local _, err = spec_mod.check_registry(opts.registry)
if err then
notify(err, vim.log.levels.ERROR)
end
end
local p = paths()
fs.mkdirp(fs.join(p.pack_dir, 'start'))
fs.mkdirp(fs.join(p.pack_dir, 'opt'))
if M.config.auto_sync then
vim.schedule(function()
M.sync()
end)
end
return M
end
return M
+165
View File
@@ -0,0 +1,165 @@
local fs = require('arti-pack.fs')
local spec_mod = require('arti-pack.spec')
local M = {}
local function result(spec, status, err)
return { name = spec.name, source = spec.source, tag = spec.tag, status = status, err = err }
end
local function target_dir(ctx, spec)
return fs.join(ctx.pack_dir, spec.opt and 'opt' or 'start', spec.name)
end
local function stale_dir(ctx, spec)
return fs.join(ctx.pack_dir, spec.opt and 'start' or 'opt', spec.name)
end
local function shell_error(cmd, out)
local stderr = (out.stderr or ''):gsub('%s+$', '')
if stderr == '' then
stderr = (out.stdout or ''):gsub('%s+$', '')
end
if stderr == '' then
stderr = 'exit code ' .. tostring(out.code)
end
return string.format('%s failed: %s', cmd, stderr)
end
local function place(ctx, spec, stage, done)
local root = fs.archive_root(stage)
local target = target_dir(ctx, spec)
fs.mkdirp(vim.fn.fnamemodify(target, ':h'))
fs.rm_rf(target)
fs.rm_rf(stale_dir(ctx, spec))
local ok, err = fs.rename(root, target)
fs.rm_rf(stage)
if not ok then
return done(result(spec, 'failed', 'could not move extracted files into place: ' .. tostring(err)))
end
local previous = ctx.lock:get(spec.name)
ctx.lock:set(spec.name, {
tag = spec.tag,
opt = spec.opt,
source = spec.source,
ext = spec.ext,
url = spec.resolved_url,
installed_at = os.time(),
})
done(result(spec, previous and 'updated' or 'installed'))
end
local function extract(ctx, spec, archive, done)
local stage = fs.join(ctx.pack_dir, '.stage', spec.name)
fs.rm_rf(stage)
fs.mkdirp(stage)
vim.system(fs.extract_cmd(archive, stage, spec.ext), { text = true }, function(out)
vim.schedule(function()
if out.code ~= 0 then
fs.rm_rf(stage)
-- A corrupt cache entry would fail forever; drop it so the next sync refetches.
fs.rm_rf(archive)
return done(result(spec, 'failed', shell_error('extract', out)))
end
place(ctx, spec, stage, done)
end)
end)
end
local function download(ctx, spec, archive, done)
local url, err = spec_mod.url(spec, ctx.registry)
if not url then
return done(result(spec, 'failed', err))
end
spec.resolved_url = url
if fs.exists(archive) and fs.verify_archive(archive, spec.ext) then
return extract(ctx, spec, archive, done)
end
fs.mkdirp(ctx.cache_dir)
local tmp = archive .. '.part'
fs.rm_rf(tmp)
local cmd = {
'curl',
'-fsSL',
'--retry',
'2',
'--max-time',
tostring(ctx.timeout),
'-o',
tmp,
url,
}
vim.system(cmd, { text = true }, function(out)
vim.schedule(function()
if out.code ~= 0 then
fs.rm_rf(tmp)
return done(result(spec, 'failed', string.format('%s: %s', url, shell_error('download', out))))
end
local ok, verr = fs.verify_archive(tmp, spec.ext)
if not ok then
fs.rm_rf(tmp)
return done(result(spec, 'failed', string.format('%s: %s', url, verr)))
end
fs.rm_rf(archive)
local renamed, rerr = fs.rename(tmp, archive)
if not renamed then
fs.rm_rf(tmp)
return done(result(spec, 'failed', 'could not store archive: ' .. tostring(rerr)))
end
extract(ctx, spec, archive, done)
end)
end)
end
function M.install(ctx, spec, done)
local archive = fs.join(ctx.cache_dir, string.format('%s-%s.%s', spec.name, spec.tag, spec.ext))
local ok, err = pcall(download, ctx, spec, archive, done)
if not ok then
done(result(spec, 'failed', tostring(err)))
end
end
-- Failures are per plugin: one bad tag must not abandon the rest of the run.
function M.run(ctx, specs, on_done)
local results, started, finished = {}, 0, 0
local limit = math.max(1, ctx.concurrency or 4)
if #specs == 0 then
return on_done(results)
end
local pump
local function complete(res)
table.insert(results, res)
finished = finished + 1
if finished == #specs then
table.sort(results, function(a, b)
return a.name < b.name
end)
return on_done(results)
end
pump()
end
pump = function()
while started < #specs and (started - finished) < limit do
started = started + 1
M.install(ctx, specs[started], complete)
end
end
pump()
end
return M
+87
View File
@@ -0,0 +1,87 @@
local M = {}
M.__index = M
local function dirname(path)
return path:match('^(.*)[/\\][^/\\]*$') or '.'
end
function M.new(path)
return setmetatable({ path = path, entries = {} }, M)
end
function M:load()
local fd = io.open(self.path, 'r')
if not fd then
self.entries = {}
return self
end
local raw = fd:read('*a')
fd:close()
local ok, decoded = pcall(vim.json.decode, raw)
if not ok or type(decoded) ~= 'table' or type(decoded.plugins) ~= 'table' then
self.entries = {}
return self
end
self.entries = {}
for name, entry in pairs(decoded.plugins) do
if type(name) == 'string' and type(entry) == 'table' then
self.entries[name] = entry
end
end
return self
end
-- Write then rename, so an interrupted save cannot truncate the live lockfile.
function M:save()
vim.fn.mkdir(dirname(self.path), 'p')
local payload = { version = 1, plugins = self.entries }
local tmp = string.format('%s.%d.tmp', self.path, vim.uv and vim.uv.os_getpid() or vim.loop.os_getpid())
local fd, err = io.open(tmp, 'w')
if not fd then
return false, err
end
fd:write(vim.json.encode(payload))
fd:close()
local ok, rename_err = os.rename(tmp, self.path)
if not ok then
os.remove(tmp)
return false, rename_err
end
return true
end
function M:get(name)
return self.entries[name]
end
function M:set(name, entry)
self.entries[name] = entry
end
function M:remove(name)
self.entries[name] = nil
end
function M:names()
local names = {}
for name in pairs(self.entries) do
table.insert(names, name)
end
table.sort(names)
return names
end
-- A plugin is current when the recorded tag and load style both still match the
-- spec; a flipped opt flag has to move the directory, so it counts as changed.
function M:is_current(spec)
local entry = self.entries[spec.name]
if not entry then
return false
end
return entry.tag == spec.tag and (entry.opt == true) == (spec.opt == true)
end
return M
+141
View File
@@ -0,0 +1,141 @@
local M = {}
local SOURCE_PATTERN = '^([%w%.%_%-]+)/([%w%.%_%-]+)$'
local EXTENSIONS = { ['zip'] = true, ['tar.gz'] = true }
local SETUP_HINT =
"set it in require('arti-pack').setup{ registry = 'https://artifacts.example.com/artifactory/github-remote' }"
local function trim(s)
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
local function bad_name(name)
return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil
end
function M.parse(entry)
if type(entry) == 'string' then
entry = { entry }
end
if type(entry) ~= 'table' then
return nil, 'plugin spec must be a string or a table'
end
local source = entry[1] or entry.source
if type(source) ~= 'string' or trim(source) == '' then
return nil, 'plugin spec is missing an "owner/repo" source'
end
source = trim(source)
local owner, repo = source:match(SOURCE_PATTERN)
if not owner then
return nil, string.format('invalid source %q, expected "owner/repo"', source)
end
local tag = entry.tag
if type(tag) ~= 'string' or trim(tag) == '' then
return nil, string.format('%s is missing a tag', source)
end
tag = trim(tag)
if tag:find('[/\\%s]') then
return nil, string.format('%s has an invalid tag %q', source, tag)
end
local ext = entry.ext or 'zip'
if not EXTENSIONS[ext] then
return nil, string.format('%s has an unsupported ext %q', source, tostring(ext))
end
local name = entry.as or repo
if type(name) ~= 'string' or bad_name(name) then
return nil, string.format('%s resolves to an unusable directory name %q', source, tostring(name))
end
local url = entry.url
if url ~= nil then
if type(url) ~= 'string' then
return nil, string.format('%s has a non-string url', source)
end
url = trim(url)
if url == '' or url:find('%s') then
return nil, string.format('%s has an invalid url %q', source, entry.url)
end
end
return {
source = source,
owner = owner,
repo = repo,
name = name,
tag = tag,
ext = ext,
opt = entry.opt == true,
url = url,
}
end
-- There is no default registry: the URL is site-specific, and a wrong one only
-- surfaces as an opaque curl failure. Returns the base trimmed and with trailing
-- slashes stripped so joining it to a path never produces "//".
function M.check_registry(registry)
if registry == nil then
return nil, 'registry is not configured; ' .. SETUP_HINT
end
local bad = string.format('registry %s is not an http(s) URL; %s', vim.inspect(registry), SETUP_HINT)
if type(registry) ~= 'string' then
return nil, bad
end
-- Whitespace left inside the URL would be baked into every download.
local base = trim(registry)
if not base:match('^https?://[^%s/]') or base:find('%s') then
return nil, bad
end
return (base:gsub('/+$', ''))
end
-- Tagged source archives live at the same path shape on every GitHub-alike, so
-- the registry is just the proxy prefix in front of "<owner>/<repo>".
function M.url(spec, registry)
if spec.url then
return spec.url
end
local base, err = M.check_registry(registry)
if not base then
return nil, err
end
return table.concat({
base,
spec.owner,
spec.repo,
'archive',
'refs',
'tags',
spec.tag .. '.' .. spec.ext,
}, '/')
end
function M.parse_all(entries)
local specs, errors, seen = {}, {}, {}
if entries == nil then
return specs, errors
end
if type(entries) ~= 'table' then
return specs, { 'plugins must be a list of specs' }
end
for i, entry in ipairs(entries) do
local spec, err = M.parse(entry)
if not spec then
table.insert(errors, string.format('plugins[%d]: %s', i, err))
elseif seen[spec.name] then
table.insert(errors, string.format('plugins[%d]: duplicate plugin name %q', i, spec.name))
else
seen[spec.name] = true
table.insert(specs, spec)
end
end
return specs, errors
end
return M
+20
View File
@@ -0,0 +1,20 @@
if vim.g.loaded_arti_pack then
return
end
vim.g.loaded_arti_pack = true
local function arti()
return require('arti-pack')
end
vim.api.nvim_create_user_command('ArtiPackSync', function(cmd)
arti().sync({ force = cmd.bang })
end, { bang = true, desc = 'Install missing arti-pack plugins and update changed tags' })
vim.api.nvim_create_user_command('ArtiPackClean', function()
arti().clean()
end, { desc = 'Remove installed plugins that are no longer in the spec' })
vim.api.nvim_create_user_command('ArtiPackList', function()
arti().list()
end, { desc = 'List configured arti-pack plugins and their state' })
+99
View File
@@ -0,0 +1,99 @@
local t = require('tests.harness')
local fs = require('arti-pack.fs')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'wb'))
fd:write(contents)
fd:close()
return path
end
t.describe('fs.archive_root', function()
t.it('strips the single wrapper directory a tag archive unpacks to', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/plenary.nvim-0.1.4/lua', 'p')
t.eq(dir .. '/plenary.nvim-0.1.4', fs.archive_root(dir))
end)
t.it('keeps the directory when the archive unpacked flat', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/lua', 'p')
write(dir .. '/README.md', 'hi')
t.eq(dir, fs.archive_root(dir))
end)
t.it('keeps the directory when its only entry is a file', function()
local dir = tmpdir()
write(dir .. '/init.lua', 'return {}')
t.eq(dir, fs.archive_root(dir))
end)
end)
t.describe('fs.verify_archive', function()
t.it('accepts a zip magic header', function()
local path = write(tmpdir() .. '/a.zip', 'PK\3\4rest of the file')
t.truthy(fs.verify_archive(path, 'zip'))
end)
t.it('accepts a gzip magic header', function()
local path = write(tmpdir() .. '/a.tar.gz', '\31\139\8\0padding')
t.truthy(fs.verify_archive(path, 'tar.gz'))
end)
t.it('rejects an empty download', function()
local path = write(tmpdir() .. '/a.zip', '')
local ok, err = fs.verify_archive(path, 'zip')
t.falsy(ok)
t.matches('empty', err)
end)
t.it('rejects an html error page served in place of a zip', function()
local path = write(tmpdir() .. '/a.zip', '<!DOCTYPE html><html>404</html>')
local ok, err = fs.verify_archive(path, 'zip')
t.falsy(ok)
t.matches('not a zip', err)
end)
t.it('rejects a zip when a tar.gz was expected', function()
local path = write(tmpdir() .. '/a.tar.gz', 'PK\3\4rest')
local ok, err = fs.verify_archive(path, 'tar.gz')
t.falsy(ok)
t.matches('not a tar%.gz', err)
end)
t.it('rejects a missing file', function()
local ok, err = fs.verify_archive(tmpdir() .. '/absent.zip', 'zip')
t.falsy(ok)
t.matches('empty', err)
end)
end)
t.describe('fs.extract_cmd', function()
t.it('uses unzip into the destination for zips', function()
t.eq({ 'unzip', '-q', '-o', '/a.zip', '-d', '/dest' }, fs.extract_cmd('/a.zip', '/dest', 'zip'))
end)
t.it('uses tar for gzipped tarballs', function()
t.eq({ 'tar', '-xzf', '/a.tar.gz', '-C', '/dest' }, fs.extract_cmd('/a.tar.gz', '/dest', 'tar.gz'))
end)
end)
t.describe('fs.rm_rf', function()
t.it('removes a populated directory tree', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/a/b', 'p')
write(dir .. '/a/b/c.txt', 'x')
fs.rm_rf(dir .. '/a')
t.falsy(fs.exists(dir .. '/a'))
end)
t.it('is a no-op on a path that is not there', function()
fs.rm_rf(tmpdir() .. '/never-existed')
end)
end)
+59
View File
@@ -0,0 +1,59 @@
local M = { passed = 0, failed = 0, failures = {} }
local current = '?'
function M.describe(name, fn)
current = name
fn()
end
function M.it(name, fn)
local ok, err = pcall(fn)
if ok then
M.passed = M.passed + 1
else
M.failed = M.failed + 1
table.insert(M.failures, string.format('%s > %s\n %s', current, name, tostring(err)))
end
end
local function render(v)
if type(v) == 'table' then
return vim.inspect(v)
end
return string.format('%q', tostring(v))
end
function M.eq(expected, actual, msg)
if not vim.deep_equal(expected, actual) then
error(string.format('%sexpected %s, got %s', msg and (msg .. ': ') or '', render(expected), render(actual)), 2)
end
end
function M.truthy(value, msg)
if not value then
error((msg or 'expected a truthy value') .. ', got ' .. render(value), 2)
end
end
function M.falsy(value, msg)
if value then
error((msg or 'expected a falsy value') .. ', got ' .. render(value), 2)
end
end
function M.matches(pattern, actual, msg)
if type(actual) ~= 'string' or not actual:find(pattern) then
error(string.format('%sexpected %s to match %q', msg and (msg .. ': ') or '', render(actual), pattern), 2)
end
end
function M.report()
for _, failure in ipairs(M.failures) do
io.stderr:write('FAIL ' .. failure .. '\n')
end
io.stdout:write(string.format('\n%d passed, %d failed\n', M.passed, M.failed))
return M.failed == 0
end
return M
+478
View File
@@ -0,0 +1,478 @@
local t = require('tests.harness')
local fs = require('arti-pack.fs')
local Lock = require('arti-pack.lock')
local arti = require('arti-pack')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'wb'))
fd:write(contents)
fd:close()
return path
end
local function have(cmd)
return vim.fn.executable(cmd) == 1
end
local env
-- Point stdpath at a throwaway tree so each test gets its own pack dir, cache
-- and lockfile, and collect notifications instead of printing them.
local function use_env()
local base = tmpdir()
env = {
data = fs.mkdirp(base .. '/data'),
cache = fs.mkdirp(base .. '/cache'),
notices = {},
}
env.pack_dir = env.data .. '/site/pack/arti-pack'
env.lock_path = env.data .. '/arti-pack/lock.json'
return env
end
use_env()
local real_stdpath = vim.fn.stdpath
local real_notify = vim.notify
vim.fn.stdpath = function(what)
if what == 'data' then
return env.data
elseif what == 'cache' then
return env.cache
end
return real_stdpath(what)
end
vim.notify = function(msg, level)
table.insert(env.notices, { msg = msg, level = level })
end
local function notices()
local out = {}
for _, n in ipairs(env.notices) do
table.insert(out, n.msg)
end
return table.concat(out, '\n')
end
local function last_notice()
return env.notices[#env.notices] and env.notices[#env.notices].msg or ''
end
local function lock()
return Lock.new(env.lock_path):load()
end
local function record(name, tag, opt)
local l = lock()
l:set(name, { tag = tag or 'v1', opt = opt == true })
assert(l:save())
end
local function place_dir(name, sub)
local dir = fs.mkdirp(env.pack_dir .. '/' .. sub .. '/' .. name)
write(dir .. '/marker.lua', 'return true')
return dir
end
local function install_fake(name, sub, tag)
place_dir(name, sub)
record(name, tag, sub == 'opt')
end
-- sync finishes asynchronously; its closing notification is the only signal.
local function is_terminal(msg)
return msg:find('everything up to date', 1, true) ~= nil
or msg:find('%d+ installed') ~= nil
or msg:find('%d+ updated') ~= nil
or msg:find('%d+ failed') ~= nil
end
local function drain(from)
assert(
vim.wait(30000, function()
for i = from + 1, #env.notices do
if is_terminal(env.notices[i].msg) then
return true
end
end
return false
end, 20),
'sync never finished: ' .. notices()
)
end
local function sync(opts)
local before = #env.notices
arti.sync(opts)
drain(before)
end
local function build_archive(ext, repo, version)
local work = tmpdir()
local root = string.format('%s-%s', repo, version)
vim.fn.mkdir(work .. '/' .. root .. '/lua', 'p')
write(work .. '/' .. root .. '/lua/marker.lua', 'return "' .. version .. '"')
local archive = string.format('%s/%s.%s', tmpdir(), root, ext)
local out = vim.system({ 'zip', '-q', '-r', archive, root }, { cwd = work, text = true }):wait()
assert(out.code == 0, 'failed to build fixture archive: ' .. tostring(out.stderr))
return archive
end
local function plugin_entry(source, tag, archive, extra)
return vim.tbl_extend('force', { source, tag = tag, url = 'file://' .. archive }, extra or {})
end
t.describe('arti-pack.setup', function()
t.it('creates the start and opt pack directories', function()
use_env()
arti.setup({ auto_sync = false })
t.truthy(fs.exists(env.pack_dir .. '/start'), 'start directory is created')
t.truthy(fs.exists(env.pack_dir .. '/opt'), 'opt directory is created')
end)
t.it('keeps the defaults for options the caller omits', function()
use_env()
arti.setup({ auto_sync = false })
t.eq(4, arti.config.concurrency)
t.eq(120, arti.config.timeout)
end)
t.it('ships no registry default', function()
use_env()
arti.setup({ auto_sync = false })
t.eq(nil, arti.config.registry, 'registry is required, so nothing is assumed')
end)
t.it('rejects a registry that is not an http(s) url', function()
for _, bad in ipairs({ '', 'artifacts.example.com/gh', 'ftp://artifacts.example.com', 42, {} }) do
use_env()
arti.setup({ auto_sync = false, registry = bad })
t.matches('is not an http%(s%) URL', last_notice(), 'rejects ' .. vim.inspect(bad))
t.matches("setup{ registry = 'https://", last_notice(), 'says how to set it')
end
end)
t.it('applies the options the caller does set', function()
use_env()
arti.setup({ auto_sync = false, registry = 'https://example.test/gh', concurrency = 1 })
t.eq('https://example.test/gh', arti.config.registry)
t.eq(1, arti.config.concurrency)
t.eq(120, arti.config.timeout, 'untouched options keep their default')
end)
t.it('does not splice defaults into the caller plugin list', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/one', tag = 'v1' }, 'owner/two' } })
t.eq(2, #arti.config.plugins)
t.eq('owner/one', arti.config.plugins[1][1])
t.eq('owner/two', arti.config.plugins[2])
end)
t.it('forgets the plugin list of an earlier setup call', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/one', tag = 'v1' } } })
arti.setup({ auto_sync = false })
t.eq({}, arti.config.plugins)
end)
t.it('syncs on its own when auto_sync is left on', function()
use_env()
local before = #env.notices
arti.setup({ plugins = {} })
drain(before)
t.matches('everything up to date', notices())
end)
end)
t.describe('arti-pack without a registry', function()
t.it('refuses to sync a plugin it would have to resolve against the registry', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/thing.nvim', tag = 'v1' } } })
arti.sync()
t.matches('registry is not configured', last_notice())
t.matches("setup{ registry = 'https://", last_notice(), 'says how to set it')
t.falsy(fs.exists(env.pack_dir .. '/start/thing.nvim'), 'nothing is downloaded')
end)
t.it('names the registry option when a command runs before setup', function()
use_env()
arti.config.registry = nil
arti.config.plugins = { { 'owner/thing.nvim', tag = 'v1' } }
arti.sync()
t.matches('registry is not configured', last_notice())
end)
t.it('still syncs plugins that carry their own url', function()
if not have('curl') then
return
end
use_env()
local before = #env.notices
arti.setup({
auto_sync = false,
plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', tmpdir() .. '/absent.zip') },
})
arti.sync()
drain(before)
t.falsy(notices():find('registry', 1, true), 'a url-only run never asks for a registry')
end)
t.it('cleans without one', function()
use_env()
arti.setup({ auto_sync = false, plugins = {} })
install_fake('drop.nvim', 'start')
arti.clean()
t.matches('removed drop.nvim', last_notice())
end)
t.it('lists without one', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/here.nvim', tag = 'v1' } } })
install_fake('here.nvim', 'start', 'v1')
t.matches('installed$', arti.list()[1])
end)
end)
t.describe('arti-pack.sync', function()
if not have('zip') or not have('unzip') then
t.it('SKIPPED: zip/unzip unavailable', function() end)
return
end
t.it('installs a configured plugin and records it in the lockfile', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } })
sync()
t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua'), 'plugin is unpacked into pack/start')
t.eq('v1.0.0', lock():get('thing.nvim').tag)
end)
t.it('installs an opt plugin under pack/opt', function()
use_env()
local archive = build_archive('zip', 'lazy.nvim', '1.0.0')
arti.setup({
auto_sync = false,
plugins = { plugin_entry('folke/lazy.nvim', 'v1.0.0', archive, { opt = true }) },
})
sync()
t.truthy(fs.exists(env.pack_dir .. '/opt/lazy.nvim/lua/marker.lua'))
t.falsy(fs.exists(env.pack_dir .. '/start/lazy.nvim'))
end)
t.it('does no work on a second run', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } })
sync()
env.notices = {}
sync()
t.matches('everything up to date', last_notice())
end)
t.it('reinstalls a current plugin when forced', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } })
sync()
fs.rm_rf(env.pack_dir .. '/start/thing.nvim')
env.notices = {}
sync({ force = true })
t.matches('1 updated', last_notice())
t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('reinstalls a plugin whose directory disappeared', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } })
sync()
fs.rm_rf(env.pack_dir .. '/start/thing.nvim')
sync()
t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('reports a bad spec and still installs the good ones', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({
auto_sync = false,
plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive), { 'not a source' } },
})
sync()
t.matches('plugins%[2%]', notices())
t.matches('invalid source', notices())
t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('surfaces an install failure instead of recording the plugin', function()
use_env()
arti.setup({
auto_sync = false,
plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', tmpdir() .. '/absent.zip') },
})
sync()
t.matches('1 failed', last_notice())
t.falsy(lock():get('thing.nvim'), 'a failed install is not locked')
end)
t.it('refuses to start a second sync while one is running', function()
use_env()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } })
local before = #env.notices
arti.sync()
arti.sync()
t.matches('a sync is already running', last_notice())
drain(before)
end)
end)
t.describe('arti-pack.clean', function()
t.it('removes the directories and lock entries that are no longer configured', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/keep.nvim', tag = 'v1' } } })
install_fake('keep.nvim', 'start')
install_fake('drop.nvim', 'start')
install_fake('dropopt.nvim', 'opt')
arti.clean()
t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim'), 'a configured plugin is kept')
t.falsy(fs.exists(env.pack_dir .. '/start/drop.nvim'))
t.falsy(fs.exists(env.pack_dir .. '/opt/dropopt.nvim'))
t.eq({ 'keep.nvim' }, lock():names())
t.matches('removed drop.nvim, dropopt.nvim', last_notice())
end)
t.it('does nothing when every install is still configured', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/keep.nvim', tag = 'v1' } } })
install_fake('keep.nvim', 'start')
arti.clean()
t.matches('nothing to clean', last_notice())
t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim'))
t.eq({ 'keep.nvim' }, lock():names())
end)
t.it('drops a lock entry whose directory is already gone', function()
use_env()
arti.setup({ auto_sync = false, plugins = {} })
record('ghost.nvim', 'v1')
arti.clean()
t.eq({}, lock():names())
end)
-- Regression: an unparseable entry used to vanish from the wanted set, so
-- clean deleted a plugin the user still had configured.
t.it('deletes nothing while any plugin spec is in error', function()
use_env()
arti.setup({
auto_sync = false,
plugins = {
{ 'owner/keep.nvim', tag = 'v1' },
{ 'owner/broken.nvim' },
},
})
install_fake('keep.nvim', 'start')
install_fake('broken.nvim', 'start')
install_fake('orphan.nvim', 'start')
arti.clean()
t.truthy(
fs.exists(env.pack_dir .. '/start/broken.nvim/marker.lua'),
'a still-configured plugin survives its own spec error'
)
t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim'))
t.truthy(fs.exists(env.pack_dir .. '/start/orphan.nvim'), 'nothing is deleted from a partial spec set')
t.eq({ 'broken.nvim', 'keep.nvim', 'orphan.nvim' }, lock():names(), 'lock entries are left alone')
t.matches('missing a tag', notices(), 'the parse error is reported')
t.matches('clean aborted', last_notice())
end)
end)
t.describe('arti-pack.list', function()
local function line_for(lines, needle)
for _, line in ipairs(lines) do
if line:find(needle, 1, true) then
return line
end
end
return nil
end
t.it('says so when nothing is configured', function()
use_env()
arti.setup({ auto_sync = false })
t.eq({ 'no plugins configured' }, arti.list())
end)
t.it('reports the state of every configured and locked plugin', function()
use_env()
arti.setup({
auto_sync = false,
plugins = {
{ 'owner/here.nvim', tag = 'v1' },
{ 'owner/old.nvim', tag = 'v2' },
{ 'owner/gone.nvim', tag = 'v1' },
{ 'owner/new.nvim', tag = 'v1' },
},
})
install_fake('here.nvim', 'start', 'v1')
install_fake('old.nvim', 'start', 'v1')
record('gone.nvim', 'v1')
record('orphan.nvim', 'v1')
local lines = arti.list()
t.matches('installed$', line_for(lines, 'owner/here.nvim'))
t.matches('outdated %(v1%)$', line_for(lines, 'owner/old.nvim'))
t.matches('gone from disk$', line_for(lines, 'owner/gone.nvim'))
t.matches('missing$', line_for(lines, 'owner/new.nvim'))
t.matches('orphaned$', line_for(lines, 'orphan.nvim'))
t.eq(5, #lines)
end)
t.it('lists spec errors alongside the plugins', function()
use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } })
local lines = arti.list()
t.eq(2, #lines)
t.matches('^error: plugins%[2%]', line_for(lines, 'error:'))
end)
end)
vim.fn.stdpath = real_stdpath
vim.notify = real_notify
+204
View File
@@ -0,0 +1,204 @@
local t = require('tests.harness')
local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'wb'))
fd:write(contents)
fd:close()
return path
end
local function have(cmd)
return vim.fn.executable(cmd) == 1
end
-- Build a tag archive that looks like the real thing: everything wrapped in a
-- single "<repo>-<version>" directory.
local function build_archive(ext, repo, version)
local work = tmpdir()
local root = string.format('%s-%s', repo, version)
vim.fn.mkdir(work .. '/' .. root .. '/lua', 'p')
vim.fn.mkdir(work .. '/' .. root .. '/doc', 'p')
write(work .. '/' .. root .. '/lua/marker.lua', 'return "' .. version .. '"')
write(work .. '/' .. root .. '/doc/thing.txt', '*thing.txt*\tA help tag\n')
local archive = string.format('%s/%s.%s', tmpdir(), root, ext)
local cmd
if ext == 'zip' then
cmd = { 'zip', '-q', '-r', archive, root }
else
cmd = { 'tar', '-czf', archive, root }
end
local out = vim.system(cmd, { cwd = work, text = true }):wait()
assert(out.code == 0, 'failed to build fixture archive: ' .. tostring(out.stderr))
return archive
end
local function new_ctx()
local base = tmpdir()
return {
registry = 'file://unused',
pack_dir = fs.mkdirp(base .. '/pack/arti-pack'),
cache_dir = fs.mkdirp(base .. '/cache'),
concurrency = 2,
timeout = 30,
lock = Lock.new(base .. '/lock.json'),
}
end
local function run(ctx, specs)
local results
installer.run(ctx, specs, function(r)
results = r
end)
assert(vim.wait(30000, function()
return results ~= nil
end, 20), 'installer.run never called back')
return results
end
local function spec_for(source, tag, archive, extra)
local entry = vim.tbl_extend('force', { source, tag = tag, url = 'file://' .. archive }, extra or {})
return assert(spec_mod.parse(entry))
end
t.describe('installer.run', function()
if not have('zip') or not have('unzip') then
t.it('SKIPPED: zip/unzip unavailable', function() end)
return
end
t.it('installs a zip into pack/start and strips the wrapper directory', function()
local archive = build_archive('zip', 'plenary.nvim', '0.1.4')
local ctx = new_ctx()
local results = run(ctx, { spec_for('nvim-lua/plenary.nvim', 'v0.1.4', archive) })
t.eq(1, #results)
t.eq('installed', results[1].status, results[1].err)
local dir = ctx.pack_dir .. '/start/plenary.nvim'
t.truthy(fs.exists(dir .. '/lua/marker.lua'), 'plugin files land at the top level')
t.falsy(fs.exists(dir .. '/plenary.nvim-0.1.4'), 'wrapper directory is stripped')
t.eq('v0.1.4', ctx.lock:get('plenary.nvim').tag)
end)
t.it('installs into pack/opt when opt is set', function()
local archive = build_archive('zip', 'lazy.nvim', '1.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('folke/lazy.nvim', 'v1.0.0', archive, { opt = true }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/opt/lazy.nvim/lua/marker.lua'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/lazy.nvim'))
t.eq(true, ctx.lock:get('lazy.nvim').opt)
end)
t.it('installs a tar.gz the same way', function()
local archive = build_archive('tar.gz', 'thing.nvim', '2.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', archive, { ext = 'tar.gz' }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('replaces the old tree on a tag change and reports an update', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
write(ctx.pack_dir .. '/start/thing.nvim/leftover.txt', 'stale')
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', build_archive('zip', 'thing.nvim', '2.0.0')) })
t.eq('updated', results[1].status, results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/leftover.txt'), 'stale files are cleared')
t.eq('v2.0.0', ctx.lock:get('thing.nvim').tag)
end)
t.it('moves a plugin between start and opt without leaving a copy behind', function()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive) })
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive, { opt = true }) })
t.truthy(fs.exists(ctx.pack_dir .. '/opt/thing.nvim'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim'))
end)
t.it('fails one plugin without abandoning the rest of the run', function()
local ctx = new_ctx()
local results = run(ctx, {
spec_for('owner/good', 'v1.0.0', build_archive('zip', 'good', '1.0.0')),
spec_for('owner/missing', 'v1.0.0', tmpdir() .. '/does-not-exist.zip'),
spec_for('owner/alsogood', 'v1.0.0', build_archive('zip', 'alsogood', '1.0.0')),
})
local byname = {}
for _, r in ipairs(results) do
byname[r.name] = r
end
t.eq(3, #results)
t.eq('installed', byname.good.status)
t.eq('installed', byname.alsogood.status)
t.eq('failed', byname.missing.status)
t.matches('download failed', byname.missing.err)
t.falsy(ctx.lock:get('missing'), 'a failed plugin is not recorded')
end)
t.it('rejects a download that is not an archive and leaves nothing behind', function()
local ctx = new_ctx()
local bogus = write(tmpdir() .. '/bogus.zip', '<html>404 not found</html>')
local results = run(ctx, { spec_for('owner/bogus', 'v1.0.0', bogus) })
t.eq('failed', results[1].status)
t.matches('not a zip', results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/bogus'))
end)
t.it('reuses a cached archive instead of downloading again', function()
local ctx = new_ctx()
local archive = build_archive('zip', 'cached.nvim', '1.0.0')
run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.truthy(fs.exists(ctx.cache_dir .. '/cached.nvim-v1.0.0.zip'), 'archive is cached by name and tag')
-- Delete the source the url points at: a second run can only succeed from cache.
vim.fn.delete(archive)
fs.rm_rf(ctx.pack_dir .. '/start/cached.nvim')
local results = run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.eq('updated', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/cached.nvim/lua/marker.lua'))
end)
t.it('installs several plugins concurrently', function()
local ctx = new_ctx()
local specs = {}
for i = 1, 5 do
local name = 'plug' .. i
table.insert(specs, spec_for('owner/' .. name, 'v1.0.0', build_archive('zip', name, '1.0.0')))
end
local results = run(ctx, specs)
t.eq(5, #results)
for _, r in ipairs(results) do
t.eq('installed', r.status, r.err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/' .. r.name .. '/lua/marker.lua'))
end
end)
t.it('returns immediately for an empty spec list', function()
t.eq({}, run(new_ctx(), {}))
end)
t.it('leaves no staging directory behind', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
t.eq({}, fs.entries(ctx.pack_dir .. '/.stage'))
end)
end)
+104
View File
@@ -0,0 +1,104 @@
local t = require('tests.harness')
local Lock = require('arti-pack.lock')
local spec = require('arti-pack.spec')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'w'))
fd:write(contents)
fd:close()
end
t.describe('lock', function()
t.it('round-trips entries through the state file', function()
local path = tmpdir() .. '/nested/lock.json'
local lock = Lock.new(path)
lock:set('plenary.nvim', { tag = 'v0.1.4', opt = false })
t.truthy(lock:save())
local reloaded = Lock.new(path):load()
t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim'))
end)
t.it('starts empty when the state file does not exist', function()
local lock = Lock.new(tmpdir() .. '/absent.json'):load()
t.eq({}, lock:names())
end)
t.it('starts empty when the state file is corrupt', function()
local path = tmpdir() .. '/lock.json'
write(path, 'not json at all')
t.eq({}, Lock.new(path):load():names())
end)
t.it('starts empty when the state file has no plugins table', function()
local path = tmpdir() .. '/lock.json'
write(path, '{"version":1}')
t.eq({}, Lock.new(path):load():names())
end)
t.it('leaves no temporary file beside the state file', function()
local dir = tmpdir()
local lock = Lock.new(dir .. '/lock.json')
lock:set('plenary.nvim', { tag = 'v0.1.4' })
t.truthy(lock:save())
local names = {}
for _, entry in ipairs(vim.fn.readdir(dir)) do
table.insert(names, entry)
end
t.eq({ 'lock.json' }, names)
end)
t.it('lists names in sorted order', function()
local lock = Lock.new(tmpdir() .. '/lock.json')
lock:set('zebra', { tag = 'v1' })
lock:set('alpha', { tag = 'v1' })
t.eq({ 'alpha', 'zebra' }, lock:names())
end)
t.it('drops removed entries from a saved file', function()
local path = tmpdir() .. '/lock.json'
local lock = Lock.new(path)
lock:set('gone', { tag = 'v1' })
lock:set('kept', { tag = 'v1' })
lock:remove('gone')
lock:save()
t.eq({ 'kept' }, Lock.new(path):load():names())
end)
end)
t.describe('lock.is_current', function()
local function make(entry)
local lock = Lock.new(tmpdir() .. '/lock.json')
if entry then
lock:set('repo', entry)
end
return lock
end
t.it('is false for a plugin that was never installed', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.falsy(make(nil):is_current(s))
end)
t.it('is true when the tag and load style both match', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.truthy(make({ tag = 'v1', opt = false }):is_current(s))
end)
t.it('is false when the tag changed', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v2' }))
t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
end)
t.it('is false when the plugin moved between start and opt', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', opt = true }))
t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
end)
end)
+15
View File
@@ -0,0 +1,15 @@
local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h')
package.path = table.concat({
root .. '/lua/?.lua',
root .. '/lua/?/init.lua',
root .. '/?.lua',
package.path,
}, ';')
local t = require('tests.harness')
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do
require('tests.' .. name)
end
os.exit(t.report() and 0 or 1)
+211
View File
@@ -0,0 +1,211 @@
local t = require('tests.harness')
local spec = require('arti-pack.spec')
local REGISTRY = 'https://artifacts.example.com/artifactory/github-remote'
t.describe('spec.parse', function()
t.it('parses a full table spec', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', opt = true }))
t.eq('nvim-lua', s.owner)
t.eq('plenary.nvim', s.repo)
t.eq('plenary.nvim', s.name)
t.eq('v0.1.4', s.tag)
t.eq('zip', s.ext)
t.eq(true, s.opt)
end)
t.it('defaults opt to false', function()
local s = assert(spec.parse({ 'tpope/vim-fugitive', tag = 'v3.7' }))
t.eq(false, s.opt)
end)
t.it('honours an "as" override for the install directory', function()
local s = assert(spec.parse({ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' }))
t.eq('tokyonight', s.name)
end)
t.it('accepts a source key instead of the positional entry', function()
local s = assert(spec.parse({ source = 'owner/repo', tag = 'v1' }))
t.eq('owner/repo', s.source)
end)
t.it('rejects a bare string with no tag', function()
local s, err = spec.parse('owner/repo')
t.falsy(s)
t.matches('missing a tag', err)
end)
t.it('rejects a source without an owner', function()
local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' })
t.falsy(s)
t.matches('expected "owner/repo"', err)
end)
t.it('rejects a source with extra path segments', function()
local s, err = spec.parse({ 'a/b/c', tag = 'v1' })
t.falsy(s)
t.matches('expected "owner/repo"', err)
end)
t.it('rejects a tag containing a path separator', function()
local s, err = spec.parse({ 'owner/repo', tag = '../../etc' })
t.falsy(s)
t.matches('invalid tag', err)
end)
t.it('rejects an unsupported extension', function()
local s, err = spec.parse({ 'owner/repo', tag = 'v1', ext = 'rar' })
t.falsy(s)
t.matches('unsupported ext', err)
end)
t.it('rejects a traversing "as" name', function()
local s, err = spec.parse({ 'owner/repo', tag = 'v1', as = '..' })
t.falsy(s)
t.matches('unusable directory name', err)
end)
t.it('rejects a non-table, non-string spec', function()
local s, err = spec.parse(42)
t.falsy(s)
t.matches('must be a string or a table', err)
end)
t.it('trims surrounding whitespace from the source and tag', function()
local s = assert(spec.parse({ ' owner/repo ', tag = ' v1.0.0 ' }))
t.eq('owner/repo', s.source)
t.eq('v1.0.0', s.tag)
end)
t.it('trims surrounding whitespace from an explicit url', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = ' https://example.test/a.zip\n' }))
t.eq('https://example.test/a.zip', s.url)
end)
t.it('rejects a url with whitespace inside it', function()
for _, bad in ipairs({ 'https://example.test/a b.zip', 'https://example\n.test/a.zip', ' ' }) do
local s, err = spec.parse({ 'owner/repo', tag = 'v1', url = bad })
t.falsy(s, 'rejects ' .. vim.inspect(bad))
t.matches('invalid url', err)
end
end)
end)
t.describe('spec.url', function()
t.it('builds a tag archive url', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' }))
t.eq(REGISTRY .. '/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip', spec.url(s, REGISTRY))
end)
t.it('honours the tar.gz extension', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', ext = 'tar.gz' }))
t.matches('/v0%.1%.4%.tar%.gz$', spec.url(s, REGISTRY))
end)
t.it('does not double up on a trailing registry slash', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.eq(REGISTRY .. '/owner/repo/archive/refs/tags/v1.zip', spec.url(s, REGISTRY .. '///'))
end)
t.it('never builds a url containing whitespace', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
local want = REGISTRY .. '/owner/repo/archive/refs/tags/v1.zip'
t.eq(want, spec.url(s, REGISTRY .. ' '))
t.eq(want, spec.url(s, REGISTRY .. '\n'))
t.eq(want, spec.url(s, ' ' .. REGISTRY))
for _, bad in ipairs({ 'https://arti facts.example.com/gh', 'https://artifacts\n.example.com/gh', ' ' }) do
local url, err = spec.url(s, bad)
t.falsy(url, 'rejects ' .. vim.inspect(bad))
t.matches('is not an http%(s%) URL', err)
end
end)
t.it('passes an explicit url straight through', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = 'https://example.test/a.zip' }))
t.eq('https://example.test/a.zip', spec.url(s, REGISTRY))
end)
t.it('errors when no registry is configured', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
local url, err = spec.url(s, nil)
t.falsy(url)
t.matches('registry is not configured', err)
t.matches("setup{ registry = 'https://", err, 'the error says how to set it')
end)
t.it('errors instead of handing a bad registry to curl', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
for _, bad in ipairs({ '', 'https://', 'artifacts.example.com/gh', 'ftp://artifacts.example.com', 42 }) do
local url, err = spec.url(s, bad)
t.falsy(url, 'rejects ' .. vim.inspect(bad))
t.matches('is not an http%(s%) URL', err)
end
end)
end)
t.describe('spec.check_registry', function()
t.it('returns the registry with trailing slashes stripped', function()
t.eq(REGISTRY, spec.check_registry(REGISTRY))
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '/'))
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '///'))
end)
t.it('accepts a plain http registry', function()
t.eq('http://artifacts.example.com/gh', spec.check_registry('http://artifacts.example.com/gh'))
end)
t.it('trims leading whitespace', function()
t.eq(REGISTRY, spec.check_registry(' ' .. REGISTRY))
end)
t.it('trims trailing whitespace, including a newline', function()
t.eq(REGISTRY, spec.check_registry(REGISTRY .. ' '))
t.eq(REGISTRY, spec.check_registry(REGISTRY .. '\n'))
t.eq(REGISTRY, spec.check_registry(' \t' .. REGISTRY .. '/ \n'))
end)
t.it('rejects whitespace embedded in the registry', function()
for _, bad in ipairs({ 'https://artifacts.example.com/g h', 'https://artifacts\n.example.com/gh' }) do
local base, err = spec.check_registry(bad)
t.falsy(base, 'rejects ' .. vim.inspect(bad))
t.matches('is not an http%(s%) URL', err)
end
end)
t.it('rejects a whitespace-only registry', function()
for _, bad in ipairs({ ' ', ' ', '\n', ' \t\n ' }) do
local base, err = spec.check_registry(bad)
t.falsy(base, 'rejects ' .. vim.inspect(bad))
t.matches('is not an http%(s%) URL', err)
end
end)
end)
t.describe('spec.parse_all', function()
t.it('collects specs and reports per-entry errors', function()
local specs, errors = spec.parse_all({
{ 'a/one', tag = 'v1' },
{ 'bad-source', tag = 'v1' },
{ 'b/two', tag = 'v2', opt = true },
})
t.eq(2, #specs)
t.eq(1, #errors)
t.matches('plugins%[2%]', errors[1])
end)
t.it('rejects two specs that install to the same directory', function()
local specs, errors = spec.parse_all({
{ 'a/shared', tag = 'v1' },
{ 'b/shared', tag = 'v2' },
})
t.eq(1, #specs)
t.matches('duplicate plugin name', errors[1])
end)
t.it('returns empty results for a nil plugin list', function()
local specs, errors = spec.parse_all(nil)
t.eq({}, specs)
t.eq({}, errors)
end)
end)