aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 23:12:23 +0200
committeruser <user@clank>2026-08-12 23:12:23 +0200
commit4e647e2173a9817a2250666f8298b3b05d13ece5 (patch)
treedfef7492f7c64745961e55a45268faa8bda4515b
parentbuild: ship the sl0ppi CLI in the tarball (diff)
downloadsl0ppi-4e647e2173a9817a2250666f8298b3b05d13ece5.tar.gz
sl0ppi-4e647e2173a9817a2250666f8298b3b05d13ece5.tar.xz
sl0ppi-4e647e2173a9817a2250666f8298b3b05d13ece5.zip
release: publish sl0ppi.sl0p.foo, and a script to keep it current
Phase 5, settled as build scripts rather than CI: make release tests, then a fresh static-musl tarball make publish render the site, upload, and verify against the live URL publish.sh is deliberately dumb -- rsync, then ask the live site whether it agrees (status codes plus a re-downloaded checksum) -- but it has two guard rails that matter: - it refuses to republish a version whose bytes differ from what is already live. /releases/ is cached for a year, so the same URL serving two different tarballs would be undebuggable. Bump VERSION instead. - artifacts upload before latest.json, so the pointer never names a file that is not downloadable yet. site/install.sh is POSIX sh and short on purpose, because people are being asked to pipe it into a shell. It reads latest.json, downloads the versioned artifact, VERIFIES ITS SHA256, and only then unpacks and runs the bundled installer. Refusal was tested for real by serving a doctored latest.json: it stops with a checksum mismatch and installs nothing. Verified end to end from the public URL, in a throwaway HOME with node, npm, bun, cargo and rustc replaced by poison stubs: curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh sl0ppij --version -> 0.45.0+sl0p1 pi --version -> 0.84.1 sl0ppi doctor -> all good The vhost itself is committed in the sl0pday repo, where that box's nginx config lives.
-rw-r--r--Makefile14
-rw-r--r--README.md41
-rw-r--r--docs/roadmap.md28
-rwxr-xr-xpackaging/publish.sh149
-rw-r--r--site/index.html.in114
-rwxr-xr-xsite/install.sh90
6 files changed, 431 insertions, 5 deletions
diff --git a/Makefile b/Makefile
index fe216e7..a3063c3 100644
--- a/Makefile
+++ b/Makefile
@@ -116,6 +116,20 @@ package: stage ## Produce the redistributable tarball
@cd $(OUT) && sha256sum $(PKG).tar.gz > $(PKG).tar.gz.sha256
@echo "-> $(OUT)/$(PKG).tar.gz"
+##@ Release
+
+.PHONY: release
+release: test package ## Full gate: tests, then a fresh tarball
+ @echo "-> $(OUT)/$(PKG).tar.gz (publish with 'make publish')"
+
+.PHONY: publish
+publish: ## Upload the built release to sl0ppi.sl0p.foo
+ packaging/publish.sh
+
+.PHONY: publish-dry
+publish-dry: ## Show what publish would upload, without touching the box
+ packaging/publish.sh --dry-run
+
##@ Dev box
.PHONY: install-config
diff --git a/README.md b/README.md
index 748ef64..6e48f7d 100644
--- a/README.md
+++ b/README.md
@@ -13,4 +13,43 @@ sense together:
The point of this repo is that `make package` produces a tarball that installs a
working environment on a machine that is not this one.
-Status: scaffolding. See [docs/roadmap.md](docs/roadmap.md).
+## Install
+
+```bash
+curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh
+```
+
+Verifies the release checksum before unpacking, installs under `~/.local`, and
+needs no node, npm, rust or python on the target: the multiplexer is a static
+musl binary and the agent runs on a bundled bun.
+
+```bash
+cd ~/dev/myproject && sl0ppi init # make it a project
+cd ~/dev && sl0ppi up # a session, one tab per project
+sl0ppi ls # what is open, which agents are live
+sl0ppi doctor # check the installation
+```
+
+## Layout
+
+| path | role |
+|---|---|
+| `sl0ppij/` | the zellij fork (renamed; coexists with a stock zellij) |
+| `sl0pstatus/` | the zjstatus fork the default layout builds its status bar from |
+| `cli/` | the `sl0ppi` orchestrator (TypeScript, runs on the bundled bun) |
+| `pi-ext/` | pi extensions + `pir` |
+| `config/` | the opinionated default environment |
+| `packaging/` | vendor, stage, package, publish |
+| `site/` | what sl0ppi.sl0p.foo serves |
+
+## Build
+
+```bash
+make # documented targets
+make test # CLI + Rust suites
+make release # tests, then a static musl tarball
+make publish # upload it to sl0ppi.sl0p.foo
+```
+
+See [docs/roadmap.md](docs/roadmap.md), [docs/architecture.md](docs/architecture.md),
+[docs/cli-design.md](docs/cli-design.md) and [FORKS.md](FORKS.md).
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0c251b9..fc9b47b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -99,11 +99,31 @@ MVP is complete: discovery + idempotent tab diff, `up`/`ls`/`open`/`close`,
Deferred: `agent --resume` (needs the pir port off python3), fuzzy picker for
tab overflow, root order file, per-project eager.
-## Phase 5 — CI and distribution
+## Phase 5 — distribution ✅ (scripts, not CI)
-Deliberately last. `git.sl0p.foo` is a bare remote with no CI. When the rest
-works: a build host, a published tarball + checksum, and a one-liner installer
-URL.
+Live at **https://sl0ppi.sl0p.foo**.
+
+- `make release` — tests, then a fresh tarball
+- `make publish` — renders the site, uploads artifact + pointers, then asks the
+ live site whether it agrees (status codes plus a re-downloaded checksum)
+- `curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh` — verifies sha256 before
+ unpacking, and refuses to install anything on mismatch (tested by pointing it
+ at a doctored latest.json)
+
+The vhost lives in the sl0pday repo (`nginx/sl0p.conf`), because that is where
+that box's nginx config is deployed from. It is static-only: no app, no build on
+the box.
+
+Deliberately still not CI. A build host and a pipeline are worth it once there
+is more than one target to build, and the publish path is scripted either way.
+
+Guard rails worth knowing:
+
+- `publish.sh` refuses to overwrite an already-published version whose bytes
+ differ. `/releases/` is cached for a year, so republishing changed bytes under
+ one name would serve two different things to two people. Bump `VERSION`.
+- artifacts upload before pointers, so `latest.json` never names a file that is
+ not downloadable yet.
---
diff --git a/packaging/publish.sh b/packaging/publish.sh
new file mode 100755
index 0000000..a697dca
--- /dev/null
+++ b/packaging/publish.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# publish.sh — put a built release on sl0ppi.sl0p.foo.
+#
+# packaging/publish.sh [--host root@sl0p.foo] [--dry-run]
+#
+# Publishing is deliberately dumb: rsync a rendered site plus the artifact, then
+# ask the live site whether it agrees. There is no CI and no build on the box —
+# the tarball must already exist (`make package`).
+#
+# The vhost lives in the sl0pday repo (nginx/sl0p.conf, deployed with its own
+# ./deploy.sh). This script only ships content into /opt/sl0ppi/www.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+HOST="${SL0PPI_PUBLISH_HOST:-root@sl0p.foo}"
+REMOTE_DIR="/opt/sl0ppi/www"
+BASE_URL="https://sl0ppi.sl0p.foo"
+dry_run=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --host) HOST="$2"; shift 2 ;;
+ --host=*) HOST="${1#*=}"; shift ;;
+ -n|--dry-run) dry_run=1; shift ;;
+ -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "publish.sh: unknown argument '$1'" >&2; exit 2 ;;
+ esac
+done
+
+log() { printf '\033[36m==>\033[0m %s\n' "$*"; }
+die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
+run() { if [ "$dry_run" = 1 ]; then echo " would: $*"; else "$@"; fi; }
+
+VERSION="$(cat "$ROOT/VERSION")"
+STEM="sl0ppi-$VERSION-x86_64-linux"
+ARTIFACT="$STEM.tar.gz"
+OUT="$ROOT/packaging/out"
+
+[ -f "$OUT/$ARTIFACT" ] || die "no $OUT/$ARTIFACT — run 'make package' first"
+[ -f "$OUT/$ARTIFACT.sha256" ] || die "no checksum beside the artifact"
+
+# Trust nothing: recompute rather than believing the sidecar we are about to
+# publish as authoritative.
+SHA="$(sha256sum "$OUT/$ARTIFACT" | cut -d' ' -f1)"
+SIDECAR="$(cut -d' ' -f1 < "$OUT/$ARTIFACT.sha256")"
+[ "$SHA" = "$SIDECAR" ] || die "artifact does not match its .sha256 sidecar"
+
+# Refuse to overwrite a published version with different bytes: released URLs are
+# immutable (nginx caches /releases/ for a year), so republishing a changed file
+# under the same name would serve two different things to different people.
+remote_sha="$(ssh -o BatchMode=yes "$HOST" \
+ "sha256sum $REMOTE_DIR/releases/$ARTIFACT 2>/dev/null | cut -d' ' -f1" || true)"
+if [ -n "$remote_sha" ] && [ "$remote_sha" != "$SHA" ]; then
+ die "$ARTIFACT is already published with a DIFFERENT checksum
+ published $remote_sha
+ local $SHA
+Bump VERSION instead of rewriting a release."
+fi
+
+# --- render the site -------------------------------------------------------
+STAGE="$(mktemp -d "${TMPDIR:-/tmp}/sl0ppi-publish.XXXXXX")"
+trap 'rm -rf "$STAGE"' EXIT
+mkdir -p "$STAGE/releases"
+
+DATE="$(date -u +%Y-%m-%d)"
+MANIFEST="$ROOT/packaging/stage/$STEM/MANIFEST"
+components=""
+if [ -f "$MANIFEST" ]; then
+ while read -r name version; do
+ [ -n "$name" ] || continue
+ components+=" <tr><td>$name</td><td>$version</td><td class=\"dim\"></td></tr>"$'\n'
+ done < "$MANIFEST"
+else
+ components=" <tr><td colspan=\"3\" class=\"dim\">see MANIFEST inside the tarball</td></tr>"
+fi
+
+python3 - "$ROOT/site/index.html.in" "$STAGE/index.html" <<PYEOF
+import sys
+src, dst = sys.argv[1], sys.argv[2]
+html = open(src, encoding="utf-8").read()
+for key, value in {
+ "@VERSION@": """$VERSION""",
+ "@DATE@": """$DATE""",
+ "@ARTIFACT@": """$ARTIFACT""",
+ "@STEM@": """$STEM""",
+ "@SHA256@": """$SHA""",
+ "@COMPONENTS@": """$components""",
+}.items():
+ html = html.replace(key, value)
+leftover = [line for line in html.splitlines() if "@" in line and "@media" not in line
+ and any(t in line for t in ("@VERSION@", "@DATE@", "@ARTIFACT@", "@STEM@", "@SHA256@", "@COMPONENTS@"))]
+if leftover:
+ raise SystemExit("unsubstituted placeholders: %r" % leftover[:3])
+open(dst, "w", encoding="utf-8").write(html)
+PYEOF
+
+install -m644 "$OUT/$ARTIFACT" "$STAGE/releases/$ARTIFACT"
+install -m644 "$OUT/$ARTIFACT.sha256" "$STAGE/releases/$ARTIFACT.sha256"
+install -m755 "$ROOT/site/install.sh" "$STAGE/install.sh"
+
+cat > "$STAGE/latest.json" <<EOF
+{
+ "version": "$VERSION",
+ "artifact": "$ARTIFACT",
+ "sha256": "$SHA",
+ "url": "$BASE_URL/releases/$ARTIFACT",
+ "published": "$DATE"
+}
+EOF
+
+log "publishing sl0ppi $VERSION to $HOST:$REMOTE_DIR"
+
+# Artifacts first, pointers second: latest.json must never name a file that is
+# not downloadable yet. Same reasoning as the SPA's two-pass deploy.
+run rsync -a --chmod=D755,F644 "$STAGE/releases/" "$HOST:$REMOTE_DIR/releases/"
+run rsync -a --chmod=F644 "$STAGE/index.html" "$HOST:$REMOTE_DIR/index.html"
+run rsync -a --chmod=F755 "$STAGE/install.sh" "$HOST:$REMOTE_DIR/install.sh"
+run rsync -a --chmod=F644 "$STAGE/latest.json" "$HOST:$REMOTE_DIR/latest.json"
+
+[ "$dry_run" = 1 ] && { log "dry run: nothing was uploaded"; exit 0; }
+
+# --- verify against the live site ------------------------------------------
+log "verifying $BASE_URL"
+fail=0
+check() {
+ local url="$1" want="$2"
+ local code
+ code="$(curl -sS -o /dev/null -w '%{http_code}' "$url")"
+ if [ "$code" = "$want" ]; then
+ printf ' ok %-58s %s\n' "$url" "$code"
+ else
+ printf ' FAIL %-58s %s (want %s)\n' "$url" "$code" "$want"; fail=1
+ fi
+}
+check "$BASE_URL/" 200
+check "$BASE_URL/install.sh" 200
+check "$BASE_URL/latest.json" 200
+check "$BASE_URL/releases/$ARTIFACT" 200
+check "$BASE_URL/releases/$ARTIFACT.sha256" 200
+
+published_sha="$(curl -fsSL "$BASE_URL/releases/$ARTIFACT" | sha256sum | cut -d' ' -f1)"
+if [ "$published_sha" = "$SHA" ]; then
+ printf ' ok %-58s %s\n' "downloaded artifact checksum" "matches"
+else
+ printf ' FAIL %-58s %s\n' "downloaded artifact checksum" "MISMATCH"; fail=1
+fi
+
+[ "$fail" = 0 ] || die "the live site does not agree with what we published"
+log "published: $BASE_URL"
diff --git a/site/index.html.in b/site/index.html.in
new file mode 100644
index 0000000..7c73120
--- /dev/null
+++ b/site/index.html.in
@@ -0,0 +1,114 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>sl0ppi — a terminal for driving agents</title>
+<meta name="description" content="An opinionated zellij fork plus pi integration, packaged as one self-contained tarball.">
+<meta property="og:title" content="sl0ppi">
+<meta property="og:description" content="An opinionated zellij fork plus pi integration, packaged as one self-contained tarball.">
+<meta property="og:url" content="https://sl0ppi.sl0p.foo/">
+<style>
+ :root {
+ --bg: #181818; --fg: #e4e4ef; --dim: #8b8b93; --accent: #ffdd33;
+ --green: #73c936; --red: #f43841; --line: #2a2a2a; --panel: #1f1f1f;
+ }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; padding: 2.5rem 1.25rem 5rem; background: var(--bg); color: var(--fg);
+ font: 15px/1.65 ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
+ }
+ main { max-width: 62rem; margin: 0 auto; }
+ h1 { font-size: 2rem; margin: 0 0 .25rem; color: var(--accent); letter-spacing: -.02em; }
+ h2 { font-size: 1rem; margin: 2.5rem 0 .75rem; color: var(--accent); font-weight: 600; }
+ h2::before { content: "## "; color: var(--dim); }
+ p { margin: .5rem 0; }
+ a { color: var(--green); }
+ .tagline { color: var(--dim); margin-bottom: 2rem; }
+ pre, .box {
+ background: var(--panel); border: 1px solid var(--line); border-radius: 4px;
+ padding: .85rem 1rem; overflow-x: auto; margin: .75rem 0;
+ }
+ pre { white-space: pre; }
+ code { color: var(--fg); }
+ .prompt { color: var(--dim); user-select: none; }
+ table { border-collapse: collapse; width: 100%; margin: .75rem 0; }
+ td, th { text-align: left; padding: .35rem .75rem .35rem 0; border-bottom: 1px solid var(--line); }
+ th { color: var(--dim); font-weight: 600; }
+ .dim { color: var(--dim); }
+ .warn { color: var(--red); }
+ ul { margin: .5rem 0; padding-left: 1.25rem; }
+ li { margin: .2rem 0; }
+ footer { margin-top: 4rem; color: var(--dim); border-top: 1px solid var(--line); padding-top: 1rem; font-size: 13px; }
+ .checksum { font-size: 12px; word-break: break-all; color: var(--dim); }
+</style>
+</head>
+<body>
+<main>
+
+<h1>sl0ppi</h1>
+<p class="tagline">A batteries-included terminal environment for driving LLM agent sessions.
+Version <strong>@VERSION@</strong> · @DATE@</p>
+
+<p>Two halves that only make sense together, packaged as one tarball:</p>
+<ul>
+ <li><strong>sl0ppij</strong> — an opinionated <a href="https://github.com/zellij-org/zellij">zellij</a>
+ fork, renamed so it installs and runs <em>alongside</em> an unmodified zellij.</li>
+ <li><strong>sl0ppi</strong> — a CLI that turns a directory of projects into a session:
+ one root = one session, one project = one tab, each with its own agent.</li>
+</ul>
+
+<h2>install</h2>
+<pre><span class="prompt">$ </span>curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh</pre>
+<p class="dim">Installs under <code>~/.local</code>. Verifies the release checksum before unpacking
+anything, and refuses to continue if it does not match. Read it first if you like:
+<a href="/install.sh">install.sh</a>.</p>
+
+<h2>what you get</h2>
+<table>
+ <tr><th>component</th><th>version</th><th></th></tr>
+@COMPONENTS@
+</table>
+<p class="dim">The multiplexer is a <strong>statically linked musl binary</strong> — no glibc
+requirement at all. The agent runs on a bundled <a href="https://bun.sh">bun</a>, so the
+target machine needs <strong>no node, npm, rust or python</strong>.</p>
+
+<h2>use it</h2>
+<pre><span class="prompt">$ </span>cd ~/dev/myproject &amp;&amp; sl0ppi init <span class="dim"># make it a project</span>
+<span class="prompt">$ </span>cd ~/dev &amp;&amp; sl0ppi up <span class="dim"># a session, one tab per project</span>
+<span class="prompt">$ </span>sl0ppi ls <span class="dim"># what is open, and which agents are live</span>
+<span class="prompt">$ </span>sl0ppi doctor <span class="dim"># check the installation</span></pre>
+<p><code>sl0ppi up</code> is idempotent: run it as often as you like, it only creates what is
+missing. Agents are staged with <code>start_suspended</code>, so opening twelve projects
+does not launch twelve agents — each starts on a keypress.</p>
+
+<h2>download</h2>
+<p><a href="/releases/@ARTIFACT@">@ARTIFACT@</a> ·
+ <a href="/releases/@ARTIFACT@.sha256">sha256</a> ·
+ <a href="/releases/">all releases</a></p>
+<p class="checksum">@SHA256@</p>
+<pre><span class="prompt">$ </span>curl -fLO https://sl0ppi.sl0p.foo/releases/@ARTIFACT@
+<span class="prompt">$ </span>curl -fsSL https://sl0ppi.sl0p.foo/releases/@ARTIFACT@.sha256 | sha256sum -c -
+<span class="prompt">$ </span>tar xzf @ARTIFACT@ &amp;&amp; ./@STEM@/install.sh</pre>
+
+<h2>source</h2>
+<pre><span class="prompt">$ </span>git clone https://git.sl0p.foo/sl0ppi.git</pre>
+<p>Browse: <a href="https://git.sl0p.foo/sl0ppi.git/">git.sl0p.foo/sl0ppi.git</a>.
+ The repo vendors all three forks (zellij, zjstatus, the pi extensions) with their
+ history, so the tarball is reproducible from a single clone.</p>
+
+<h2>caveats</h2>
+<ul>
+ <li>x86_64 Linux only so far.</li>
+ <li><code>pir</code> (session resume) still needs <code>python3</code> and <code>fzf</code> on the host —
+ the only part of the bundle that is not self-contained. <code>sl0ppi doctor</code> tells you.</li>
+ <li>Pre-1.0: the on-disk layout and the <code>.sl0ppi/layout.kdl</code> format may still move.</li>
+</ul>
+
+<footer>
+sl0ppi @VERSION@ · built @DATE@ · <a href="https://sl0p.foo">sl0p.foo</a>
+</footer>
+
+</main>
+</body>
+</html>
diff --git a/site/install.sh b/site/install.sh
new file mode 100755
index 0000000..25c1ada
--- /dev/null
+++ b/site/install.sh
@@ -0,0 +1,90 @@
+#!/bin/sh
+# sl0ppi installer.
+#
+# curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh
+# curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh -s -- --prefix /opt/sl0ppi
+#
+# Fetches the current release, VERIFIES ITS SHA256, unpacks it, and runs the
+# installer inside the tarball. Nothing is installed if the checksum does not
+# match.
+#
+# POSIX sh on purpose, and deliberately short: you are piping this into a shell,
+# so it should be readable in one sitting. Read it first:
+# curl -fsSL https://sl0ppi.sl0p.foo/install.sh | less
+set -eu
+
+BASE_URL="${SL0PPI_BASE_URL:-https://sl0ppi.sl0p.foo}"
+PREFIX="${PREFIX:-$HOME/.local}"
+KEEP=0
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --prefix=*) PREFIX="${1#*=}"; shift ;;
+ --keep) KEEP=1; shift ;;
+ -h|--help) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "install.sh: unknown argument '$1'" >&2; exit 2 ;;
+ esac
+done
+
+say() { printf '\033[36m==>\033[0m %s\n' "$*"; }
+die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
+
+need() { command -v "$1" >/dev/null 2>&1 || die "need $1"; }
+need curl
+need tar
+
+# sha256: coreutils on Linux, shasum on macOS/BSD. No checksum tool, no install.
+if command -v sha256sum >/dev/null 2>&1; then
+ sha256() { sha256sum "$1" | cut -d' ' -f1; }
+elif command -v shasum >/dev/null 2>&1; then
+ sha256() { shasum -a 256 "$1" | cut -d' ' -f1; }
+else
+ die "need sha256sum or shasum to verify the download"
+fi
+
+# Only x86_64 linux is published so far; fail clearly rather than install
+# something that cannot run.
+os="$(uname -s)"; arch="$(uname -m)"
+[ "$os" = "Linux" ] || die "no build for $os yet (linux only)"
+case "$arch" in
+ x86_64|amd64) ;;
+ *) die "no build for $arch yet (x86_64 only)" ;;
+esac
+
+# One tiny JSON field reader, so this needs no jq.
+json_field() {
+ sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
+}
+
+say "asking $BASE_URL for the current release"
+latest="$(curl -fsSL "$BASE_URL/latest.json")" || die "could not fetch $BASE_URL/latest.json"
+
+version="$(printf '%s' "$latest" | json_field version)"
+artifact="$(printf '%s' "$latest" | json_field artifact)"
+want="$(printf '%s' "$latest" | json_field sha256)"
+[ -n "$version" ] && [ -n "$artifact" ] && [ -n "$want" ] || die "latest.json is malformed"
+
+tmp="$(mktemp -d "${TMPDIR:-/tmp}/sl0ppi-install.XXXXXX")"
+[ "$KEEP" -eq 1 ] || trap 'rm -rf "$tmp"' EXIT INT TERM
+
+say "downloading sl0ppi $version"
+curl -fSL --progress-bar -o "$tmp/$artifact" "$BASE_URL/releases/$artifact" \
+ || die "download failed"
+
+got="$(sha256 "$tmp/$artifact")"
+if [ "$got" != "$want" ]; then
+ die "CHECKSUM MISMATCH
+ expected $want
+ got $got
+Nothing was installed. Either the download was corrupted, or the file is not
+what the release says it is; do not run it."
+fi
+say "sha256 verified"
+
+tar -xzf "$tmp/$artifact" -C "$tmp"
+dir="$tmp/$(printf '%s' "$artifact" | sed 's/\.tar\.gz$//')"
+[ -x "$dir/install.sh" ] || die "the archive has no installer where one was expected"
+
+say "running the bundled installer"
+"$dir/install.sh" --prefix "$PREFIX"