Add arti-pack plugin manager scaffold
- add lua modules for spec parsing, lock state, fs helpers and the installer - add ArtiPackSync/ArtiPackClean/ArtiPackList commands and help doc - add headless nvim test harness and specs - add make test/lint/fmt/pre-commit and patch/minor/major bump targets - add woodpecker test, lint and pre-commit pipelines
This commit is contained in:
@@ -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,98 @@
|
||||
# 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 store instead of
|
||||
git. It downloads tagged upstream source archives through
|
||||
[artifactapi](https://artifactapi.k8s.syd1.au.unkin.net) and unpacks them into
|
||||
Neovim's native package path — see `:h packages`.
|
||||
|
||||
No git, no submodules, no network access to GitHub from the editor: every
|
||||
plugin arrives as a cached, immutable, tagged artifact from the same proxy that
|
||||
serves RPMs, containers and Terraform providers.
|
||||
|
||||
## 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://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
|
||||
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' },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
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 |
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| ------------- | ------- | ------------------------------------------- |
|
||||
| `registry` | the estate artifactapi github remote | Proxy 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 defaults is
|
||||
|
||||
```
|
||||
https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip
|
||||
```
|
||||
|
||||
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,82 @@
|
||||
*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 through
|
||||
artifactapi, and unpacks them into Neovim's native package path. See |packages|
|
||||
for how Neovim loads what arti-pack writes.
|
||||
|
||||
Requires curl, and unzip or tar.
|
||||
|
||||
==============================================================================
|
||||
2. SETUP *arti-pack-setup*
|
||||
|
||||
*arti-pack.setup()*
|
||||
>lua
|
||||
require('arti-pack').setup({
|
||||
registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
|
||||
plugins = {
|
||||
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
|
||||
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
|
||||
},
|
||||
})
|
||||
<
|
||||
Options:
|
||||
|
||||
registry Proxy prefix placed in front of "owner/repo".
|
||||
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.
|
||||
|
||||
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,220 @@
|
||||
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 = {}
|
||||
|
||||
local DEFAULTS = {
|
||||
registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 = spec_mod.parse_all(M.config.plugins)
|
||||
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 {}
|
||||
|
||||
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,79 @@
|
||||
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
|
||||
|
||||
function M:save()
|
||||
vim.fn.mkdir(dirname(self.path), 'p')
|
||||
local payload = { version = 1, plugins = self.entries }
|
||||
local fd, err = io.open(self.path, 'w')
|
||||
if not fd then
|
||||
return false, err
|
||||
end
|
||||
fd:write(vim.json.encode(payload))
|
||||
fd:close()
|
||||
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,113 @@
|
||||
local M = {}
|
||||
|
||||
local SOURCE_PATTERN = '^([%w%.%_%-]+)/([%w%.%_%-]+)$'
|
||||
local EXTENSIONS = { ['zip'] = true, ['tar.gz'] = true }
|
||||
|
||||
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
|
||||
|
||||
if entry.url ~= nil and type(entry.url) ~= 'string' then
|
||||
return nil, string.format('%s has a non-string url', source)
|
||||
end
|
||||
|
||||
return {
|
||||
source = source,
|
||||
owner = owner,
|
||||
repo = repo,
|
||||
name = name,
|
||||
tag = tag,
|
||||
ext = ext,
|
||||
opt = entry.opt == true,
|
||||
url = entry.url,
|
||||
}
|
||||
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
|
||||
if type(registry) ~= 'string' or registry == '' then
|
||||
return nil, 'registry is not configured'
|
||||
end
|
||||
local base = registry:gsub('/+$', '')
|
||||
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,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,91 @@
|
||||
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('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' }) do
|
||||
require('tests.' .. name)
|
||||
end
|
||||
|
||||
os.exit(t.report() and 0 or 1)
|
||||
@@ -0,0 +1,136 @@
|
||||
local t = require('tests.harness')
|
||||
local spec = require('arti-pack.spec')
|
||||
|
||||
local REGISTRY = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github'
|
||||
|
||||
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)
|
||||
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('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, '')
|
||||
t.falsy(url)
|
||||
t.matches('registry', err)
|
||||
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