install.py
python
sha256:5477f1954a99236e4733341d917e62e937a85a10301326b7fb8296aff596a666
fix: strip .tar.gz not just .gz when extracting version fro…
Sonnet 4.6
48 days ago
| 1 | """Install / uninstall script generation for the Muse CLI. |
| 2 | |
| 3 | Endpoints: |
| 4 | GET /install.sh — shell script that installs the Muse CLI into a venv |
| 5 | GET /uninstall.sh — shell script that removes the venv and the bin symlink |
| 6 | |
| 7 | Design rules: |
| 8 | - No PyPI dependency — the muse sdist is downloaded directly from this |
| 9 | MuseHub instance at {MUSEHUB_URL}/releases/muse-{ver}.tar.gz. |
| 10 | - MUSEHUB_URL is derived from the request so self-hosted instances work |
| 11 | without configuration. |
| 12 | - The URL is sanitised before embedding to prevent shell injection. |
| 13 | - Python 3.14+ is required; the script exits with a clear message if absent. |
| 14 | - Venv lives at ~/.local/share/muse/venv; ~/.muse data is never touched. |
| 15 | - Both scripts are idempotent — running them twice leaves a clean state. |
| 16 | |
| 17 | Dev / agent note — editable installs and the install script do not mix: |
| 18 | This script always does a regular `pip install <sdist>` into the venv, |
| 19 | never `pip install -e`. Running install.sh (or the uninstall/install cycle) |
| 20 | will therefore CLOBBER any `pip install -e ~/ecosystem/muse` that was |
| 21 | previously wired into ~/.local/share/muse/venv. After reinstalling, restore |
| 22 | the editable link with: |
| 23 | |
| 24 | ~/.local/share/muse/venv/bin/pip install -e ~/ecosystem/muse |
| 25 | |
| 26 | The install script is intentionally for end users. Do not change it to use |
| 27 | -e; the sdist URL is the only source of truth for released builds. |
| 28 | """ |
| 29 | |
| 30 | import re |
| 31 | from pathlib import Path |
| 32 | |
| 33 | from fastapi import APIRouter, HTTPException, Request, status |
| 34 | from fastapi.responses import PlainTextResponse |
| 35 | |
| 36 | |
| 37 | def _latest_published_muse_version() -> str: |
| 38 | """Return the version of the most recently published muse tarball. |
| 39 | |
| 40 | Scans ``settings.musehub_releases_dir`` for ``muse-*.tar.gz`` files and |
| 41 | returns the version of the file with the highest mtime — i.e. the most |
| 42 | recently uploaded tarball, regardless of how semver orders it. This |
| 43 | avoids PEP 440's pre-release ordering where a stable ``0.2.0`` would |
| 44 | rank above ``0.2.0rc10`` even when rc10 is the intended latest release. |
| 45 | |
| 46 | Falls back to the musehub package version if no tarballs are found |
| 47 | (e.g. during local dev without a releases volume). |
| 48 | """ |
| 49 | try: |
| 50 | from musehub.config import settings |
| 51 | releases_dir = Path(settings.musehub_releases_dir) |
| 52 | tarballs = list(releases_dir.glob("muse-*.tar.gz")) |
| 53 | if tarballs: |
| 54 | latest = max(tarballs, key=lambda p: p.stat().st_mtime) |
| 55 | return latest.name.removesuffix(".tar.gz").removeprefix("muse-") |
| 56 | except Exception: |
| 57 | pass |
| 58 | from musehub.protocol.version import MUSE_VERSION |
| 59 | return MUSE_VERSION |
| 60 | |
| 61 | router = APIRouter(tags=["Install"]) |
| 62 | |
| 63 | # Only HTTP(S) URLs with safe hostname characters are accepted. |
| 64 | # Shell metacharacters ($, ", ', `, \, ;, |, &, (, ), <, >, newline) are all |
| 65 | # outside this character class and will cause the check to fail. |
| 66 | _SAFE_URL_RE = re.compile(r"^https?://[A-Za-z0-9._:@/-]+$") |
| 67 | |
| 68 | _INSTALL_TEMPLATE = """\ |
| 69 | #!/usr/bin/env sh |
| 70 | # Muse CLI installer — installs muse into a dedicated Python venv. |
| 71 | # Generated by MuseHub {musehub_url} |
| 72 | # Run: curl -fsSL {musehub_url}/install.sh | sh |
| 73 | set -euf |
| 74 | |
| 75 | MUSEHUB_URL="{musehub_url}" |
| 76 | MUSE_VERSION="{version}" |
| 77 | VENV_DIR="${{MUSE_VENV_DIR:-$HOME/.local/share/muse/venv}}" |
| 78 | BIN_DIR="${{MUSE_INSTALL_DIR:-$HOME/.local/bin}}" |
| 79 | SDIST_URL="${{MUSEHUB_URL}}/releases/muse-${{MUSE_VERSION}}.tar.gz" |
| 80 | |
| 81 | # ── Require Python 3.14+ ─────────────────────────────────────────────────────── |
| 82 | PYTHON="" |
| 83 | for candidate in python3.14 python3; do |
| 84 | if command -v "$candidate" >/dev/null 2>&1; then |
| 85 | ver="$("$candidate" -c 'import sys; print("%d%02d" % sys.version_info[:2])')" |
| 86 | if [ "$ver" -ge 314 ]; then |
| 87 | PYTHON="$candidate" |
| 88 | break |
| 89 | fi |
| 90 | fi |
| 91 | done |
| 92 | |
| 93 | if [ -z "$PYTHON" ]; then |
| 94 | printf 'Error: Muse requires Python 3.14 or later.\\n' >&2 |
| 95 | printf 'Install it from https://python.org and re-run this script.\\n' >&2 |
| 96 | exit 1 |
| 97 | fi |
| 98 | |
| 99 | printf 'Using %s\\n' "$($PYTHON --version)" |
| 100 | |
| 101 | # ── Create venv ──────────────────────────────────────────────────────────────── |
| 102 | printf 'Creating venv at %s ...\\n' "$VENV_DIR" |
| 103 | "$PYTHON" -m venv "$VENV_DIR" |
| 104 | |
| 105 | # ── Download and install muse sdist ─────────────────────────────────────────── |
| 106 | printf 'Downloading Muse %s from %s ...\\n' "$MUSE_VERSION" "$MUSEHUB_URL" |
| 107 | TMP_DIR="$(mktemp -d)" |
| 108 | cleanup() {{ rm -rf "$TMP_DIR"; }} |
| 109 | trap cleanup EXIT |
| 110 | |
| 111 | curl -fsSL "$SDIST_URL" -o "$TMP_DIR/muse.tar.gz" |
| 112 | "$VENV_DIR/bin/pip" install --quiet "$TMP_DIR/muse.tar.gz" |
| 113 | |
| 114 | # ── Symlink binary ───────────────────────────────────────────────────────────── |
| 115 | mkdir -p "$BIN_DIR" |
| 116 | ln -sf "$VENV_DIR/bin/muse" "$BIN_DIR/muse" |
| 117 | |
| 118 | printf '\\nMuse %s installed.\\n' "$MUSE_VERSION" |
| 119 | printf 'Binary: %s/muse\\n' "$BIN_DIR" |
| 120 | printf 'Venv: %s\\n' "$VENV_DIR" |
| 121 | printf '\\nMake sure %s is on your PATH, then run: muse --version\\n' "$BIN_DIR" |
| 122 | """ |
| 123 | |
| 124 | _UNINSTALL_TEMPLATE = """\ |
| 125 | #!/usr/bin/env sh |
| 126 | # Muse CLI uninstaller. |
| 127 | # Removes the venv and the bin symlink. |
| 128 | # Your repository data in ~/.muse is NEVER deleted by this script. |
| 129 | set -euf |
| 130 | |
| 131 | VENV_DIR="${MUSE_VENV_DIR:-$HOME/.local/share/muse/venv}" |
| 132 | BIN_DIR="${MUSE_INSTALL_DIR:-$HOME/.local/bin}" |
| 133 | BINARY="$BIN_DIR/muse" |
| 134 | |
| 135 | if [ -L "$BINARY" ] || [ -f "$BINARY" ]; then |
| 136 | rm -f "$BINARY" |
| 137 | printf 'Removed %s\\n' "$BINARY" |
| 138 | else |
| 139 | printf 'muse binary not found at %s\\n' "$BINARY" |
| 140 | fi |
| 141 | |
| 142 | if [ -d "$VENV_DIR" ]; then |
| 143 | rm -rf "$VENV_DIR" |
| 144 | printf 'Removed venv at %s\\n' "$VENV_DIR" |
| 145 | else |
| 146 | printf 'No venv found at %s\\n' "$VENV_DIR" |
| 147 | fi |
| 148 | |
| 149 | printf '\\nYour repository data in ~/.muse has been preserved.\\n' |
| 150 | printf 'To remove it manually: rm -rf ~/.muse\\n' |
| 151 | """ |
| 152 | |
| 153 | def sanitize_musehub_url(url: str) -> str: |
| 154 | """Validate and return *url* if it is safe to embed in a shell script. |
| 155 | |
| 156 | Raises ``ValueError`` for URLs containing shell metacharacters or |
| 157 | non-HTTP(S) schemes. |
| 158 | """ |
| 159 | url = url.rstrip("/") |
| 160 | if not _SAFE_URL_RE.match(url): |
| 161 | raise ValueError( |
| 162 | f"URL contains unsafe characters and cannot be embedded in a shell script: {url!r}" |
| 163 | ) |
| 164 | return url |
| 165 | |
| 166 | def build_sdist_url(musehub_url: str, version: str) -> str: |
| 167 | """Construct the sdist download URL. |
| 168 | |
| 169 | >>> build_sdist_url("https://hub.example.com", "1.2.3") |
| 170 | 'https://hub.example.com/releases/muse-1.2.3.tar.gz' |
| 171 | """ |
| 172 | return f"{musehub_url}/releases/muse-{version}.tar.gz" |
| 173 | |
| 174 | def generate_install_script(musehub_url: str, version: str | None = None) -> str: |
| 175 | """Return the install shell script with *musehub_url* and *version* substituted. |
| 176 | |
| 177 | When *version* is omitted the latest published tarball in the releases |
| 178 | directory is used, so the script always reflects what is actually available. |
| 179 | """ |
| 180 | safe_url = sanitize_musehub_url(musehub_url) |
| 181 | resolved_version = version if version is not None else _latest_published_muse_version() |
| 182 | return _INSTALL_TEMPLATE.format(musehub_url=safe_url, version=resolved_version) |
| 183 | |
| 184 | def generate_uninstall_script() -> str: |
| 185 | """Return the uninstall shell script (no substitutions required).""" |
| 186 | return _UNINSTALL_TEMPLATE |
| 187 | |
| 188 | def _base_url(request: Request) -> str: |
| 189 | """Return the base URL, honouring X-Forwarded-Proto set by Cloudflare/nginx. |
| 190 | |
| 191 | Without this, requests arriving via HTTPS at Cloudflare are forwarded to |
| 192 | the origin over HTTP, causing ``request.base_url`` to report ``http://`` |
| 193 | and the generated install script to download the sdist over plain HTTP. |
| 194 | """ |
| 195 | proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() |
| 196 | url = str(request.base_url).rstrip("/") |
| 197 | if proto == "https" and url.startswith("http://"): |
| 198 | url = f"https://{url[len('http://'):]}" |
| 199 | return url |
| 200 | |
| 201 | @router.get( |
| 202 | "/install.sh", |
| 203 | summary="Download the Muse CLI install script", |
| 204 | response_class=PlainTextResponse, |
| 205 | ) |
| 206 | async def get_install_script(request: Request) -> PlainTextResponse: |
| 207 | """Return a POSIX shell script that installs the Muse CLI. |
| 208 | |
| 209 | The script creates a Python 3.14 venv at ``~/.local/share/muse/venv``, |
| 210 | downloads the muse source distribution from this MuseHub instance, and |
| 211 | installs it via pip. A symlink is placed at ``~/.local/bin/muse``. |
| 212 | Running the script a second time is safe — the venv is recreated in place. |
| 213 | """ |
| 214 | musehub_url = _base_url(request) |
| 215 | try: |
| 216 | script = generate_install_script(musehub_url) |
| 217 | except ValueError as exc: |
| 218 | raise HTTPException( |
| 219 | status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) |
| 220 | ) from exc |
| 221 | return PlainTextResponse(content=script, media_type="text/x-sh") |
| 222 | |
| 223 | @router.get( |
| 224 | "/uninstall.sh", |
| 225 | summary="Download the Muse CLI uninstall script", |
| 226 | response_class=PlainTextResponse, |
| 227 | ) |
| 228 | async def get_uninstall_script() -> PlainTextResponse: |
| 229 | """Return a POSIX shell script that removes the Muse CLI venv and symlink. |
| 230 | |
| 231 | ``~/.muse`` (the local object store and configuration) is **never** |
| 232 | deleted by this script — only the venv and bin symlink are removed. |
| 233 | """ |
| 234 | return PlainTextResponse(content=generate_uninstall_script(), media_type="text/x-sh") |
File History
3 commits
sha256:5477f1954a99236e4733341d917e62e937a85a10301326b7fb8296aff596a666
fix: strip .tar.gz not just .gz when extracting version fro…
Sonnet 4.6
48 days ago
sha256:d1c7650d416853a700e08cf62546290b99e80705d1d7ac227906ebd8fb17682f
fix: use mtime not semver to find latest muse tarball in in…
Sonnet 4.6
48 days ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505
fix: install.sh version from latest published tarball, not …
Sonnet 4.6
minor
⚠
48 days ago