gabriel / musehub public
install.py python
234 lines 9.2 KB
Raw
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