From 46517953e53b665e1ea83640543323f9ca4e63f4 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:22:15 +1000 Subject: [PATCH 1/6] 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 --- .editorconfig | 11 ++ .gitignore | 1 + .luacheckrc | 3 + .pre-commit-config.yaml | 10 ++ .stylua.toml | 4 + .woodpecker/lint.yaml | 19 ++++ .woodpecker/pre-commit.yaml | 18 +++ .woodpecker/test.yaml | 19 ++++ Makefile | 36 ++++++ README.md | 97 +++++++++++++++- doc/arti-pack.txt | 82 ++++++++++++++ lua/arti-pack/fs.lua | 88 +++++++++++++++ lua/arti-pack/init.lua | 220 ++++++++++++++++++++++++++++++++++++ lua/arti-pack/installer.lua | 165 +++++++++++++++++++++++++++ lua/arti-pack/lock.lua | 79 +++++++++++++ lua/arti-pack/spec.lua | 113 ++++++++++++++++++ plugin/arti-pack.lua | 20 ++++ tests/fs_spec.lua | 99 ++++++++++++++++ tests/harness.lua | 59 ++++++++++ tests/installer_spec.lua | 204 +++++++++++++++++++++++++++++++++ tests/lock_spec.lua | 91 +++++++++++++++ tests/run.lua | 15 +++ tests/spec_spec.lua | 136 ++++++++++++++++++++++ 23 files changed, 1588 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .luacheckrc create mode 100644 .pre-commit-config.yaml create mode 100644 .stylua.toml create mode 100644 .woodpecker/lint.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Makefile create mode 100644 doc/arti-pack.txt create mode 100644 lua/arti-pack/fs.lua create mode 100644 lua/arti-pack/init.lua create mode 100644 lua/arti-pack/installer.lua create mode 100644 lua/arti-pack/lock.lua create mode 100644 lua/arti-pack/spec.lua create mode 100644 plugin/arti-pack.lua create mode 100644 tests/fs_spec.lua create mode 100644 tests/harness.lua create mode 100644 tests/installer_spec.lua create mode 100644 tests/lock_spec.lua create mode 100644 tests/run.lua create mode 100644 tests/spec_spec.lua diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ef93195 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..926ccaa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +doc/tags diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..39af3f7 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,3 @@ +std = "lua51" +globals = { "vim" } +max_line_length = 120 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7ff64ea --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..6272367 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,4 @@ +column_width = 120 +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 0000000..92c873c --- /dev/null +++ b/.woodpecker/lint.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..a2f7f95 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3d44e3 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 5521c85..2b3e58c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,98 @@ # arti-pack -Neovim plugin manager that installs plugins from tagged archives proxied through artifactapi into nvim's native package path \ No newline at end of file +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/`, or `.../opt/` 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 + +``` +///archive/refs/tags/. +``` + +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 `-` +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 +``` diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt new file mode 100644 index 0000000..e6fa6b0 --- /dev/null +++ b/doc/arti-pack.txt @@ -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: > + ///archive/refs/tags/. +< +============================================================================== +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/ loaded at startup + stdpath('data')/site/pack/arti-pack/opt/ 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 "-" 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: diff --git a/lua/arti-pack/fs.lua b/lua/arti-pack/fs.lua new file mode 100644 index 0000000..f3834cd --- /dev/null +++ b/lua/arti-pack/fs.lua @@ -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 "-" 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 diff --git a/lua/arti-pack/init.lua b/lua/arti-pack/init.lua new file mode 100644 index 0000000..4b09a78 --- /dev/null +++ b/lua/arti-pack/init.lua @@ -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 diff --git a/lua/arti-pack/installer.lua b/lua/arti-pack/installer.lua new file mode 100644 index 0000000..26b4c78 --- /dev/null +++ b/lua/arti-pack/installer.lua @@ -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 diff --git a/lua/arti-pack/lock.lua b/lua/arti-pack/lock.lua new file mode 100644 index 0000000..fdea743 --- /dev/null +++ b/lua/arti-pack/lock.lua @@ -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 diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua new file mode 100644 index 0000000..71b425b --- /dev/null +++ b/lua/arti-pack/spec.lua @@ -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 "/". +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 diff --git a/plugin/arti-pack.lua b/plugin/arti-pack.lua new file mode 100644 index 0000000..d7dd5de --- /dev/null +++ b/plugin/arti-pack.lua @@ -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' }) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua new file mode 100644 index 0000000..98ad6d9 --- /dev/null +++ b/tests/fs_spec.lua @@ -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', '404') + 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) diff --git a/tests/harness.lua b/tests/harness.lua new file mode 100644 index 0000000..ba0e8f0 --- /dev/null +++ b/tests/harness.lua @@ -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 diff --git a/tests/installer_spec.lua b/tests/installer_spec.lua new file mode 100644 index 0000000..330612d --- /dev/null +++ b/tests/installer_spec.lua @@ -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 "-" 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', '404 not found') + 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) diff --git a/tests/lock_spec.lua b/tests/lock_spec.lua new file mode 100644 index 0000000..58d97e8 --- /dev/null +++ b/tests/lock_spec.lua @@ -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) diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..a0bb7b5 --- /dev/null +++ b/tests/run.lua @@ -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) diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua new file mode 100644 index 0000000..eca0019 --- /dev/null +++ b/tests/spec_spec.lua @@ -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) -- 2.47.3 From 3190b30e970c7495ed3ae9cfae3ea2fb30ee0834 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:32:04 +1000 Subject: [PATCH 2/6] Abort clean when a plugin spec fails to parse An unparseable entry dropped out of the wanted set, so clean deleted the directory and lock entry of a plugin the user still has configured. Add tests/init_spec.lua covering setup, sync, clean and list. --- lua/arti-pack/init.lua | 11 +- tests/init_spec.lua | 407 +++++++++++++++++++++++++++++++++++++++++ tests/run.lua | 2 +- 3 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 tests/init_spec.lua diff --git a/lua/arti-pack/init.lua b/lua/arti-pack/init.lua index 4b09a78..c82b6c9 100644 --- a/lua/arti-pack/init.lua +++ b/lua/arti-pack/init.lua @@ -119,7 +119,16 @@ function M.sync(opts) end function M.clean() - local specs = spec_mod.parse_all(M.config.plugins) + 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 diff --git a/tests/init_spec.lua b/tests/init_spec.lua new file mode 100644 index 0000000..068bf02 --- /dev/null +++ b/tests/init_spec.lua @@ -0,0 +1,407 @@ +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('https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', arti.config.registry) + t.eq(4, arti.config.concurrency) + t.eq(120, arti.config.timeout) + 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.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 diff --git a/tests/run.lua b/tests/run.lua index a0bb7b5..ff6d6f5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8,7 +8,7 @@ package.path = table.concat({ local t = require('tests.harness') -for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec' }) do +for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do require('tests.' .. name) end -- 2.47.3 From 11ce1fed9d46dc7729ad5f4b7733dc7ef2c5d996 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:32:04 +1000 Subject: [PATCH 3/6] Save the lockfile through a temp file and a rename An interrupted save could leave a truncated lock.json. --- lua/arti-pack/lock.lua | 10 +++++++++- tests/lock_spec.lua | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lua/arti-pack/lock.lua b/lua/arti-pack/lock.lua index fdea743..ba01e7a 100644 --- a/lua/arti-pack/lock.lua +++ b/lua/arti-pack/lock.lua @@ -33,15 +33,23 @@ function M:load() 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 fd, err = io.open(self.path, 'w') + 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 diff --git a/tests/lock_spec.lua b/tests/lock_spec.lua index 58d97e8..4b6030b 100644 --- a/tests/lock_spec.lua +++ b/tests/lock_spec.lua @@ -42,6 +42,19 @@ t.describe('lock', function() 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' }) -- 2.47.3 From 7def2a28a9aa32e2b8100f556867564f20941741 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Wed, 9 Sep 2026 22:35:00 +1000 Subject: [PATCH 4/6] Describe arti-pack against any artifact repository The docs described one specific deployment by hostname, reading as though arti-pack only works there. It works against any HTTP artifact repository that serves a tag archive at a stable URL. - describe the registry as any HTTP artifact repository, with Artifactory as the worked example - use a placeholder host in every configuration and URL example - state that registry must be set, since the shipped default is site-specific --- README.md | 30 +++++++++++++++++++----------- doc/arti-pack.txt | 17 ++++++++++++----- tests/spec_spec.lua | 2 +- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2b3e58c..330be80 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # arti-pack -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`. +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`. -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. +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 @@ -27,7 +29,7 @@ git clone https://git.unkin.net/unkin/arti-pack \ ```lua require('arti-pack').setup({ - registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', + 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 }, @@ -37,6 +39,9 @@ require('arti-pack').setup({ }) ``` +`registry` is the one option you have to set: the URL prefix your artifact +repository serves upstream tag archives under. + Every plugin needs an explicit `tag` — that is the point. Plugins install to `stdpath('data')/site/pack/arti-pack/start/`, or `.../opt/` when `opt = true`. @@ -56,7 +61,7 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to | Option | Default | Meaning | | ------------- | ------- | ------------------------------------------- | -| `registry` | the estate artifactapi github remote | Proxy prefix placed in front of `owner/repo` | +| `registry` | *site-specific* | 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 | @@ -76,12 +81,15 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to ///archive/refs/tags/. ``` -which for the defaults is +which for the example above is ``` -https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip +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 `-` diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index e6fa6b0..3454665 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -12,9 +12,14 @@ CONTENTS *arti-pack-contents* ============================================================================== 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. +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. @@ -24,7 +29,7 @@ Requires curl, and unzip or tar. *arti-pack.setup()* >lua require('arti-pack').setup({ - registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', + 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 }, @@ -33,7 +38,9 @@ Requires curl, and unzip or tar. < Options: - registry Proxy prefix placed in front of "owner/repo". + registry Archive URL prefix placed in front of "owner/repo". The + shipped default is site-specific; set it to your own + artifact repository. 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. diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua index eca0019..6d9ae04 100644 --- a/tests/spec_spec.lua +++ b/tests/spec_spec.lua @@ -1,7 +1,7 @@ local t = require('tests.harness') local spec = require('arti-pack.spec') -local REGISTRY = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github' +local REGISTRY = 'https://artifacts.example.com/artifactory/github-remote' t.describe('spec.parse', function() t.it('parses a full table spec', function() -- 2.47.3 From c75a866c027faaac33e593f21e3a0d932e8bf5df Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Wed, 9 Sep 2026 22:44:33 +1000 Subject: [PATCH 5/6] Require an explicit registry instead of defaulting to one The shipped default named one private, internet-unreachable host, so an unconfigured install failed against a server it could never reach rather than saying what to set. - drop the registry default and make the option required - reject a non-string, empty, or non-http(s) registry with the option name and how to set it - ask for a registry only when a run has to resolve one, so clean, list, and specs with an explicit url keep working without it --- README.md | 14 ++++++-- doc/arti-pack.txt | 10 ++++-- lua/arti-pack/init.lua | 26 ++++++++++++++- lua/arti-pack/spec.lua | 21 ++++++++++-- tests/init_spec.lua | 73 +++++++++++++++++++++++++++++++++++++++++- tests/spec_spec.lua | 26 +++++++++++++-- 6 files changed, 157 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 330be80..77af1b3 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,16 @@ require('arti-pack').setup({ }) ``` -`registry` is the one option you have to set: the URL prefix your artifact -repository serves upstream tag archives under. +`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. 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/`, or `.../opt/` when @@ -61,7 +69,7 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to | Option | Default | Meaning | | ------------- | ------- | ------------------------------------------- | -| `registry` | *site-specific* | Archive URL prefix placed in front of `owner/repo` | +| `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 | diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index 3454665..e7b1fd6 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -38,9 +38,13 @@ Requires curl, and unzip or tar. < Options: - registry Archive URL prefix placed in front of "owner/repo". The - shipped default is site-specific; set it to your own - artifact repository. + 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; anything else is + rejected at setup. 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. diff --git a/lua/arti-pack/init.lua b/lua/arti-pack/init.lua index c82b6c9..7c75cf6 100644 --- a/lua/arti-pack/init.lua +++ b/lua/arti-pack/init.lua @@ -5,8 +5,9 @@ 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 = { - registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', plugins = {}, auto_sync = true, concurrency = 4, @@ -42,6 +43,17 @@ local function context() } 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 @@ -110,6 +122,11 @@ function M.sync(opts) 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) @@ -213,6 +230,13 @@ function M.setup(opts) -- 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')) diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua index 71b425b..be48769 100644 --- a/lua/arti-pack/spec.lua +++ b/lua/arti-pack/spec.lua @@ -2,6 +2,8 @@ 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+$', '')) @@ -65,16 +67,29 @@ function M.parse(entry) } 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 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 + if type(registry) ~= 'string' or not registry:match('^https?://[^%s/]') then + return nil, string.format('registry %s is not an http(s) URL; %s', vim.inspect(registry), SETUP_HINT) + end + return (registry: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 "/". 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' + local base, err = M.check_registry(registry) + if not base then + return nil, err end - local base = registry:gsub('/+$', '') return table.concat({ base, spec.owner, diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 068bf02..dfb4394 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -142,11 +142,25 @@ t.describe('arti-pack.setup', function() t.it('keeps the defaults for options the caller omits', function() use_env() arti.setup({ auto_sync = false }) - t.eq('https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', arti.config.registry) 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 }) @@ -179,6 +193,63 @@ t.describe('arti-pack.setup', function() 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) diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua index 6d9ae04..1dcb818 100644 --- a/tests/spec_spec.lua +++ b/tests/spec_spec.lua @@ -101,9 +101,31 @@ t.describe('spec.url', function() t.it('errors when no registry is configured', function() local s = assert(spec.parse({ 'owner/repo', tag = 'v1' })) - local url, err = spec.url(s, '') + local url, err = spec.url(s, nil) t.falsy(url) - t.matches('registry', err) + 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) end) -- 2.47.3 From e61e950d309205645a50435a2e310bbdfa5abd70 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Wed, 9 Sep 2026 22:56:18 +1000 Subject: [PATCH 6/6] Trim whitespace out of the registry and spec urls A registry with a stray space or newline passed validation and was concatenated verbatim into the archive URL, so curl saw the malformed address instead of the user seeing the required-registry error. Trim the value before matching and reject any whitespace left inside it; apply the same to a spec's explicit url. --- README.md | 6 +++-- doc/arti-pack.txt | 15 +++++++----- lua/arti-pack/spec.lua | 29 ++++++++++++++++------- tests/spec_spec.lua | 53 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 77af1b3..4b49e9d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ require('arti-pack').setup({ `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. Until you set it, `:ArtiPackSync` refuses to run and says so: +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' } @@ -63,7 +65,7 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to | `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 | +| `url` | — | Full archive URL, bypassing the registry (no whitespace) | ### Options diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index e7b1fd6..841097f 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -40,11 +40,13 @@ 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; anything else is - rejected at setup. 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". + 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. @@ -59,7 +61,8 @@ Each entry is a table whose first element is "owner/repo". 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. + url Full archive URL, bypassing the registry. Trimmed; it may not + contain whitespace. The archive URL is built as: > ///archive/refs/tags/. diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua index be48769..2bf6f6e 100644 --- a/lua/arti-pack/spec.lua +++ b/lua/arti-pack/spec.lua @@ -51,8 +51,15 @@ function M.parse(entry) 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) + 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 { @@ -63,21 +70,27 @@ function M.parse(entry) tag = tag, ext = ext, opt = entry.opt == true, - url = entry.url, + 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 with trailing slashes --- stripped so joining it to a path never produces "//". +-- 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 - if type(registry) ~= 'string' or not registry:match('^https?://[^%s/]') then - return nil, string.format('registry %s is not an http(s) URL; %s', vim.inspect(registry), SETUP_HINT) + 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 - return (registry:gsub('/+$', '')) + -- 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 diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua index 1dcb818..5076f18 100644 --- a/tests/spec_spec.lua +++ b/tests/spec_spec.lua @@ -76,6 +76,19 @@ t.describe('spec.parse', function() 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() @@ -94,6 +107,20 @@ t.describe('spec.url', function() 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)) @@ -127,6 +154,32 @@ t.describe('spec.check_registry', function() 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() -- 2.47.3