Add arti-pack plugin manager scaffold #1
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
doc/tags
|
||||
@@ -0,0 +1,3 @@
|
||||
std = "lua51"
|
||||
globals = { "vim" }
|
||||
max_line_length = 120
|
||||
@@ -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]
|
||||
@@ -0,0 +1,4 @@
|
||||
column_width = 120
|
||||
indent_type = "Spaces"
|
||||
indent_width = 2
|
||||
quote_style = "AutoPreferSingle"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user