diff options
| author | user <user@clank> | 2026-08-12 23:12:23 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 23:12:23 +0200 |
| commit | 4e647e2173a9817a2250666f8298b3b05d13ece5 (patch) | |
| tree | dfef7492f7c64745961e55a45268faa8bda4515b | |
| parent | build: ship the sl0ppi CLI in the tarball (diff) | |
| download | sl0ppi-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-- | Makefile | 14 | ||||
| -rw-r--r-- | README.md | 41 | ||||
| -rw-r--r-- | docs/roadmap.md | 28 | ||||
| -rwxr-xr-x | packaging/publish.sh | 149 | ||||
| -rw-r--r-- | site/index.html.in | 114 | ||||
| -rwxr-xr-x | site/install.sh | 90 |
6 files changed, 431 insertions, 5 deletions
@@ -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 @@ -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 && sl0ppi init <span class="dim"># make it a project</span> +<span class="prompt">$ </span>cd ~/dev && 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@ && ./@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" |
