diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3dba941 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: jdx/mise-action@v4 + with: + install: false + - run: test/e2e.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..605be18 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 chunkzero + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1529826 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# mise-chunkzero + +A [mise](https://mise.jdx.dev) backend plugin for chunkzero tools. It installs releases listed in +[chunkzero/mise-registry](https://github.com/chunkzero/mise-registry), verifies each archive's sha256, and keeps the +runtime files bundled next to the executable. + +```toml +[plugins] +chunkzero = "https://github.com/chunkzero/mise-chunkzero" + +[tools] +# The newest nightly; `chunkzero:rpp-beta` follows the newest alpha, beta or rc instead. +"chunkzero:chunk-nightly" = { version = "latest", prerelease = true } +# An exact version from any channel. +"chunkzero:rpp" = "0.1.0-nightly.20261004062300.ge282f11816cd" +``` + +Each channel is its own tool, so its releases are cached and installed separately from the others. `prerelease = true` +lets `latest` select nightly and beta versions. With `mise.lock`, the resolved version stays pinned; `mise upgrade` +moves to the channel's newest release. The registry has no release dates, so mise's `minimum_release_age` doesn't apply +to these tools. + +Stable releases don't need the plugin: + +```toml +[tools] +"github:chunkzero/chunk" = "latest" +``` + +Tools: `chunk`, `rpp`. Platforms: Linux and macOS on x64 and arm64, Windows on x64. + +## Development + +`test/e2e.sh` installs chunk from a local copy of the registry and checks that a wrong checksum is rejected. Set +`MISE_CHUNKZERO_REGISTRY` to point the plugin at another registry URL. diff --git a/hooks/backend_exec_env.lua b/hooks/backend_exec_env.lua new file mode 100644 index 0000000..aa0cbc2 --- /dev/null +++ b/hooks/backend_exec_env.lua @@ -0,0 +1,3 @@ +function PLUGIN:BackendExecEnv(ctx) + return { env_vars = { { key = "PATH", value = ctx.install_path } } } +end diff --git a/hooks/backend_install.lua b/hooks/backend_install.lua new file mode 100644 index 0000000..e4ef436 --- /dev/null +++ b/hooks/backend_install.lua @@ -0,0 +1,54 @@ +local archiver = require("archiver") +local cmd = require("cmd") +local file = require("file") +local http = require("http") +local registry = require("registry") + +local function sha256(path) + -- The path goes through the environment, so neither cmd.exe nor sh can expand or split it. + local options = { env = { CHUNKZERO_ARCHIVE = path } } + local output + if RUNTIME.osType == "windows" then + -- `(Get-FileHash -Algorithm SHA256 -LiteralPath $env:CHUNKZERO_ARCHIVE).Hash`, encoded so the shell running the command can't expand it. + output = cmd.exec("powershell -NoProfile -EncodedCommand KABHAGUAdAAtAEYAaQBsAGUASABhAHMAaAAgAC0AQQBsAGcAbwByAGkAdABoAG0AIABTAEgAQQAyADUANgAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABlAG4AdgA6AEMASABVAE4ASwBaAEUAUgBPAF8AQQBSAEMASABJAFYARQApAC4ASABhAHMAaAA=", options) + else + local command = RUNTIME.osType == "darwin" and "shasum -a 256" or "sha256sum" + output = cmd.exec(command .. ' < "$CHUNKZERO_ARCHIVE"', options) + end + local digest = output:match("^%s*(%x+)") + if not digest or #digest ~= 64 then + error("couldn't compute the sha256 of " .. path .. ": " .. output) + end + return digest:lower() +end + +function PLUGIN:BackendInstall(ctx) + local tool, channel = registry.parse(ctx.tool) + if channel and registry.channel(ctx.version) ~= channel then + error(ctx.tool .. " only installs " .. channel .. " versions, not " .. ctx.version) + end + local entry + for _, candidate in ipairs(registry.load(tool).versions) do + if candidate.version == ctx.version then + entry = candidate + end + end + if not entry then + error(tool .. " " .. ctx.version .. " isn't in the chunkzero registry") + end + local platform = registry.platform() + local asset = entry.assets[platform] + if not asset or not asset.sha256:match("^%x+$") or #asset.sha256 ~= 64 then + error(tool .. " " .. ctx.version .. " has no " .. platform .. " build") + end + + local archive = file.join_path(ctx.download_path, tool .. "-" .. ctx.version .. "-" .. platform .. ".tar.gz") + http.download_file({ url = asset.url }, archive) + local actual = sha256(archive) + if actual ~= asset.sha256:lower() then + error("checksum mismatch for " .. asset.url .. ": expected " .. asset.sha256 .. ", got " .. actual) + end + -- Archives hold one top-level directory with the executable and the runtime files it loads relative to itself. + archiver.decompress(archive, ctx.install_path, { strip_components = 1 }) + return {} +end diff --git a/hooks/backend_list_versions.lua b/hooks/backend_list_versions.lua new file mode 100644 index 0000000..09a7389 --- /dev/null +++ b/hooks/backend_list_versions.lua @@ -0,0 +1,14 @@ +local registry = require("registry") + +-- `chunkzero:chunk-nightly` and `chunkzero:chunk-beta` list only that channel's releases, so mise's `latest` picks its +-- newest; as separate tools they also get separate caches and installs. `chunkzero:chunk` lists every release. +function PLUGIN:BackendListVersions(ctx) + local tool, channel = registry.parse(ctx.tool) + local versions = {} + for _, entry in ipairs(registry.load(tool).versions) do + if channel == nil or registry.channel(entry.version) == channel then + table.insert(versions, entry.version) + end + end + return { versions = versions } +end diff --git a/lib/registry.lua b/lib/registry.lua new file mode 100644 index 0000000..d9a06c5 --- /dev/null +++ b/lib/registry.lua @@ -0,0 +1,74 @@ +local http = require("http") +local json = require("json") + +local M = {} + +-- MISE_CHUNKZERO_REGISTRY overrides the registry base URL, e.g. to test against a local copy. +M.BASE = os.getenv("MISE_CHUNKZERO_REGISTRY") or "https://raw.githubusercontent.com/chunkzero/mise-registry/main" + +local CHANNELS = { nightly = true, beta = true } + +--- Splits `chunk-nightly` into ("chunk", "nightly"); a bare `chunk` lists every channel. +function M.parse(name) + if not name:match("^%l[%l%d%-]*$") then + error("invalid chunkzero tool name " .. name) + end + local tool, channel = name:match("^(.-)%-(%l+)$") + if tool and CHANNELS[channel] then + return tool, channel + end + return name, nil +end + +function M.channel(version) + if version:find("-nightly.", 1, true) then + return "nightly" + end + return version:find("-", 1, true) and "beta" or "latest" +end + +local VERSIONS = { + "^%d+%.%d+%.%d+$", + "^%d+%.%d+%.%d+%-alpha%.%d+$", + "^%d+%.%d+%.%d+%-beta%.%d+$", + "^%d+%.%d+%.%d+%-rc%.%d+$", + "^%d+%.%d+%.%d+%-nightly%.%d+%.g%x+$", +} + +function M.valid_version(version) + for _, pattern in ipairs(VERSIONS) do + if version:match(pattern) then + return true + end + end + return false +end + +function M.load(tool) + local response = http.get({ url = M.BASE .. "/tools/" .. tool .. ".json" }) + if response.status_code == 404 then + error("chunkzero has no tool named " .. tool .. "; see https://github.com/chunkzero/mise-registry/tree/main/tools") + end + if response.status_code ~= 200 then + error("couldn't read the chunkzero registry for " .. tool .. ": HTTP " .. response.status_code) + end + local registry = json.decode(response.body) + for _, entry in ipairs(registry.versions) do + if not M.valid_version(entry.version) then + error("the chunkzero registry lists an invalid " .. tool .. " version: " .. entry.version) + end + end + return registry +end + +function M.platform() + local os_names = { linux = "linux", darwin = "darwin", windows = "windows" } + local arch_names = { amd64 = "x64", arm64 = "arm64" } + local os_name, arch = os_names[RUNTIME.osType], arch_names[RUNTIME.archType] + if not os_name or not arch then + error("chunkzero tools don't support " .. RUNTIME.osType .. "-" .. RUNTIME.archType) + end + return os_name .. "-" .. arch +end + +return M diff --git a/metadata.lua b/metadata.lua new file mode 100644 index 0000000..bdd3afe --- /dev/null +++ b/metadata.lua @@ -0,0 +1,8 @@ +PLUGIN = {} +PLUGIN.name = "chunkzero" +PLUGIN.version = "0.1.0" +PLUGIN.homepage = "https://github.com/chunkzero/mise-chunkzero" +PLUGIN.license = "MIT" +PLUGIN.description = "Installs chunkzero tools (chunk, rpp) from chunkzero/mise-registry" +PLUGIN.minRuntimeVersion = "0.3.0" +PLUGIN.notes = {} diff --git a/test/e2e.sh b/test/e2e.sh new file mode 100755 index 0000000..15ea038 --- /dev/null +++ b/test/e2e.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Installs chunk through the plugin from a local registry copy, then checks that other channels' versions and wrong checksums are rejected. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'kill "$server" 2>/dev/null; rm -rf "$work"' EXIT +cp -r "$root/test/registry" "$work/registry" +python3 -m http.server 8765 --bind 127.0.0.1 --directory "$work/registry" >/dev/null 2>&1 & +server=$! +sleep 1 + +export MISE_DATA_DIR="$work/data" MISE_CACHE_DIR="$work/cache" MISE_STATE_DIR="$work/state" MISE_CONFIG_DIR="$work/config" +export MISE_GLOBAL_CONFIG_FILE="$work/config/config.toml" MISE_YES=1 MISE_CHUNKZERO_REGISTRY=http://127.0.0.1:8765 +mkdir -p "$work/project" && cd "$work/project" +mise plugins link chunkzero "$root" +cat > mise.toml <<'TOML' +[tools] +"chunkzero:chunk-nightly" = { version = "latest", prerelease = true } +TOML +mise trust -q +mise install +mise exec -- chunk --version | grep -qx 'chunk 0.1.0-nightly.20261004.ge282f11816cd' +if mise install chunkzero:chunk-beta@0.1.0-nightly.20261004.ge282f11816cd 2>"$work/error"; then + echo "installed a nightly as a beta" >&2 + exit 1 +fi +grep -q "only installs beta versions" "$work/error" + +sed -i 's/"sha256": "[0-9a-f]*"/"sha256": "'"$(printf '0%.0s' {1..64})"'"/' "$work/registry/tools/chunk.json" +rm -rf "$MISE_DATA_DIR/installs" "$MISE_CACHE_DIR" +if mise install 2>"$work/error"; then + echo "installed despite a wrong checksum" >&2 + exit 1 +fi +grep -q "checksum mismatch" "$work/error" +echo "ok" diff --git a/test/registry/tools/chunk.json b/test/registry/tools/chunk.json new file mode 100644 index 0000000..185447e --- /dev/null +++ b/test/registry/tools/chunk.json @@ -0,0 +1,16 @@ +{ + "repository": "chunkzero/chunk", + "channels": { "nightly": "0.1.0-nightly.20261004.ge282f11816cd" }, + "versions": [ + { + "version": "0.1.0-nightly.20261004.ge282f11816cd", + "tag": "v0.1.0-nightly.20261004.ge282f11816cd", + "assets": { + "linux-x64": { + "url": "https://github.com/chunkzero/chunk/releases/download/v0.1.0-nightly.20261004.ge282f11816cd/chunk-0.1.0-nightly.20261004.ge282f11816cd-linux-x64.tar.gz", + "sha256": "e707dc45481899d3d49b759695c602e000a828089fea9430d23abf5225bdc96a" + } + } + } + ] +}