From 35b9df9eba4e90a14c6d75c0546c30a26c17c5ab Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 5 Sep 2026 13:28:53 +1000 Subject: [PATCH 1/2] Add vimpack: archive-based Neovim plugin manager ## Why Cloning plugins from git ties the editor to GitHub uptime and moving branches; archives at a pinned version from our own registry are reproducible and reachable offline. ## How - Add a registry resolver interface `(spec, registry) -> {url, headers, filename, sha256?}` with builtin `template` and `artifactapi` kinds, a per-plugin registry override and a `url` escape hatch. - Take auth tokens only from an env var or a `token_cmd`; reject a literal token at setup. - Download with curl, verify an optional sha256, extract to a staging dir and rename into `pack/vimpack/{start,opt}/`, restoring the previous tree if the swap fails. - Track installed name/version/sha256 in `stdpath('data')/vimpack/manifest.json` and skip plugins already at the wanted version. - Add `:VimpackInstall`, `:VimpackUpdate`, `:VimpackClean` and `:VimpackStatus`, running downloads through `vim.system` under a concurrency cap. - Hash with a pure-Lua SHA-256; `vim.fn.sha256()` rejects binary archives with E976. - Add a dependency-free `tests/minimal.lua` harness and 57 specs covering resolution, auth, manifest, planning, checksum mismatch, orphan detection and end-to-end install. - Add `.woodpecker/{test,lint,pre-commit}.yaml`, luacheck/stylua config and pre-commit hooks. --- .luacheckrc | 14 +++ .pre-commit-config.yaml | 28 +++++ .stylua.toml | 5 + .woodpecker/lint.yaml | 35 +++++++ .woodpecker/pre-commit.yaml | 20 ++++ .woodpecker/test.yaml | 19 ++++ README.md | 156 ++++++++++++++++++++++++++- doc/vimpack.txt | 172 ++++++++++++++++++++++++++++++ lua/vimpack/checksum.lua | 51 +++++++++ lua/vimpack/config.lua | 87 +++++++++++++++ lua/vimpack/fs.lua | 180 +++++++++++++++++++++++++++++++ lua/vimpack/init.lua | 204 ++++++++++++++++++++++++++++++++++++ lua/vimpack/installer.lua | 132 +++++++++++++++++++++++ lua/vimpack/manifest.lua | 70 +++++++++++++ lua/vimpack/plan.lua | 94 +++++++++++++++++ lua/vimpack/registry.lua | 166 +++++++++++++++++++++++++++++ lua/vimpack/sha256.lua | 81 ++++++++++++++ lua/vimpack/util.lua | 63 +++++++++++ plugin/vimpack.lua | 24 +++++ tests/fs_spec.lua | 105 +++++++++++++++++++ tests/install_spec.lua | 154 +++++++++++++++++++++++++++ tests/manifest_spec.lua | 143 +++++++++++++++++++++++++ tests/minimal.lua | 92 ++++++++++++++++ tests/plan_spec.lua | 140 +++++++++++++++++++++++++ tests/registry_spec.lua | 136 ++++++++++++++++++++++++ 25 files changed, 2370 insertions(+), 1 deletion(-) 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 doc/vimpack.txt create mode 100644 lua/vimpack/checksum.lua create mode 100644 lua/vimpack/config.lua create mode 100644 lua/vimpack/fs.lua create mode 100644 lua/vimpack/init.lua create mode 100644 lua/vimpack/installer.lua create mode 100644 lua/vimpack/manifest.lua create mode 100644 lua/vimpack/plan.lua create mode 100644 lua/vimpack/registry.lua create mode 100644 lua/vimpack/sha256.lua create mode 100644 lua/vimpack/util.lua create mode 100644 plugin/vimpack.lua create mode 100644 tests/fs_spec.lua create mode 100644 tests/install_spec.lua create mode 100644 tests/manifest_spec.lua create mode 100644 tests/minimal.lua create mode 100644 tests/plan_spec.lua create mode 100644 tests/registry_spec.lua diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..5c9c3c5 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,14 @@ +std = 'luajit' +cache = true +max_line_length = 140 + +-- vim.g / vim.env are written to, so `vim` is not read-only here. +globals = { 'vim' } + +files['tests/'] = { + read_globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' }, +} + +files['tests/minimal.lua'] = { + globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' }, +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d4ff18f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + args: ['--maxkb=500'] + - id: check-toml + - id: check-yaml + - id: detect-private-key + - id: mixed-line-ending + args: ['--fix=lf'] + + - repo: local + hooks: + - id: luacheck + name: luacheck + entry: luacheck + language: system + types: [lua] + + - id: stylua + name: stylua + entry: stylua --check + language: system + types: [lua] diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..3d2eb16 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,5 @@ +column_width = 120 +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" +call_parentheses = "Always" diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 0000000..d0d169d --- /dev/null +++ b/.woodpecker/lint.yaml @@ -0,0 +1,35 @@ +when: + - event: pull_request + +steps: + - name: luacheck + image: alpine:3.21 + commands: + - apk add --no-cache luacheck + - luacheck lua plugin tests + backend_options: + kubernetes: + serviceAccountName: vimpack-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: stylua + image: alpine:3.21 + commands: + - apk add --no-cache stylua + - stylua --check lua plugin tests + backend_options: + kubernetes: + serviceAccountName: vimpack-ci + 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..637c067 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,20 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: alpine:3.21 + commands: + - apk add --no-cache pre-commit git luacheck stylua + - git config --global --add safe.directory "$CI_WORKSPACE" + - pre-commit run --all-files --show-diff-on-failure + backend_options: + kubernetes: + serviceAccountName: vimpack-ci + 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..742c919 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: test + image: alpine:3.21 + commands: + - apk add --no-cache neovim tar curl unzip + - nvim --headless --clean -l tests/minimal.lua + backend_options: + kubernetes: + serviceAccountName: vimpack-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/README.md b/README.md index e252b4e..1dec705 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,157 @@ # vimpack -Neovim plugin manager installing plugins from tagged archives on artifactapi/Artifactory instead of git clones \ No newline at end of file +A `vim.pack`-shaped Neovim plugin manager that installs plugins from **archives on an +artifact registry** instead of git clones. + +## Why + +Git-cloning plugins at startup makes an editor depend on GitHub being up, on `git` being +on `PATH`, and on whatever a moving branch happens to contain today. vimpack pulls a +tarball or zip at an explicit version from whatever HTTP registry you already run — +artifactapi, Artifactory, Nexus, S3 behind a CDN, a plain nginx directory — verifies it +against a checksum, and unpacks it into `pack/vimpack/`. The result is reproducible, +auditable, and works on hosts that cannot reach github.com. + +Pure Lua. No dependencies beyond Neovim 0.10+ (`vim.system`, `vim.uv`, `vim.json`) plus +`curl` and `tar`/`unzip` for the fetch and unpack. + +## Install + +vimpack manages plugins but does not manage itself. Drop it on your `runtimepath` — a +submodule, an archive from your registry, or a clone: + +``` +~/.config/nvim/pack/bootstrap/start/vimpack/ +``` + +## Configure + +```lua +require('vimpack').setup({ + registry = { + kind = 'artifactapi', + base_url = 'https://artifactapi.k8s.syd1.au.unkin.net', + repo = 'nvim-plugins', + auth = { env = 'ARTIFACTAPI_TOKEN' }, + }, + plugins = { + { name = 'telescope.nvim', version = '0.1.8' }, + { name = 'oil.nvim', version = '2.11.0', sha256 = 'd9e2...' }, + { name = 'vim-fugitive', version = '3.7', opt = true }, + -- explicit URL, registry bypassed entirely + { name = 'plenary.nvim', version = '0.1.4', url = 'https://example.com/plenary-0.1.4.tar.gz' }, + }, + concurrency = 4, + timeout = 120000, +}) +``` + +Then `:VimpackInstall`. + +### Plugin spec + +| Field | Meaning | +| --- | --- | +| `name` | required; also the directory name under `pack/vimpack/{start,opt}/` | +| `version` | required unless `url` is given; fed to the registry resolver | +| `sha256` | optional; the download is rejected on mismatch | +| `opt` | `true` places the plugin in `opt/` for `:packadd` | +| `url` | fully explicit archive URL; skips resolution | +| `ext` | archive extension, default `tar.gz` | +| `registry` | per-plugin registry override | +| `dir_name` | override the on-disk directory name | + +## Registries + +A registry turns `(name, version)` into a download URL plus optional auth headers. A +resolver is a plain function: + +```lua +--- (spec, registry) -> resolved | nil, err +--- resolved = { url = string, headers = table, filename = string, sha256 = string? } +require('vimpack').register_resolver('mine', function(spec, registry) + return { + url = registry.base_url .. '/' .. spec.name .. '@' .. spec.version .. '.tar.gz', + headers = {}, + filename = spec.name .. '.tar.gz', + } +end) +``` + +Two resolvers ship built in. + +### `template` — any HTTP server + +`${name}`, `${version}`, `${ext}`, `${slug}`, `${repo}` and `${base_url}` are substituted. +An unknown placeholder is an error, not an empty string. + +Artifactory generic repo: + +```lua +registry = { + kind = 'template', + url_template = 'https://artifactory.example.com/artifactory/nvim-generic/${name}/${version}/${name}-${version}.${ext}', + auth = { header = 'X-JFrog-Art-Api', prefix = '', token_cmd = { 'pass', 'show', 'artifactory/api-key' } }, +} +``` + +### `artifactapi` — the estate's registry + +Resolves to `${base_url}/api/v2/remotes/${repo}/files/${path}`, where `path` defaults to +`${name}/${name}-${version}.${ext}` and is overridable via `path_template`. + +```lua +registry = { + kind = 'artifactapi', + base_url = 'https://artifactapi.k8s.syd1.au.unkin.net', + repo = 'nvim-plugins', + auth = { env = 'ARTIFACTAPI_TOKEN' }, +} +``` + +### Auth + +Tokens are never written into config. `auth` takes either: + +- `env = 'VAR'` — read from the environment, or +- `token_cmd = { 'pass', 'show', 'path' }` (or a shell string) — a command that prints the token. + +`header` defaults to `Authorization` and `prefix` to `Bearer ` for that header. A literal +`token` key is rejected at `setup()`. + +## Commands + +| Command | Effect | +| --- | --- | +| `:VimpackInstall [name...]` | install missing plugins and reconcile drift; up-to-date plugins are skipped | +| `:VimpackUpdate [name...]` | re-fetch regardless of the manifest | +| `:VimpackClean` | remove installed plugins no longer in the spec | +| `:VimpackStatus` | table of wanted vs installed version and state (`ok`, `missing`, `drift`, `orphan`) | + +Downloads and extraction run through `vim.system`, capped at `concurrency` in flight, so +the UI stays responsive. + +## How it installs + +1. Resolve `(name, version)` to a URL and headers. +2. `curl` the archive into `stdpath('cache')/vimpack/archives/`. +3. Verify `sha256` when the spec carries one; a mismatch aborts with expected and got. +4. Extract into `stdpath('cache')/vimpack/stage/`, hoisting a single wrapper directory. +5. Rename into `stdpath('data')/site/pack/vimpack/{start,opt}/`, parking the old + tree aside first and restoring it if the rename fails. +6. Record name, version, sha256 and bucket in `stdpath('data')/vimpack/manifest.json`. + +A failure at any step leaves the previous install untouched. + +## Out of scope (v1) + +Lazy loading. `opt = true` places a plugin under `opt/` for `:packadd`; there is no +event, filetype or command trigger machinery. + +## Tests + +```sh +nvim --headless --clean -l tests/minimal.lua +luacheck lua plugin tests +stylua --check lua plugin tests +``` diff --git a/doc/vimpack.txt b/doc/vimpack.txt new file mode 100644 index 0000000..0da5f3e --- /dev/null +++ b/doc/vimpack.txt @@ -0,0 +1,172 @@ +*vimpack.txt* Install Neovim plugins from archives on an artifact registry + +============================================================================== +CONTENTS *vimpack-contents* + + 1. Introduction ............................. |vimpack-introduction| + 2. Setup .................................... |vimpack-setup| + 3. Plugin spec .............................. |vimpack-spec| + 4. Registries ............................... |vimpack-registries| + 5. Auth ..................................... |vimpack-auth| + 6. Commands ................................. |vimpack-commands| + 7. Layout ................................... |vimpack-layout| + 8. Lua API .................................. |vimpack-api| + +============================================================================== +1. INTRODUCTION *vimpack-introduction* + +vimpack installs Neovim plugins from tarballs or zips served by an artifact +registry, at an explicit version, rather than by cloning git repositories. +Downloads are checksum-verified and swapped into place atomically, so a failed +install never leaves a half-written plugin behind. + +Requirements: Neovim 0.10+, `curl`, and `tar` (plus `unzip` for .zip +archives). + +============================================================================== +2. SETUP *vimpack-setup* + +>lua + require('vimpack').setup({ + registry = { + kind = 'artifactapi', + base_url = 'https://artifactapi.k8s.syd1.au.unkin.net', + repo = 'nvim-plugins', + auth = { env = 'ARTIFACTAPI_TOKEN' }, + }, + plugins = { + { name = 'telescope.nvim', version = '0.1.8' }, + { name = 'vim-fugitive', version = '3.7', opt = true }, + }, + concurrency = 4, + timeout = 120000, + }) +< +`setup()` validates the spec and returns the normalised config, or nil plus an +error message. Nothing is downloaded until |:VimpackInstall|. + +Top-level options ~ + + registry default registry for every plugin (table, optional) + plugins list of plugin specs (list, default {}) + concurrency maximum simultaneous downloads (number, default 4) + timeout per-download timeout in milliseconds (number, default 120000) + +============================================================================== +3. PLUGIN SPEC *vimpack-spec* + + name Plugin name. Required. Also the directory name under + pack/vimpack/{start,opt}/ unless dir_name overrides it. + version Required unless `url` is given. Passed to the resolver. + sha256 Expected digest of the archive. A mismatch aborts the install + and reports both the expected and the actual digest. + opt When true the plugin lands in opt/ for |:packadd|. + url Fully explicit archive URL; registry resolution is skipped. + ext Archive extension, default "tar.gz". + registry Per-plugin registry, overriding the top-level one. + dir_name On-disk directory name. + +============================================================================== +4. REGISTRIES *vimpack-registries* + +A registry maps (name, version) to a download URL. `kind` selects the +resolver; the default kind is "template". + +template ~ + +Substitutes ${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url} into +`url_template`. An unrecognised placeholder is reported as an error. +`filename_template` optionally names the cached archive. +>lua + registry = { + kind = 'template', + url_template = 'https://artifactory.example.com/artifactory/nvim-generic/' + .. '${name}/${version}/${name}-${version}.${ext}', + } +< +artifactapi ~ + +Builds ${base_url}/api/v2/remotes/${repo}/files/${path}. `path_template` +defaults to "${name}/${name}-${version}.${ext}". +>lua + registry = { + kind = 'artifactapi', + base_url = 'https://artifactapi.k8s.syd1.au.unkin.net', + repo = 'nvim-plugins', + } +< +custom ~ + +A resolver is `(spec, registry) -> resolved | nil, err`, where `resolved` is +`{ url, headers, filename, sha256? }`. +>lua + require('vimpack').register_resolver('mine', function(spec, registry) + return { url = registry.base_url .. '/' .. spec.name .. '-' + .. spec.version .. '.tar.gz', headers = {} } + end) +< +============================================================================== +5. AUTH *vimpack-auth* + +Tokens are never stored in the config. `registry.auth` accepts: + + env name of an environment variable holding the token + token_cmd command (list or shell string) that prints the token + header header name, default "Authorization" + prefix value prefix, default "Bearer " for Authorization + +A literal `token` key is rejected at |vimpack-setup| time. +>lua + auth = { header = 'X-JFrog-Art-Api', prefix = '', + token_cmd = { 'pass', 'show', 'artifactory/api-key' } } +< +============================================================================== +6. COMMANDS *vimpack-commands* + + *:VimpackInstall* +:VimpackInstall [{name}...] + Install plugins that are missing or whose installed version, digest or + start/opt placement has drifted from the spec. Up-to-date plugins are + skipped. With names, restrict the work to those plugins. + + *:VimpackUpdate* +:VimpackUpdate [{name}...] + Re-fetch plugins whether or not the manifest says they are current. + + *:VimpackClean* +:VimpackClean + Remove installed plugins and manifest entries that are no longer in the + spec. + + *:VimpackStatus* +:VimpackStatus + Print wanted versus installed version per plugin, with a state of "ok", + "missing", "drift" or "orphan". + +============================================================================== +7. LAYOUT *vimpack-layout* + + stdpath('cache')/vimpack/archives/ downloaded archives + stdpath('cache')/vimpack/stage/ extraction staging area + stdpath('data')/vimpack/manifest.json installed name/version/sha256 + stdpath('data')/site/pack/vimpack/start/ + stdpath('data')/site/pack/vimpack/opt/ + +An install extracts to the staging area, hoists a single wrapper directory if +the archive has one, then renames into place after parking any previous tree +aside. If the rename fails the previous tree is restored. + +Lazy loading is out of scope: `opt = true` only chooses the opt/ bucket. + +============================================================================== +8. LUA API *vimpack-api* + + require('vimpack').setup(opts) configure; returns cfg or nil,err + require('vimpack').install(names?) same as |:VimpackInstall| + require('vimpack').update(names?) same as |:VimpackUpdate| + require('vimpack').clean() same as |:VimpackClean| + require('vimpack').status() same as |:VimpackStatus| + require('vimpack').status_rows() status as a list of tables + require('vimpack').register_resolver(n, fn) add a registry resolver + +vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/vimpack/checksum.lua b/lua/vimpack/checksum.lua new file mode 100644 index 0000000..2696435 --- /dev/null +++ b/lua/vimpack/checksum.lua @@ -0,0 +1,51 @@ +local sha256 = require('vimpack.sha256') + +local M = {} + +local uv = vim.uv or vim.loop + +--- Read a file whole. Archives are plugin-sized, so this stays cheap. +local function read_file(path) + local fd, open_err = uv.fs_open(path, 'r', 438) + if not fd then + return nil, open_err or ('cannot open ' .. path) + end + local stat, stat_err = uv.fs_fstat(fd) + if not stat then + uv.fs_close(fd) + return nil, stat_err or ('cannot stat ' .. path) + end + local data, read_err = uv.fs_read(fd, stat.size, 0) + uv.fs_close(fd) + if not data then + return nil, read_err or ('cannot read ' .. path) + end + return data +end + +function M.sha256_file(path) + local data, err = read_file(path) + if not data then + return nil, err + end + return sha256.hash(data) +end + +--- Compare a file against an expected digest. Case-insensitive, and the error +--- names both digests so a mismatch is diagnosable without re-running. +function M.verify(path, expected) + if expected == nil or expected == '' then + return true + end + local got, err = M.sha256_file(path) + if not got then + return false, string.format('checksum: %s', err) + end + if got:lower() ~= expected:lower() then + return false, + string.format('checksum mismatch for %s\n expected: %s\n got: %s', path, expected:lower(), got:lower()) + end + return true, nil, got +end + +return M diff --git a/lua/vimpack/config.lua b/lua/vimpack/config.lua new file mode 100644 index 0000000..a927a15 --- /dev/null +++ b/lua/vimpack/config.lua @@ -0,0 +1,87 @@ +local M = {} + +M.defaults = { + registry = nil, + plugins = {}, + concurrency = 4, + timeout = 120000, +} + +local function normalize_registry(registry, where) + if registry == nil then + return nil + end + if type(registry) ~= 'table' then + return nil, string.format('%s: registry must be a table', where) + end + if registry.auth and registry.auth.token ~= nil then + return nil, string.format('%s: registry auth must not carry a literal token; use env or token_cmd', where) + end + return registry +end + +--- Validate and canonicalise user options into a config table. +--- Every plugin ends up with name, version, opt, dir_name and its own registry. +function M.normalize(opts) + opts = opts or {} + local cfg = vim.tbl_extend('force', vim.deepcopy(M.defaults), opts) + + local default_registry, err = normalize_registry(cfg.registry, 'setup') + if err then + return nil, err + end + cfg.registry = default_registry + + local seen = {} + local plugins = {} + for i, entry in ipairs(opts.plugins or {}) do + local spec = entry + if type(spec) == 'string' then + spec = { name = spec } + end + if type(spec) ~= 'table' then + return nil, string.format('plugins[%d]: expected table or string, got %s', i, type(entry)) + end + spec = vim.deepcopy(spec) + if type(spec.name) ~= 'string' or spec.name == '' then + return nil, string.format('plugins[%d]: name is required', i) + end + if seen[spec.name] then + return nil, string.format('plugins[%d]: duplicate plugin name %q', i, spec.name) + end + seen[spec.name] = true + if not spec.url and (type(spec.version) ~= 'string' or spec.version == '') then + return nil, string.format('plugin %q: version is required unless url is given', spec.name) + end + local reg, rerr = normalize_registry(spec.registry, string.format('plugin %q', spec.name)) + if rerr then + return nil, rerr + end + spec.registry = reg or cfg.registry + if not spec.registry and not spec.url then + return nil, string.format('plugin %q: no registry configured and no url given', spec.name) + end + spec.opt = spec.opt == true + spec.version = spec.version or 'url' + spec.dir_name = spec.dir_name or require('vimpack.util').slug(spec.name) + plugins[#plugins + 1] = spec + end + cfg.plugins = plugins + + if type(cfg.concurrency) ~= 'number' or cfg.concurrency < 1 then + return nil, 'concurrency must be a positive number' + end + + return cfg +end + +--- Index plugins by name for O(1) lookup. +function M.by_name(cfg) + local out = {} + for _, spec in ipairs(cfg.plugins) do + out[spec.name] = spec + end + return out +end + +return M diff --git a/lua/vimpack/fs.lua b/lua/vimpack/fs.lua new file mode 100644 index 0000000..2db378d --- /dev/null +++ b/lua/vimpack/fs.lua @@ -0,0 +1,180 @@ +local util = require('vimpack.util') + +local M = {} + +local uv = vim.uv or vim.loop + +function M.pack_root() + return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'vimpack') +end + +function M.cache_dir() + return vim.fs.joinpath(vim.fn.stdpath('cache'), 'vimpack') +end + +function M.target_dir(spec, root) + return vim.fs.joinpath(root or M.pack_root(), spec.opt and 'opt' or 'start', spec.dir_name or util.slug(spec.name)) +end + +function M.exists(path) + return uv.fs_stat(path) ~= nil +end + +--- Map plugin dir name -> { opt, dir } for everything under the pack root. +function M.scan(root) + root = root or M.pack_root() + local out = {} + for _, bucket in ipairs({ 'start', 'opt' }) do + local dir = vim.fs.joinpath(root, bucket) + if M.exists(dir) then + for name, kind in vim.fs.dir(dir) do + if kind == 'directory' then + out[name] = { opt = bucket == 'opt', dir = vim.fs.joinpath(dir, name) } + end + end + end + end + return out +end + +function M.rm_rf(path) + if path and path ~= '' and M.exists(path) then + vim.fn.delete(path, 'rf') + end +end + +function M.mkdir_p(path) + vim.fn.mkdir(path, 'p') + return path +end + +--- Archives usually wrap everything in a single top-level directory; hoist it +--- so pack// holds lua/, plugin/, doc/ directly. +function M.strip_top_level(dir) + local entries = {} + for name, kind in vim.fs.dir(dir) do + entries[#entries + 1] = { name = name, kind = kind } + if #entries > 1 then + return dir + end + end + if #entries == 1 and entries[1].kind == 'directory' then + return vim.fs.joinpath(dir, entries[1].name) + end + return dir +end + +--- Replace `target` with `staged` without ever leaving a half-written plugin +--- behind: the old tree is parked aside first and restored on failure. +function M.swap(staged, target) + M.mkdir_p(vim.fs.dirname(target)) + local backup = target .. '.vimpack-old' + M.rm_rf(backup) + + local had_previous = M.exists(target) + if had_previous then + local ok, err = uv.fs_rename(target, backup) + if not ok then + return false, string.format('cannot move existing install aside (%s): %s', target, err) + end + end + + local ok, err = uv.fs_rename(staged, target) + if not ok then + if had_previous then + uv.fs_rename(backup, target) + end + return false, string.format('cannot move new install into place (%s): %s', target, err) + end + + M.rm_rf(backup) + return true +end + +--- Extract an archive into `dest` asynchronously. Calls cb(ok, err). +function M.extract(archive, dest, cb) + M.mkdir_p(dest) + local kind = util.archive_kind(archive) + local cmd + if kind == 'tar' then + cmd = { 'tar', '-xf', archive, '-C', dest } + elseif kind == 'zip' then + cmd = { 'unzip', '-q', '-o', archive, '-d', dest } + else + cb(false, string.format('unsupported archive type for %s (expected .tar.*, .tgz or .zip)', archive)) + return + end + + local ok, handle = pcall(vim.system, cmd, { text = true }, function(res) + vim.schedule(function() + if res.code ~= 0 then + cb( + false, + string.format( + 'extraction failed for %s (%s exited %d): %s', + archive, + cmd[1], + res.code, + vim.trim(res.stderr or '') + ) + ) + else + cb(true) + end + end) + end) + if not ok then + cb(false, string.format('cannot run %s: %s (is it installed?)', cmd[1], handle)) + end +end + +--- Download a URL to `out` asynchronously with curl. Calls cb(ok, err). +function M.download(url, out, headers, timeout, cb) + M.mkdir_p(vim.fs.dirname(out)) + local cmd = { + 'curl', + '--fail-with-body', + '--location', + '--silent', + '--show-error', + '--write-out', + '%{http_code}', + '--max-time', + tostring(math.floor((timeout or 120000) / 1000)), + '--output', + out, + url, + } + for header, value in pairs(headers or {}) do + cmd[#cmd + 1] = '--header' + cmd[#cmd + 1] = header .. ': ' .. value + end + + -- on_exit runs in a libuv callback where Vimscript functions are banned; + -- hop back to the main loop before touching vim.fn. + local ok, handle = pcall(vim.system, cmd, { text = true }, function(res) + vim.schedule(function() + if res.code ~= 0 then + M.rm_rf(out) + local status = vim.trim(res.stdout or '') + cb( + false, + string.format( + 'download failed (HTTP %s, curl exited %d)\n url: %s\n %s', + status ~= '' and status or '?', + res.code, + url, + vim.trim(res.stderr or '') + ) + ) + else + cb(true) + end + end) + end) + if not ok then + cb(false, string.format('cannot run curl: %s (vimpack needs curl on PATH)', handle)) + end +end + +return M diff --git a/lua/vimpack/init.lua b/lua/vimpack/init.lua new file mode 100644 index 0000000..9134b82 --- /dev/null +++ b/lua/vimpack/init.lua @@ -0,0 +1,204 @@ +local config = require('vimpack.config') +local fsmod = require('vimpack.fs') +local installer = require('vimpack.installer') +local manifest = require('vimpack.manifest') +local plan = require('vimpack.plan') +local registry = require('vimpack.registry') +local util = require('vimpack.util') + +local M = {} + +M.config = nil + +--- Expose the resolver registry so users can plug in their own. +M.register_resolver = registry.register + +function M.setup(opts) + local cfg, err = config.normalize(opts) + if not cfg then + util.err(err) + return nil, err + end + M.config = cfg + return cfg +end + +local function require_config() + if not M.config then + util.err('not configured; call require("vimpack").setup{...} first') + return nil + end + return M.config +end + +local function context(cfg, data) + return { + manifest = data, + pack_root = fsmod.pack_root(), + timeout = cfg.timeout, + concurrency = cfg.concurrency, + } +end + +--- Disk scan keyed by plugin name rather than directory name. +local function installed_by_name(cfg) + local by_dir = fsmod.scan() + local out = {} + local claimed = {} + for _, spec in ipairs(cfg.plugins) do + local rec = by_dir[spec.dir_name] + if rec then + out[spec.name] = rec + claimed[spec.dir_name] = true + end + end + for dir_name, rec in pairs(by_dir) do + if not claimed[dir_name] then + out[dir_name] = rec + end + end + return out +end + +local function report(results, data, verb) + local ok_count, failures = 0, {} + for _, res in ipairs(results) do + if res.ok then + ok_count = ok_count + 1 + if not res.item.opt then + pcall(vim.cmd.packadd, res.item.dir_name) + end + else + failures[#failures + 1] = res.err + end + end + local wrote, werr = manifest.write(nil, data) + if not wrote then + failures[#failures + 1] = werr + end + if #failures > 0 then + util.err(string.format('%s: %d ok, %d failed\n%s', verb, ok_count, #failures, table.concat(failures, '\n'))) + else + util.notify(string.format('%s: %d plugin(s)', verb, ok_count)) + end +end + +local function select_specs(items, names) + if not names or #names == 0 then + return items + end + local wanted = {} + for _, name in ipairs(names) do + wanted[name] = true + end + local out = {} + for _, item in ipairs(items) do + if wanted[item.spec.name] then + out[#out + 1] = item + end + end + return out +end + +--- Install anything missing or drifted. Never touches up-to-date plugins. +function M.install(names) + local cfg = require_config() + if not cfg then + return + end + local data = manifest.read() + local built = plan.build(cfg, data, installed_by_name(cfg)) + local items = select_specs(vim.list_extend(vim.deepcopy(built.install), built.update), names) + if #items == 0 then + util.notify('nothing to install') + return + end + installer.run(items, context(cfg, data), function(results) + report(results, data, 'install') + end) +end + +--- Re-fetch plugins even when the manifest says they are current. +function M.update(names) + local cfg = require_config() + if not cfg then + return + end + local data = manifest.read() + local items = {} + for _, spec in ipairs(cfg.plugins) do + items[#items + 1] = { spec = spec, reason = 'forced update' } + end + items = select_specs(items, names) + if #items == 0 then + util.err(string.format('no plugin named %s in the spec', table.concat(names or {}, ', '))) + return + end + installer.run(items, context(cfg, data), function(results) + report(results, data, 'update') + end) +end + +--- Remove installed plugins that are no longer in the spec. +function M.clean() + local cfg = require_config() + if not cfg then + return + end + local data = manifest.read() + local built = plan.build(cfg, data, installed_by_name(cfg)) + if #built.orphans == 0 then + util.notify('nothing to clean') + return + end + local removed = {} + for _, orphan in ipairs(built.orphans) do + if orphan.dir then + fsmod.rm_rf(orphan.dir) + end + manifest.remove(data, orphan.name) + removed[#removed + 1] = orphan.name + end + local wrote, werr = manifest.write(nil, data) + if not wrote then + util.err(werr) + return + end + util.notify('removed ' .. table.concat(removed, ', ')) +end + +function M.status_rows() + local cfg = require_config() + if not cfg then + return {} + end + return plan.status_rows(cfg, manifest.read(), installed_by_name(cfg)) +end + +function M.status() + local rows = M.status_rows() + if #rows == 0 then + util.notify('no plugins configured') + return + end + local lines = { string.format('%-32s %-14s %-14s %s', 'PLUGIN', 'WANTED', 'INSTALLED', 'STATE') } + for _, row in ipairs(rows) do + lines[#lines + 1] = string.format('%-32s %-14s %-14s %s', row.name, row.wanted, row.installed, row.state) + end + vim.api.nvim_echo({ { table.concat(lines, '\n') } }, false, {}) +end + +function M.plugin_names() + local cfg = M.config + if not cfg then + return {} + end + local out = {} + for _, spec in ipairs(cfg.plugins) do + out[#out + 1] = spec.name + end + table.sort(out) + return out +end + +return M diff --git a/lua/vimpack/installer.lua b/lua/vimpack/installer.lua new file mode 100644 index 0000000..e517ad5 --- /dev/null +++ b/lua/vimpack/installer.lua @@ -0,0 +1,132 @@ +local checksum = require('vimpack.checksum') +local fsmod = require('vimpack.fs') +local registry = require('vimpack.registry') +local util = require('vimpack.util') + +local M = {} + +--- Run `worker(item, cb)` over items with at most `concurrency` in flight. +--- cb(ok, err) per item; `done(results)` once every item has reported. +function M.run_tasks(items, concurrency, worker, done) + local total = #items + local results = {} + if total == 0 then + done(results) + return + end + + local next_index = 1 + local finished = 0 + local limit = math.min(math.max(concurrency, 1), total) + + local function start() + local index = next_index + if index > total then + return + end + next_index = index + 1 + worker(items[index], function(ok, err) + results[index] = { item = items[index], ok = ok, err = err } + finished = finished + 1 + if finished == total then + done(results) + else + start() + end + end) + end + + for _ = 1, limit do + start() + end +end + +local function stage_path(spec) + return vim.fs.joinpath( + fsmod.cache_dir(), + 'stage', + string.format('%s.%d', util.slug(spec.name), vim.uv.hrtime() % 1000000) + ) +end + +--- Download, verify, extract and swap a single plugin into place. +--- On any failure the previous install is left untouched. +function M.install_one(spec, ctx, cb) + local resolved, err = registry.resolve(spec, spec.registry) + if not resolved then + cb(false, err) + return + end + + local archive = vim.fs.joinpath( + fsmod.cache_dir(), + 'archives', + util.slug(spec.name) .. '-' .. spec.version .. '-' .. resolved.filename + ) + local target = fsmod.target_dir(spec, ctx.pack_root) + local staged = stage_path(spec) + + local function fail(message) + fsmod.rm_rf(staged) + cb(false, string.format('%s: %s', spec.name, message)) + end + + fsmod.download(resolved.url, archive, resolved.headers, ctx.timeout, function(ok, derr) + if not ok then + fail(derr) + return + end + + local verified, verr, digest = checksum.verify(archive, resolved.sha256) + if not verified then + fsmod.rm_rf(archive) + fail(verr) + return + end + digest = digest or select(1, checksum.sha256_file(archive)) + + fsmod.rm_rf(staged) + fsmod.extract(archive, staged, function(eok, eerr) + if not eok then + fail(eerr) + return + end + + local source = fsmod.strip_top_level(staged) + + -- The other bucket may still hold this plugin after an opt/start flip. + local other = fsmod.target_dir(vim.tbl_extend('force', spec, { opt = not spec.opt }), ctx.pack_root) + + local swapped, serr = fsmod.swap(source, target) + if not swapped then + fail(serr) + return + end + fsmod.rm_rf(staged) + fsmod.rm_rf(other) + + ctx.manifest.plugins[spec.name] = { + version = spec.version, + sha256 = digest, + opt = spec.opt, + url = resolved.url, + dir = target, + installed_at = os.time(), + } + cb(true) + end) + end) +end + +--- Install every item in `items` ({ spec = ..., reason = ... }) concurrently. +function M.run(items, ctx, done) + local specs = {} + for _, item in ipairs(items) do + specs[#specs + 1] = item.spec + end + M.run_tasks(specs, ctx.concurrency or 4, function(spec, cb) + M.install_one(spec, ctx, cb) + end, done) +end + +return M diff --git a/lua/vimpack/manifest.lua b/lua/vimpack/manifest.lua new file mode 100644 index 0000000..f1ef66f --- /dev/null +++ b/lua/vimpack/manifest.lua @@ -0,0 +1,70 @@ +local M = {} + +local uv = vim.uv or vim.loop + +M.VERSION = 1 + +function M.empty() + return { version = M.VERSION, plugins = {} } +end + +function M.path() + return vim.fs.joinpath(vim.fn.stdpath('data'), 'vimpack', 'manifest.json') +end + +function M.read(path) + path = path or M.path() + local fd = uv.fs_open(path, 'r', 438) + if not fd then + return M.empty() + end + local stat = uv.fs_fstat(fd) + local data = stat and uv.fs_read(fd, stat.size, 0) or nil + uv.fs_close(fd) + if not data or data == '' then + return M.empty() + end + local ok, decoded = pcall(vim.json.decode, data) + if not ok or type(decoded) ~= 'table' then + return M.empty(), string.format('manifest %s is not valid JSON, starting fresh', path) + end + decoded.version = decoded.version or M.VERSION + if type(decoded.plugins) ~= 'table' then + decoded.plugins = {} + end + return decoded +end + +function M.write(path, data) + path = path or M.path() + local dir = vim.fs.dirname(path) + vim.fn.mkdir(dir, 'p') + local fd, err = uv.fs_open(path .. '.tmp', 'w', 420) + if not fd then + return false, string.format('cannot write manifest %s: %s', path, err) + end + local encoded = vim.json.encode(data) + uv.fs_write(fd, encoded, 0) + uv.fs_close(fd) + local ok, rerr = uv.fs_rename(path .. '.tmp', path) + if not ok then + return false, string.format('cannot rename manifest into place: %s', rerr) + end + return true +end + +function M.get(data, name) + return data.plugins[name] +end + +function M.set(data, name, entry) + data.plugins[name] = entry + return data +end + +function M.remove(data, name) + data.plugins[name] = nil + return data +end + +return M diff --git a/lua/vimpack/plan.lua b/lua/vimpack/plan.lua new file mode 100644 index 0000000..ce50443 --- /dev/null +++ b/lua/vimpack/plan.lua @@ -0,0 +1,94 @@ +local M = {} + +--- Decide what a single plugin needs. `installed` is the on-disk record for the +--- plugin name: { opt = boolean } or nil when nothing is on disk. +--- Returns 'install' | 'update' | 'skip' plus a human reason. +function M.decide(spec, entry, installed) + if not installed then + return 'install', 'not installed' + end + if not entry then + return 'update', 'no manifest entry for an existing directory' + end + if entry.version ~= spec.version then + return 'update', string.format('version %s -> %s', entry.version, spec.version) + end + if spec.sha256 and entry.sha256 and entry.sha256:lower() ~= spec.sha256:lower() then + return 'update', 'sha256 in spec differs from installed' + end + if installed.opt ~= spec.opt then + return 'update', string.format('moves to %s/', spec.opt and 'opt' or 'start') + end + if spec.url and entry.url and entry.url ~= spec.url then + return 'update', 'url changed' + end + return 'skip', string.format('%s already at %s', spec.name, spec.version) +end + +--- Build the full plan for a config against the manifest and the disk scan. +--- `installed` maps plugin name -> { opt = boolean, dir = string }. +function M.build(cfg, manifest, installed) + installed = installed or {} + local plan = { install = {}, update = {}, skip = {}, orphans = {} } + local wanted = {} + + for _, spec in ipairs(cfg.plugins) do + wanted[spec.name] = true + local action, reason = M.decide(spec, manifest.plugins[spec.name], installed[spec.name]) + plan[action][#plan[action] + 1] = { spec = spec, reason = reason } + end + + for name, rec in pairs(installed) do + if not wanted[name] then + plan.orphans[#plan.orphans + 1] = { name = name, dir = rec.dir, opt = rec.opt } + end + end + for name in pairs(manifest.plugins) do + if not wanted[name] and not installed[name] then + plan.orphans[#plan.orphans + 1] = { name = name, dir = nil, opt = false } + end + end + table.sort(plan.orphans, function(a, b) + return a.name < b.name + end) + + return plan +end + +--- Rows for :VimpackStatus, sorted by name. +function M.status_rows(cfg, manifest, installed) + installed = installed or {} + local plan = M.build(cfg, manifest, installed) + local rows = {} + local function push(list, state) + for _, item in ipairs(list) do + local entry = manifest.plugins[item.spec.name] + rows[#rows + 1] = { + name = item.spec.name, + wanted = item.spec.version, + installed = entry and entry.version or '-', + state = state, + reason = item.reason, + } + end + end + push(plan.skip, 'ok') + push(plan.install, 'missing') + push(plan.update, 'drift') + for _, orphan in ipairs(plan.orphans) do + local entry = manifest.plugins[orphan.name] + rows[#rows + 1] = { + name = orphan.name, + wanted = '-', + installed = entry and entry.version or 'unknown', + state = 'orphan', + reason = 'not in spec', + } + end + table.sort(rows, function(a, b) + return a.name < b.name + end) + return rows +end + +return M diff --git a/lua/vimpack/registry.lua b/lua/vimpack/registry.lua new file mode 100644 index 0000000..2d3fc95 --- /dev/null +++ b/lua/vimpack/registry.lua @@ -0,0 +1,166 @@ +local util = require('vimpack.util') + +local M = {} + +M.DEFAULT_EXT = 'tar.gz' + +--- @class VimpackResolved +--- @field url string fully qualified archive URL +--- @field headers table HTTP headers to send (may be empty) +--- @field filename string name to store the downloaded archive under +--- @field sha256 string|nil digest advertised by the registry, if any + +--- @alias VimpackResolver fun(spec: table, registry: table): VimpackResolved|nil, string|nil + +--- @type table +M.resolvers = {} + +--- Register a resolver. A resolver is a plain function +--- `(spec, registry) -> resolved|nil, err` where `resolved` carries url, +--- headers, filename and an optional sha256. +function M.register(name, fn) + if type(name) ~= 'string' or name == '' then + error('vimpack: resolver name must be a non-empty string') + end + if type(fn) ~= 'function' then + error('vimpack: resolver ' .. name .. ' must be a function') + end + M.resolvers[name] = fn +end + +local function template_vars(spec, registry) + return { + name = spec.name, + version = spec.version, + ext = spec.ext or registry.ext or M.DEFAULT_EXT, + slug = util.slug(spec.name), + repo = registry.repo, + base_url = registry.base_url and registry.base_url:gsub('/+$', '') or nil, + } +end + +--- Generic templated-URL resolver: the user supplies `url_template` using +--- ${name}, ${version}, ${ext}, ${slug}, ${repo} and ${base_url}. +M.register('template', function(spec, registry) + if not registry.url_template then + return nil, 'registry kind "template" requires a url_template' + end + local vars = template_vars(spec, registry) + local url, err = util.interpolate(registry.url_template, vars) + if not url then + return nil, err + end + local filename = registry.filename_template + if filename then + local rendered, ferr = util.interpolate(filename, vars) + if not rendered then + return nil, ferr + end + filename = rendered + end + return { url = url, headers = {}, filename = filename or util.url_basename(url) } +end) + +--- artifactapi generic-repo layout: +--- {base_url}/api/v2/remotes/{repo}/files/{path_template} +--- path_template defaults to ${name}/${name}-${version}.${ext}. +M.register('artifactapi', function(spec, registry) + if not registry.base_url then + return nil, 'registry kind "artifactapi" requires a base_url' + end + if not registry.repo then + return nil, 'registry kind "artifactapi" requires a repo' + end + local vars = template_vars(spec, registry) + local path, err = util.interpolate(registry.path_template or '${name}/${name}-${version}.${ext}', vars) + if not path then + return nil, err + end + local url = string.format('%s/api/v2/remotes/%s/files/%s', vars.base_url, registry.repo, path) + return { url = url, headers = {}, filename = util.url_basename(url) } +end) + +--- Resolve the auth header for a registry. Tokens come from the environment or +--- from a command that prints one; a literal token in config is rejected. +function M.build_headers(registry) + local auth = registry.auth + if not auth then + return {} + end + if auth.token ~= nil then + return nil, 'registry auth must not carry a literal token; use env or token_cmd' + end + local token + if auth.env then + token = vim.env[auth.env] + if token == nil or token == '' then + return nil, string.format('registry auth env %s is unset or empty', auth.env) + end + elseif auth.token_cmd then + local cmd = auth.token_cmd + if type(cmd) == 'string' then + cmd = { 'sh', '-c', cmd } + end + local ok, res = pcall(function() + return vim.system(cmd, { text = true }):wait() + end) + if not ok then + return nil, string.format('registry auth token_cmd failed to start: %s', res) + end + if res.code ~= 0 then + return nil, string.format('registry auth token_cmd exited %d: %s', res.code, vim.trim(res.stderr or '')) + end + token = vim.trim(res.stdout or '') + if token == '' then + return nil, 'registry auth token_cmd printed nothing' + end + else + return nil, 'registry auth needs either env or token_cmd' + end + local header = auth.header or 'Authorization' + local prefix = auth.prefix + if prefix == nil and header:lower() == 'authorization' then + prefix = 'Bearer ' + end + return { [header] = (prefix or '') .. token } +end + +--- Turn a plugin spec plus its registry into a download target. +--- A spec-level `url` short-circuits registry resolution entirely. +function M.resolve(spec, registry) + if spec.url then + return { + url = spec.url, + headers = registry and select(1, M.build_headers(registry)) or {}, + filename = spec.filename or util.url_basename(spec.url), + sha256 = spec.sha256, + } + end + + if not registry then + return nil, string.format('plugin %q has no registry and no url', spec.name) + end + + local kind = registry.kind or 'template' + local resolver = M.resolvers[kind] + if not resolver then + return nil, + string.format('unknown registry kind %q (known: %s)', kind, table.concat(vim.tbl_keys(M.resolvers), ', ')) + end + + local resolved, err = resolver(spec, registry) + if not resolved then + return nil, string.format('plugin %q: %s', spec.name, err or 'resolver returned nothing') + end + + local headers, herr = M.build_headers(registry) + if not headers then + return nil, string.format('plugin %q: %s', spec.name, herr) + end + resolved.headers = vim.tbl_extend('force', headers, resolved.headers or {}) + resolved.sha256 = spec.sha256 or resolved.sha256 + resolved.filename = resolved.filename or util.url_basename(resolved.url) + return resolved +end + +return M diff --git a/lua/vimpack/sha256.lua b/lua/vimpack/sha256.lua new file mode 100644 index 0000000..e5c35ee --- /dev/null +++ b/lua/vimpack/sha256.lua @@ -0,0 +1,81 @@ +-- SHA-256 over LuaJIT bit ops. vim.fn.sha256() rejects binary input (E976: +-- Using a Blob as a String), and archives are binary, so vimpack hashes here. +local bit = require('bit') + +local band, bxor, bnot = bit.band, bit.bxor, bit.bnot +local rshift, rol, tobit, tohex = bit.rshift, bit.rol, bit.tobit, bit.tohex +local byte, rep, sub = string.byte, string.rep, string.sub + +-- stylua: ignore +local K = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +} +for i = 1, 64 do + K[i] = tobit(K[i]) +end + +local function pad(msg) + local len = #msg + local bits = len * 8 + local pad_len = (55 - len) % 64 + local tail = {} + -- 64-bit big-endian bit length; the top word stays 0 below 512 MiB. + local hi = math.floor(bits / 4294967296) + local lo = bits % 4294967296 + for shift = 24, 0, -8 do + tail[#tail + 1] = string.char(band(rshift(tobit(hi), shift), 0xff)) + end + for shift = 24, 0, -8 do + tail[#tail + 1] = string.char(band(rshift(tobit(lo), shift), 0xff)) + end + return msg .. '\128' .. rep('\0', pad_len) .. table.concat(tail) +end + +--- Hex digest of an arbitrary (binary-safe) string. +local function sha256(msg) + local h0, h1, h2, h3 = tobit(0x6a09e667), tobit(0xbb67ae85), tobit(0x3c6ef372), tobit(0xa54ff53a) + local h4, h5, h6, h7 = tobit(0x510e527f), tobit(0x9b05688c), tobit(0x1f83d9ab), tobit(0x5be0cd19) + + local data = pad(msg) + local w = {} + + for offset = 1, #data, 64 do + local chunk = sub(data, offset, offset + 63) + for i = 1, 16 do + local b1, b2, b3, b4 = byte(chunk, i * 4 - 3, i * 4) + w[i] = tobit(b1 * 16777216 + b2 * 65536 + b3 * 256 + b4) + end + for i = 17, 64 do + local x, y = w[i - 15], w[i - 2] + local s0 = bxor(rol(x, 25), rol(x, 14), rshift(x, 3)) + local s1 = bxor(rol(y, 15), rol(y, 13), rshift(y, 10)) + w[i] = tobit(w[i - 16] + s0 + w[i - 7] + s1) + end + + local a, b, c, d, e, f, g, h = h0, h1, h2, h3, h4, h5, h6, h7 + for i = 1, 64 do + local S1 = bxor(rol(e, 26), rol(e, 21), rol(e, 7)) + local ch = bxor(band(e, f), band(bnot(e), g)) + local t1 = tobit(h + S1 + ch + K[i] + w[i]) + local S0 = bxor(rol(a, 30), rol(a, 19), rol(a, 10)) + local maj = bxor(band(a, b), band(a, c), band(b, c)) + local t2 = tobit(S0 + maj) + h, g, f, e = g, f, e, tobit(d + t1) + d, c, b, a = c, b, a, tobit(t1 + t2) + end + + h0, h1, h2, h3 = tobit(h0 + a), tobit(h1 + b), tobit(h2 + c), tobit(h3 + d) + h4, h5, h6, h7 = tobit(h4 + e), tobit(h5 + f), tobit(h6 + g), tobit(h7 + h) + end + + return tohex(h0) .. tohex(h1) .. tohex(h2) .. tohex(h3) .. tohex(h4) .. tohex(h5) .. tohex(h6) .. tohex(h7) +end + +return { hash = sha256 } diff --git a/lua/vimpack/util.lua b/lua/vimpack/util.lua new file mode 100644 index 0000000..9f78ed2 --- /dev/null +++ b/lua/vimpack/util.lua @@ -0,0 +1,63 @@ +local M = {} + +M.PLACEHOLDER = '%${([%w_]+)}' + +--- Substitute ${key} placeholders. Returns nil plus the offending key when a +--- placeholder has no value, so callers can report the exact template fault. +function M.interpolate(template, vars) + if type(template) ~= 'string' then + return nil, 'template must be a string, got ' .. type(template) + end + local missing + local out = template:gsub(M.PLACEHOLDER, function(key) + local value = vars[key] + if value == nil then + missing = missing or key + return '' + end + return tostring(value) + end) + if missing then + return nil, string.format('template %q references unknown placeholder ${%s}', template, missing) + end + return out +end + +local TAR_SUFFIXES = { '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tar' } + +--- Classify an archive by filename suffix: 'tar', 'zip' or nil. +function M.archive_kind(filename) + local lower = filename:lower() + if lower:sub(-4) == '.zip' then + return 'zip' + end + for _, suffix in ipairs(TAR_SUFFIXES) do + if lower:sub(-#suffix) == suffix then + return 'tar' + end + end + return nil +end + +--- Filename component of a URL, query string and fragment stripped. +function M.url_basename(url) + local path = url:gsub('[?#].*$', '') + return path:match('([^/]+)$') or path +end + +--- Path-safe form of a plugin name (telescope.nvim stays readable). +function M.slug(name) + return (name:gsub('[^%w%.%-_]', '_')) +end + +function M.notify(msg, level) + vim.schedule(function() + vim.notify('[vimpack] ' .. msg, level or vim.log.levels.INFO) + end) +end + +function M.err(msg) + M.notify(msg, vim.log.levels.ERROR) +end + +return M diff --git a/plugin/vimpack.lua b/plugin/vimpack.lua new file mode 100644 index 0000000..572972a --- /dev/null +++ b/plugin/vimpack.lua @@ -0,0 +1,24 @@ +if vim.g.loaded_vimpack then + return +end +vim.g.loaded_vimpack = true + +local function complete() + return require('vimpack').plugin_names() +end + +vim.api.nvim_create_user_command('VimpackInstall', function(opts) + require('vimpack').install(opts.fargs) +end, { nargs = '*', complete = complete, desc = 'Install missing or drifted vimpack plugins' }) + +vim.api.nvim_create_user_command('VimpackUpdate', function(opts) + require('vimpack').update(opts.fargs) +end, { nargs = '*', complete = complete, desc = 'Re-fetch vimpack plugins' }) + +vim.api.nvim_create_user_command('VimpackClean', function() + require('vimpack').clean() +end, { nargs = 0, desc = 'Remove installed plugins no longer in the spec' }) + +vim.api.nvim_create_user_command('VimpackStatus', function() + require('vimpack').status() +end, { nargs = 0, desc = 'Show installed vs wanted plugin versions' }) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua new file mode 100644 index 0000000..a44bafd --- /dev/null +++ b/tests/fs_spec.lua @@ -0,0 +1,105 @@ +local fsmod = require('vimpack.fs') + +describe('scan', function() + it('maps directories to their bucket', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/start/a/lua', 'p') + vim.fn.mkdir(root .. '/opt/b', 'p') + vim.fn.writefile({ '' }, root .. '/start/loose.txt') + + local found = fsmod.scan(root) + assert_eq(false, found.a.opt) + assert_eq(true, found.b.opt) + assert_nil(found['loose.txt']) + end) + + it('returns nothing for a missing root', function() + assert_eq({}, fsmod.scan(tmpdir() .. '/absent')) + end) +end) + +describe('target_dir', function() + it('splits start and opt', function() + assert_eq('/r/start/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = false }, '/r')) + assert_eq('/r/opt/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = true }, '/r')) + end) +end) + +describe('strip_top_level', function() + it('hoists a single wrapper directory', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/oil.nvim-2.11.0/lua', 'p') + assert_eq(root .. '/oil.nvim-2.11.0', fsmod.strip_top_level(root)) + end) + + it('leaves a flat archive alone', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/lua', 'p') + vim.fn.mkdir(root .. '/plugin', 'p') + assert_eq(root, fsmod.strip_top_level(root)) + end) +end) + +describe('swap', function() + it('replaces an existing install', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/target', 'p') + vim.fn.writefile({ 'old' }, root .. '/target/marker') + vim.fn.mkdir(root .. '/staged', 'p') + vim.fn.writefile({ 'new' }, root .. '/staged/marker') + + assert_true(fsmod.swap(root .. '/staged', root .. '/target')) + assert_eq({ 'new' }, vim.fn.readfile(root .. '/target/marker')) + assert_true(not fsmod.exists(root .. '/target.vimpack-old')) + end) + + it('keeps the previous install when the staged tree is missing', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/target', 'p') + vim.fn.writefile({ 'old' }, root .. '/target/marker') + + local ok, err = fsmod.swap(root .. '/absent', root .. '/target') + assert_true(not ok) + assert_match('cannot move new install into place', err) + assert_eq({ 'old' }, vim.fn.readfile(root .. '/target/marker')) + end) +end) + +describe('extract', function() + local function wait_extract(archive, dest) + local result + fsmod.extract(archive, dest, function(ok, err) + result = { ok = ok, err = err } + end) + vim.wait(10000, function() + return result ~= nil + end) + return result + end + + it('unpacks a tarball', function() + local root = tmpdir() + vim.fn.mkdir(root .. '/src/plug/lua', 'p') + vim.fn.writefile({ 'return {}' }, root .. '/src/plug/lua/plug.lua') + vim.fn.system({ 'tar', '-czf', root .. '/plug.tar.gz', '-C', root .. '/src', 'plug' }) + + local result = wait_extract(root .. '/plug.tar.gz', root .. '/dest') + assert_true(result.ok, tostring(result.err)) + assert_true(fsmod.exists(root .. '/dest/plug/lua/plug.lua')) + assert_eq(root .. '/dest/plug', fsmod.strip_top_level(root .. '/dest')) + end) + + it('rejects an unknown archive type', function() + local result = wait_extract(tmpdir() .. '/thing.rpm', tmpdir() .. '/dest') + assert_true(not result.ok) + assert_match('unsupported archive type', result.err) + end) + + it('reports a corrupt tarball', function() + local root = tmpdir() + vim.fn.writefile({ 'definitely not a tarball' }, root .. '/bad.tar.gz') + local result = wait_extract(root .. '/bad.tar.gz', root .. '/dest') + assert_true(not result.ok) + assert_match('extraction failed', result.err) + end) +end) diff --git a/tests/install_spec.lua b/tests/install_spec.lua new file mode 100644 index 0000000..61c5cc7 --- /dev/null +++ b/tests/install_spec.lua @@ -0,0 +1,154 @@ +-- End-to-end install over file:// URLs: real curl, real tar, real swap. +local checksum = require('vimpack.checksum') +local config = require('vimpack.config') +local fsmod = require('vimpack.fs') +local installer = require('vimpack.installer') +local manifest = require('vimpack.manifest') +local plan = require('vimpack.plan') + +local function make_tarball(root, version, body) + local src = vim.fs.joinpath(root, 'src', 'demo.nvim-' .. version, 'lua') + vim.fn.mkdir(src, 'p') + vim.fn.writefile({ body }, vim.fs.joinpath(src, 'demo.lua')) + local archive = vim.fs.joinpath(root, 'demo-' .. version .. '.tar.gz') + vim.fn.system({ 'tar', '-czf', archive, '-C', vim.fs.joinpath(root, 'src'), 'demo.nvim-' .. version }) + return archive +end + +local function install(cfg, data, pack_root) + local built = plan.build(cfg, data, fsmod.scan(pack_root)) + local items = vim.list_extend(vim.deepcopy(built.install), built.update) + local results + installer.run(items, { + manifest = data, + pack_root = pack_root, + timeout = 30000, + concurrency = 2, + }, function(r) + results = r + end) + vim.wait(30000, function() + return results ~= nil + end) + return results, items +end + +describe('install end to end', function() + it('downloads, extracts, swaps and records the manifest', function() + local root = tmpdir() + local pack_root = vim.fs.joinpath(root, 'pack') + local archive = make_tarball(root, '1.0.0', 'return 1') + local cfg = assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } }, + })) + local data = manifest.empty() + + local results = install(cfg, data, pack_root) + assert_eq(1, #results) + assert_true(results[1].ok, tostring(results[1].err)) + + local installed = vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua') + assert_true(fsmod.exists(installed), 'expected ' .. installed) + assert_eq({ 'return 1' }, vim.fn.readfile(installed)) + + local entry = manifest.get(data, 'demo.nvim') + assert_eq('1.0.0', entry.version) + assert_eq(assert(checksum.sha256_file(archive)), entry.sha256) + + -- Second pass is a no-op: nothing left to install. + local _, items = install(cfg, data, pack_root) + assert_eq(0, #items) + end) + + it('replaces the old tree on a version bump', function() + local root = tmpdir() + local pack_root = vim.fs.joinpath(root, 'pack') + local data = manifest.empty() + + local v1 = make_tarball(root, '1.0.0', 'return 1') + local cfg1 = assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } }, + })) + assert_true(install(cfg1, data, pack_root)[1].ok) + + local v2 = make_tarball(root, '2.0.0', 'return 2') + local cfg2 = assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2 } }, + })) + local results = install(cfg2, data, pack_root) + assert_true(results[1].ok, tostring(results[1].err)) + assert_eq({ 'return 2' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua'))) + assert_eq('2.0.0', manifest.get(data, 'demo.nvim').version) + end) + + it('aborts on a checksum mismatch and keeps the previous install', function() + local root = tmpdir() + local pack_root = vim.fs.joinpath(root, 'pack') + local data = manifest.empty() + + local v1 = make_tarball(root, '1.0.0', 'return 1') + assert_true(install( + assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. v1 } }, + })), + data, + pack_root + )[1].ok) + + local v2 = make_tarball(root, '2.0.0', 'return 2') + local results = install( + assert(config.normalize({ + plugins = { + { name = 'demo.nvim', version = '2.0.0', url = 'file://' .. v2, sha256 = string.rep('a', 64) }, + }, + })), + data, + pack_root + ) + assert_true(not results[1].ok) + assert_match('checksum mismatch', results[1].err) + assert_eq({ 'return 1' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua'))) + assert_eq('1.0.0', manifest.get(data, 'demo.nvim').version) + end) + + it('names the url when the download fails', function() + local root = tmpdir() + local missing = 'file://' .. vim.fs.joinpath(root, 'nope.tar.gz') + local results = install( + assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = missing } }, + })), + manifest.empty(), + vim.fs.joinpath(root, 'pack') + ) + assert_true(not results[1].ok) + assert_match('download failed', results[1].err) + assert_match(missing, results[1].err) + end) + + it('moves a plugin from start to opt', function() + local root = tmpdir() + local pack_root = vim.fs.joinpath(root, 'pack') + local data = manifest.empty() + local archive = make_tarball(root, '1.0.0', 'return 1') + + assert_true(install( + assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } }, + })), + data, + pack_root + )[1].ok) + + local results = install( + assert(config.normalize({ + plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive, opt = true } }, + })), + data, + pack_root + ) + assert_true(results[1].ok, tostring(results[1].err)) + assert_true(fsmod.exists(vim.fs.joinpath(pack_root, 'opt', 'demo.nvim', 'lua', 'demo.lua'))) + assert_true(not fsmod.exists(vim.fs.joinpath(pack_root, 'start', 'demo.nvim'))) + end) +end) diff --git a/tests/manifest_spec.lua b/tests/manifest_spec.lua new file mode 100644 index 0000000..ec7d459 --- /dev/null +++ b/tests/manifest_spec.lua @@ -0,0 +1,143 @@ +local checksum = require('vimpack.checksum') +local config = require('vimpack.config') +local manifest = require('vimpack.manifest') + +describe('manifest', function() + it('returns an empty manifest for a missing file', function() + local data = manifest.read(tmpdir() .. '/nope.json') + assert_eq(1, data.version) + assert_eq({}, data.plugins) + end) + + it('round-trips through disk', function() + local path = tmpdir() .. '/manifest.json' + local data = manifest.empty() + manifest.set(data, 'oil.nvim', { version = '2.11.0', sha256 = 'abc', opt = false }) + assert_true(manifest.write(path, data)) + + local read_back = manifest.read(path) + assert_eq('2.11.0', manifest.get(read_back, 'oil.nvim').version) + assert_eq('abc', read_back.plugins['oil.nvim'].sha256) + end) + + it('survives a corrupt file', function() + local path = tmpdir() .. '/manifest.json' + vim.fn.writefile({ 'not json {' }, path) + local data, err = manifest.read(path) + assert_eq({}, data.plugins) + assert_match('not valid JSON', err) + end) + + it('removes entries', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1' }) + manifest.remove(data, 'a') + assert_nil(manifest.get(data, 'a')) + end) +end) + +describe('sha256', function() + local sha256 = require('vimpack.sha256') + + it('matches the NIST vectors', function() + assert_eq('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', sha256.hash('')) + assert_eq('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', sha256.hash('abc')) + assert_eq( + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1', + sha256.hash('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq') + ) + end) + + it('handles binary data and multi-block input', function() + assert_eq( + '79eebf0b2aae05b2490234d0b884581935b2e28127f06a74a5b48e758f86bd25', + sha256.hash(string.char(0, 1, 2, 255, 0)) + ) + assert_eq('541b3e9daa09b20bf85fa273e5cbd3e80185aa4ec298e765db87742b70138a53', sha256.hash(string.rep('\0', 1000))) + assert_eq('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0', sha256.hash(string.rep('a', 1000000))) + end) +end) + +describe('checksum', function() + local function write(contents) + local path = tmpdir() .. '/blob.bin' + vim.fn.writefile({ contents }, path, 'b') + return path + end + + it('passes when the digest matches', function() + local path = write('hello vimpack') + local digest = assert(checksum.sha256_file(path)) + assert_true(checksum.verify(path, digest)) + assert_true(checksum.verify(path, digest:upper())) + end) + + it('skips verification when no digest is configured', function() + assert_true(checksum.verify(write('x'), nil)) + end) + + it('names expected and got on mismatch', function() + local path = write('hello vimpack') + local ok, err = checksum.verify(path, string.rep('0', 64)) + assert_true(not ok) + assert_match('checksum mismatch', err) + assert_match(string.rep('0', 64), err) + assert_match(assert(checksum.sha256_file(path)), err) + end) + + it('reports an unreadable file', function() + local ok, err = checksum.verify(tmpdir() .. '/missing', string.rep('0', 64)) + assert_true(not ok) + assert_match('checksum', err) + end) +end) + +describe('config', function() + local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' } + + it('normalizes strings and defaults', function() + local cfg = assert(config.normalize({ + registry = reg, + plugins = { { name = 'oil.nvim', version = '2.11.0' } }, + })) + assert_eq(false, cfg.plugins[1].opt) + assert_eq('oil.nvim', cfg.plugins[1].dir_name) + assert_eq(reg, cfg.plugins[1].registry) + end) + + it('lets a plugin override the registry', function() + local other = { kind = 'template', url_template = 'https://other/${name}' } + local cfg = assert(config.normalize({ + registry = reg, + plugins = { { name = 'a', version = '1', registry = other } }, + })) + assert_eq('https://other/${name}', cfg.plugins[1].registry.url_template) + end) + + it('requires a version unless a url is given', function() + local cfg, err = config.normalize({ registry = reg, plugins = { { name = 'a' } } }) + assert_nil(cfg) + assert_match('version is required', err) + assert_true(config.normalize({ plugins = { { name = 'a', url = 'https://h/a.tar.gz' } } }) ~= nil) + end) + + it('rejects duplicates and literal tokens', function() + local _, dup = config.normalize({ + registry = reg, + plugins = { { name = 'a', version = '1' }, { name = 'a', version = '2' } }, + }) + assert_match('duplicate', dup) + + local _, tok = config.normalize({ + registry = vim.tbl_extend('force', reg, { auth = { token = 'literal' } }), + plugins = {}, + }) + assert_match('literal token', tok) + end) + + it('rejects a plugin with no registry and no url', function() + local cfg, err = config.normalize({ plugins = { { name = 'a', version = '1' } } }) + assert_nil(cfg) + assert_match('no registry', err) + end) +end) diff --git a/tests/minimal.lua b/tests/minimal.lua new file mode 100644 index 0000000..1aef540 --- /dev/null +++ b/tests/minimal.lua @@ -0,0 +1,92 @@ +-- Minimal busted-flavoured harness: no rocks, no vendored plugins. +-- Run with: nvim --headless --clean -l tests/minimal.lua tests/*_spec.lua +local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h') +vim.opt.runtimepath:prepend(root) +package.path = table.concat({ root .. '/lua/?.lua', root .. '/lua/?/init.lua', package.path }, ';') + +local M = { passed = 0, failed = 0, failures = {} } +local stack = {} + +local function describe(name, fn) + stack[#stack + 1] = name + fn() + stack[#stack] = nil +end + +local function it(name, fn) + local label = table.concat(stack, ' ') .. ' ' .. name + local ok, err = pcall(fn) + if ok then + M.passed = M.passed + 1 + io.write('ok ' .. label .. '\n') + else + M.failed = M.failed + 1 + M.failures[#M.failures + 1] = label .. '\n ' .. tostring(err) + io.write('FAIL ' .. label .. '\n ' .. tostring(err) .. '\n') + end +end + +local assert_eq = function(expected, got, msg) + if not vim.deep_equal(expected, got) then + error( + string.format('%sexpected %s, got %s', msg and (msg .. ': ') or '', vim.inspect(expected), vim.inspect(got)), + 2 + ) + end +end + +local assert_true = function(value, msg) + if not value then + error(msg or ('expected truthy, got ' .. vim.inspect(value)), 2) + end +end + +local assert_nil = function(value, msg) + if value ~= nil then + error(msg or ('expected nil, got ' .. vim.inspect(value)), 2) + end +end + +local assert_match = function(pattern, str, msg) + if type(str) ~= 'string' or not str:find(pattern, 1, true) then + error(string.format('%sexpected %s to contain %q', msg and (msg .. ': ') or '', vim.inspect(str), pattern), 2) + end +end + +_G.describe = describe +_G.it = it +_G.assert_eq = assert_eq +_G.assert_true = assert_true +_G.assert_nil = assert_nil +_G.assert_match = assert_match +_G.tmpdir = function() + local dir = vim.fs.joinpath(vim.fn.tempname(), 'vimpack-test') + vim.fn.mkdir(dir, 'p') + return dir +end + +local files = {} +for i = 1, #_G.arg do + files[#files + 1] = _G.arg[i] +end +if #files == 0 then + for name, kind in vim.fs.dir(root .. '/tests') do + if kind == 'file' and name:match('_spec%.lua$') then + files[#files + 1] = root .. '/tests/' .. name + end + end + table.sort(files) +end + +for _, file in ipairs(files) do + dofile(file) +end + +io.write(string.format('\n%d passed, %d failed\n', M.passed, M.failed)) +if M.failed > 0 then + for _, failure in ipairs(M.failures) do + io.write('\n' .. failure .. '\n') + end + vim.cmd('cquit 1') +end +vim.cmd('quit') diff --git a/tests/plan_spec.lua b/tests/plan_spec.lua new file mode 100644 index 0000000..bc67c38 --- /dev/null +++ b/tests/plan_spec.lua @@ -0,0 +1,140 @@ +local config = require('vimpack.config') +local installer = require('vimpack.installer') +local manifest = require('vimpack.manifest') +local plan = require('vimpack.plan') + +local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' } + +local function cfg_with(plugins) + return assert(config.normalize({ registry = reg, plugins = plugins })) +end + +describe('plan', function() + it('installs what is not on disk', function() + local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), {}) + assert_eq(1, #built.install) + assert_eq('not installed', built.install[1].reason) + end) + + it('skips an install that already matches', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1', sha256 = 'ff', opt = false }) + local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), data, { a = { opt = false, dir = '/x/a' } }) + assert_eq(0, #built.install) + assert_eq(0, #built.update) + assert_eq(1, #built.skip) + end) + + it('updates on a version bump', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1', opt = false }) + local built = plan.build(cfg_with({ { name = 'a', version = '2' } }), data, { a = { opt = false, dir = '/x/a' } }) + assert_eq(1, #built.update) + assert_match('1 -> 2', built.update[1].reason) + end) + + it('updates when the spec sha256 differs from the installed one', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1', sha256 = 'aa', opt = false }) + local built = plan.build( + cfg_with({ { name = 'a', version = '1', sha256 = 'bb' } }), + data, + { a = { opt = false, dir = '/x/a' } } + ) + assert_eq(1, #built.update) + assert_match('sha256', built.update[1].reason) + end) + + it('updates when a plugin moves between start and opt', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1', opt = false }) + local built = plan.build( + cfg_with({ { name = 'a', version = '1', opt = true } }), + data, + { a = { opt = false, dir = '/x/start/a' } } + ) + assert_eq(1, #built.update) + assert_match('opt/', built.update[1].reason) + end) + + it('updates a directory with no manifest entry', function() + local built = + plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), { a = { opt = false, dir = '/x/a' } }) + assert_eq(1, #built.update) + assert_match('no manifest entry', built.update[1].reason) + end) + + it('flags orphans on disk and in the manifest', function() + local data = manifest.empty() + manifest.set(data, 'ghost', { version = '9' }) + local built = plan.build( + cfg_with({ { name = 'a', version = '1' } }), + data, + { a = { opt = false, dir = '/x/a' }, old = { opt = true, dir = '/x/opt/old' } } + ) + assert_eq(2, #built.orphans) + assert_eq('ghost', built.orphans[1].name) + assert_eq('old', built.orphans[2].name) + assert_eq('/x/opt/old', built.orphans[2].dir) + end) +end) + +describe('status_rows', function() + it('reports one row per plugin plus orphans', function() + local data = manifest.empty() + manifest.set(data, 'a', { version = '1', opt = false }) + local rows = plan.status_rows( + cfg_with({ { name = 'a', version = '2' }, { name = 'b', version = '1' } }), + data, + { a = { opt = false, dir = '/x/a' }, gone = { opt = false, dir = '/x/gone' } } + ) + assert_eq(3, #rows) + assert_eq({ 'a', 'b', 'gone' }, { rows[1].name, rows[2].name, rows[3].name }) + assert_eq({ 'drift', 'missing', 'orphan' }, { rows[1].state, rows[2].state, rows[3].state }) + assert_eq('1', rows[1].installed) + assert_eq('2', rows[1].wanted) + end) +end) + +describe('run_tasks', function() + it('runs every item and respects the concurrency cap', function() + local items = { 1, 2, 3, 4, 5 } + local inflight, peak, seen = 0, 0, {} + local done_results + installer.run_tasks(items, 2, function(item, cb) + inflight = inflight + 1 + peak = math.max(peak, inflight) + vim.schedule(function() + seen[#seen + 1] = item + inflight = inflight - 1 + cb(true) + end) + end, function(results) + done_results = results + end) + vim.wait(1000, function() + return done_results ~= nil + end) + assert_eq(5, #done_results) + assert_eq(5, #seen) + assert_true(peak <= 2, 'peak concurrency was ' .. peak) + end) + + it('completes immediately on an empty list', function() + local called = false + installer.run_tasks({}, 4, function() end, function() + called = true + end) + assert_true(called) + end) + + it('carries failures through', function() + local results + installer.run_tasks({ 'a' }, 1, function(_, cb) + cb(false, 'boom') + end, function(r) + results = r + end) + assert_eq('boom', results[1].err) + end) +end) diff --git a/tests/registry_spec.lua b/tests/registry_spec.lua new file mode 100644 index 0000000..20823af --- /dev/null +++ b/tests/registry_spec.lua @@ -0,0 +1,136 @@ +local registry = require('vimpack.registry') +local util = require('vimpack.util') + +describe('interpolate', function() + it('substitutes every placeholder', function() + assert_eq( + 'a/1.0/a.tar.gz', + (util.interpolate('${name}/${version}/${name}.${ext}', { name = 'a', version = '1.0', ext = 'tar.gz' })) + ) + end) + + it('reports an unknown placeholder', function() + local out, err = util.interpolate('${name}-${nope}', { name = 'a' }) + assert_nil(out) + assert_match('${nope}', err) + end) +end) + +describe('template resolver', function() + local reg = { + kind = 'template', + url_template = 'https://artifacts.example.com/nvim/${name}/${version}/${name}-${version}.${ext}', + } + + it('renders name and version into the url', function() + local resolved = assert(registry.resolve({ name = 'telescope.nvim', version = '0.1.8' }, reg)) + assert_eq('https://artifacts.example.com/nvim/telescope.nvim/0.1.8/telescope.nvim-0.1.8.tar.gz', resolved.url) + assert_eq('telescope.nvim-0.1.8.tar.gz', resolved.filename) + end) + + it('honours a per-plugin ext override', function() + local resolved = assert(registry.resolve({ name = 'plenary.nvim', version = '1', ext = 'zip' }, reg)) + assert_match('plenary.nvim-1.zip', resolved.url) + end) + + it('fails loudly without a url_template', function() + local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'template' }) + assert_nil(resolved) + assert_match('url_template', err) + end) + + it('names the unknown kind', function() + local resolved, err = registry.resolve({ name = 'x', version = '1' }, { kind = 'nosuch' }) + assert_nil(resolved) + assert_match('nosuch', err) + end) +end) + +describe('artifactapi resolver', function() + local reg = { + kind = 'artifactapi', + base_url = 'https://artifactapi.k8s.syd1.au.unkin.net/', + repo = 'nvim-plugins', + } + + it('builds the generic-repo path', function() + local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg)) + assert_eq( + 'https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/nvim-plugins/files/oil.nvim/oil.nvim-2.11.0.tar.gz', + resolved.url + ) + end) + + it('accepts a path_template override', function() + local reg2 = vim.tbl_extend('force', reg, { path_template = '${version}/${slug}.${ext}' }) + local resolved = assert(registry.resolve({ name = 'oil.nvim', version = '2.11.0' }, reg2)) + assert_match('/files/2.11.0/oil.nvim.tar.gz', resolved.url) + end) +end) + +describe('url escape hatch', function() + it('bypasses the registry entirely', function() + local resolved = assert(registry.resolve({ name = 'x', version = '1', url = 'https://h/x.zip' }, nil)) + assert_eq('https://h/x.zip', resolved.url) + assert_eq('x.zip', resolved.filename) + end) +end) + +describe('auth headers', function() + it('reads a token from the environment', function() + vim.env.VIMPACK_TEST_TOKEN = 'sekret' + local headers = assert(registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } })) + assert_eq({ Authorization = 'Bearer sekret' }, headers) + vim.env.VIMPACK_TEST_TOKEN = nil + end) + + it('reports an unset env var', function() + vim.env.VIMPACK_TEST_TOKEN = nil + local headers, err = registry.build_headers({ auth = { env = 'VIMPACK_TEST_TOKEN' } }) + assert_nil(headers) + assert_match('VIMPACK_TEST_TOKEN', err) + end) + + it('runs token_cmd and uses a custom header', function() + local headers = assert(registry.build_headers({ + auth = { token_cmd = { 'printf', '%s', 'abc123' }, header = 'X-JFrog-Art-Api', prefix = '' }, + })) + assert_eq({ ['X-JFrog-Art-Api'] = 'abc123' }, headers) + end) + + it('rejects a literal token', function() + local headers, err = registry.build_headers({ auth = { token = 'oops' } }) + assert_nil(headers) + assert_match('literal token', err) + end) + + it('attaches auth to a resolved url', function() + vim.env.VIMPACK_TEST_TOKEN = 'sekret' + local resolved = assert(registry.resolve({ name = 'a', version = '1' }, { + kind = 'template', + url_template = 'https://h/${name}-${version}.tar.gz', + auth = { env = 'VIMPACK_TEST_TOKEN' }, + })) + assert_eq('Bearer sekret', resolved.headers.Authorization) + vim.env.VIMPACK_TEST_TOKEN = nil + end) +end) + +describe('custom resolvers', function() + it('are usable once registered', function() + registry.register('spec_custom', function(spec) + return { url = 'https://custom/' .. spec.name, headers = {}, filename = 'a.tar.gz' } + end) + local resolved = assert(registry.resolve({ name = 'z', version = '1' }, { kind = 'spec_custom' })) + assert_eq('https://custom/z', resolved.url) + end) +end) + +describe('archive_kind', function() + it('classifies suffixes', function() + assert_eq('tar', util.archive_kind('a.tar.gz')) + assert_eq('tar', util.archive_kind('a.tgz')) + assert_eq('zip', util.archive_kind('A.ZIP')) + assert_nil(util.archive_kind('a.rpm')) + end) +end) From ac7d0866236566e48201d311549bc47767c81fd5 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 5 Sep 2026 13:30:17 +1000 Subject: [PATCH 2/2] Run CI under the default service account The vimpack-ci ServiceAccount does not exist in argocd-apps, so every step failed at pod creation. Other non-Go repos in the estate use default. --- .woodpecker/lint.yaml | 4 ++-- .woodpecker/pre-commit.yaml | 2 +- .woodpecker/test.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index d0d169d..44ad6eb 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -9,7 +9,7 @@ steps: - luacheck lua plugin tests backend_options: kubernetes: - serviceAccountName: vimpack-ci + serviceAccountName: default resources: requests: memory: 512Mi @@ -25,7 +25,7 @@ steps: - stylua --check lua plugin tests backend_options: kubernetes: - serviceAccountName: vimpack-ci + serviceAccountName: default resources: requests: memory: 512Mi diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml index 637c067..ead5274 100644 --- a/.woodpecker/pre-commit.yaml +++ b/.woodpecker/pre-commit.yaml @@ -10,7 +10,7 @@ steps: - pre-commit run --all-files --show-diff-on-failure backend_options: kubernetes: - serviceAccountName: vimpack-ci + serviceAccountName: default resources: requests: memory: 512Mi diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index 742c919..fcfc3de 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -9,7 +9,7 @@ steps: - nvim --headless --clean -l tests/minimal.lua backend_options: kubernetes: - serviceAccountName: vimpack-ci + serviceAccountName: default resources: requests: memory: 512Mi