feat(website): serve an install script that picks brew or the release binary
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh replaces the brew one-liner as the front-page command: it works on Linux without Homebrew by downloading the latest release archive for the platform, verifying its sha256 and installing to /usr/local/bin or ~/.local/bin. Windows still gets pointed at the msvc zip. scripts/test-install.sh covers target detection, the brew fast path, checksum failure and real installs over curl and wget.
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs the latest soulseek-rs release.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh
|
||||||
|
#
|
||||||
|
# Uses Homebrew when it is on the PATH (macOS and Linux), otherwise downloads
|
||||||
|
# the prebuilt binary for this platform from GitHub releases, verifies its
|
||||||
|
# sha256, and puts it in /usr/local/bin or ~/.local/bin.
|
||||||
|
#
|
||||||
|
# SOULSEEK_RS_INSTALL_DIR overrides where the binary lands.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REPO="michel/soulseek-rs"
|
||||||
|
|
||||||
|
say() { printf '%s\n' "$*"; }
|
||||||
|
die() {
|
||||||
|
printf 'install.sh: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) os="apple-darwin" ;;
|
||||||
|
Linux) os="unknown-linux-musl" ;;
|
||||||
|
MINGW* | MSYS* | CYGWIN* | Windows_NT)
|
||||||
|
die "no install script for Windows: take the pc-windows-msvc zip from https://github.com/$REPO/releases/latest, or run 'cargo install soulseek-rs'" ;;
|
||||||
|
*) die "unsupported OS '$(uname -s)': see https://github.com/$REPO/releases/latest" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64) arch="x86_64" ;;
|
||||||
|
arm64 | aarch64) arch="aarch64" ;;
|
||||||
|
*) die "unsupported architecture '$(uname -m)': releases cover x86_64 and aarch64" ;;
|
||||||
|
esac
|
||||||
|
target="$arch-$os"
|
||||||
|
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
say "Homebrew found: installing michel/tap/soulseek-rs (brew also handles upgrades)"
|
||||||
|
exec brew install michel/tap/soulseek-rs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
fetch() { curl -fsSL "$1"; }
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
fetch() { wget -qO- "$1"; }
|
||||||
|
else
|
||||||
|
die "need curl or wget"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" |
|
||||||
|
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
|
[ -n "$tag" ] || die "could not resolve the latest release; downloads are at https://github.com/$REPO/releases/latest"
|
||||||
|
|
||||||
|
archive="soulseek-rs-$tag-$target.tar.gz"
|
||||||
|
base_url="https://github.com/$REPO/releases/download/$tag"
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
say "Downloading $archive"
|
||||||
|
fetch "$base_url/$archive" >"$tmp/$archive"
|
||||||
|
fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256"
|
||||||
|
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
got=$(sha256sum "$tmp/$archive")
|
||||||
|
else
|
||||||
|
got=$(shasum -a 256 "$tmp/$archive")
|
||||||
|
fi
|
||||||
|
got=${got%% *}
|
||||||
|
want=$(cut -d' ' -f1 <"$tmp/expected.sha256")
|
||||||
|
[ "$got" = "$want" ] || die "sha256 mismatch for $archive: expected $want, got $got"
|
||||||
|
|
||||||
|
tar -xzf "$tmp/$archive" -C "$tmp"
|
||||||
|
[ -f "$tmp/soulseek-rs" ] || die "archive $archive did not contain a soulseek-rs binary"
|
||||||
|
|
||||||
|
dir="${SOULSEEK_RS_INSTALL_DIR:-}"
|
||||||
|
if [ -z "$dir" ]; then
|
||||||
|
dir="/usr/local/bin"
|
||||||
|
{ [ -d "$dir" ] && [ -w "$dir" ]; } || dir="$HOME/.local/bin"
|
||||||
|
fi
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
# mv over a copy, not cp over the target: replacing a running binary in place
|
||||||
|
# fails with "text file busy" on Linux.
|
||||||
|
cp "$tmp/soulseek-rs" "$dir/.soulseek-rs.new"
|
||||||
|
chmod 755 "$dir/.soulseek-rs.new"
|
||||||
|
mv -f "$dir/.soulseek-rs.new" "$dir/soulseek-rs"
|
||||||
|
|
||||||
|
say "Installed $("$dir/soulseek-rs" --version) to $dir/soulseek-rs"
|
||||||
|
case ":$PATH:" in
|
||||||
|
*:"$dir":*) ;;
|
||||||
|
*) say "note: $dir is not on your PATH; add it with: export PATH=\"$dir:\$PATH\"" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Exercises public/install.sh: target detection, the brew fast path, checksum
|
||||||
|
# verification, and a real install into a throwaway prefix. Network tests hit
|
||||||
|
# GitHub; run from a machine that can reach it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
script="$here/../public/install.sh"
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
report() {
|
||||||
|
if [ "$1" = 0 ]; then
|
||||||
|
printf 'ok %s\n' "$2"
|
||||||
|
else
|
||||||
|
printf 'FAIL %s\n' "$2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac; }
|
||||||
|
|
||||||
|
# Stub uname reads STUB_OS / STUB_ARCH; stub curl logs every URL it is asked
|
||||||
|
# for, answers the release-API call with a canned tag, and refuses the rest so
|
||||||
|
# a detection test stops before downloading anything.
|
||||||
|
mkdir -p "$tmp/stub"
|
||||||
|
cat >"$tmp/stub/uname" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
case "${1:-}" in
|
||||||
|
-m) echo "$STUB_ARCH" ;;
|
||||||
|
*) echo "$STUB_OS" ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
cat >"$tmp/stub/curl" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
for a; do url=\$a; done
|
||||||
|
echo "\$url" >>"$tmp/curl.log"
|
||||||
|
case "\$url" in
|
||||||
|
*api.github.com*) echo '{"tag_name": "v12.0.0"}' ;;
|
||||||
|
*) exit 22 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$tmp/stub/uname" "$tmp/stub/curl"
|
||||||
|
base_path="/usr/bin:/bin"
|
||||||
|
|
||||||
|
resolved_target() {
|
||||||
|
: >"$tmp/curl.log"
|
||||||
|
STUB_OS="$1" STUB_ARCH="$2" PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 || true
|
||||||
|
grep -o 'soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz' "$tmp/curl.log" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
for spec in \
|
||||||
|
"Darwin arm64 aarch64-apple-darwin" \
|
||||||
|
"Darwin x86_64 x86_64-apple-darwin" \
|
||||||
|
"Linux aarch64 aarch64-unknown-linux-musl" \
|
||||||
|
"Linux amd64 x86_64-unknown-linux-musl"; do
|
||||||
|
# spec is three space-separated fields, so the splitting is the point
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $spec
|
||||||
|
res=1
|
||||||
|
if [ "$(resolved_target "$1" "$2")" = "soulseek-rs-v12.0.0-$3.tar.gz" ]; then res=0; fi
|
||||||
|
report $res "$1/$2 resolves to $3"
|
||||||
|
done
|
||||||
|
|
||||||
|
out=$(STUB_OS="MINGW64_NT-10.0" STUB_ARCH=x86_64 PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" != 0 ] && contains "$out" "pc-windows-msvc"; then res=0; fi
|
||||||
|
report $res "Windows refuses and points at the msvc zip"
|
||||||
|
|
||||||
|
out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" != 0 ] && contains "$out" "unsupported architecture"; then res=0; fi
|
||||||
|
report $res "armv7l refuses as unsupported"
|
||||||
|
|
||||||
|
# Brew fast path: a stub brew must be exec'd with the tap formula, and no URL
|
||||||
|
# may be fetched.
|
||||||
|
cat >"$tmp/stub/brew" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
echo "\$*" >"$tmp/brew.log"
|
||||||
|
EOF
|
||||||
|
chmod +x "$tmp/stub/brew"
|
||||||
|
: >"$tmp/curl.log"
|
||||||
|
STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1
|
||||||
|
res=1
|
||||||
|
if [ "$(cat "$tmp/brew.log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$tmp/curl.log" ]; then res=0; fi
|
||||||
|
report $res "brew on PATH wins and skips the download"
|
||||||
|
rm -f "$tmp/stub/brew"
|
||||||
|
|
||||||
|
# Offline happy path: stub curl serves a locally built archive whose sha256
|
||||||
|
# matches, and the installed fake binary must answer --version.
|
||||||
|
mkdir -p "$tmp/payload" "$tmp/bin"
|
||||||
|
printf '#!/bin/sh\necho "soulseek-rs 99.0.0-test"\n' >"$tmp/payload/soulseek-rs"
|
||||||
|
chmod +x "$tmp/payload/soulseek-rs"
|
||||||
|
tar -czf "$tmp/payload.tar.gz" -C "$tmp/payload" soulseek-rs
|
||||||
|
sum=$(shasum -a 256 "$tmp/payload.tar.gz" | cut -d' ' -f1)
|
||||||
|
cat >"$tmp/stub/curl" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
for a; do url=\$a; done
|
||||||
|
echo "\$url" >>"$tmp/curl.log"
|
||||||
|
case "\$url" in
|
||||||
|
*api.github.com*) echo '{"tag_name": "v12.0.0"}' ;;
|
||||||
|
*.tar.gz) cat "$tmp/payload.tar.gz" ;;
|
||||||
|
*.sha256) echo "$sum *payload" ;;
|
||||||
|
*) exit 22 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \
|
||||||
|
PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" = 0 ] && [ -x "$tmp/bin/soulseek-rs" ] &&
|
||||||
|
contains "$out" "Installed soulseek-rs 99.0.0-test"; then res=0; fi
|
||||||
|
report $res "verified archive installs and reports the version"
|
||||||
|
res=1
|
||||||
|
if contains "$out" "not on your PATH"; then res=0; fi
|
||||||
|
report $res "install dir off the PATH gets the PATH note"
|
||||||
|
|
||||||
|
# Tampered archive: same stub but a wrong checksum must abort before install.
|
||||||
|
rm -f "$tmp/bin/soulseek-rs"
|
||||||
|
sed "s/$sum/0000000000000000000000000000000000000000000000000000000000000000/" \
|
||||||
|
"$tmp/stub/curl" >"$tmp/stub/curl.bad" && mv "$tmp/stub/curl.bad" "$tmp/stub/curl"
|
||||||
|
chmod +x "$tmp/stub/curl"
|
||||||
|
out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \
|
||||||
|
PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" != 0 ] && [ ! -e "$tmp/bin/soulseek-rs" ] &&
|
||||||
|
contains "$out" "sha256 mismatch"; then res=0; fi
|
||||||
|
report $res "sha256 mismatch aborts without installing"
|
||||||
|
|
||||||
|
# Real network installs: once over curl, once over wget (a symlink farm of
|
||||||
|
# /usr/bin and /bin minus curl and brew forces the wget code path).
|
||||||
|
out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-curl" PATH="$base_path" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" = 0 ] && "$tmp/real-curl/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi
|
||||||
|
report $res "real release installs via curl and runs"
|
||||||
|
|
||||||
|
if wget=$(command -v wget); then
|
||||||
|
mkdir -p "$tmp/farm"
|
||||||
|
for d in /usr/bin /bin; do
|
||||||
|
for f in "$d"/*; do ln -sf "$f" "$tmp/farm/${f##*/}"; done
|
||||||
|
done
|
||||||
|
rm -f "$tmp/farm/curl" "$tmp/farm/brew"
|
||||||
|
ln -sf "$wget" "$tmp/farm/wget"
|
||||||
|
out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-wget" PATH="$tmp/farm" sh "$script" 2>&1) && rc=0 || rc=$?
|
||||||
|
res=1
|
||||||
|
if [ "$rc" = 0 ] && "$tmp/real-wget/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi
|
||||||
|
report $res "real release installs via wget and runs"
|
||||||
|
else
|
||||||
|
echo "skip real release installs via wget (no wget on this machine)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$failures" = 0 ] || {
|
||||||
|
echo "$failures test(s) failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "all tests passed"
|
||||||
@@ -18,3 +18,5 @@ export const LINKS = {
|
|||||||
export const VERSION = __APP_VERSION__
|
export const VERSION = __APP_VERSION__
|
||||||
|
|
||||||
export const SITE_URL = 'https://re-invention.nl/soulseek-rs/'
|
export const SITE_URL = 'https://re-invention.nl/soulseek-rs/'
|
||||||
|
|
||||||
|
export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh`
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { Cols, Eyebrow, Prose, Section, SectionHead, Wrap } from '@/components/u
|
|||||||
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
||||||
import { Callout } from '@/components/ui/panel'
|
import { Callout } from '@/components/ui/panel'
|
||||||
import { Terminal } from '@/components/ui/terminal'
|
import { Terminal } from '@/components/ui/terminal'
|
||||||
import { LINKS } from '@/lib/links'
|
import { INSTALL_CMD, LINKS } from '@/lib/links'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
|
const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
|
||||||
{ name: 'macOS', note: 'brew or cargo', anchor: 'macos' },
|
{ name: 'macOS', note: 'script or brew', anchor: 'macos' },
|
||||||
{ name: 'Linux', note: 'cargo install', anchor: 'linux' },
|
{ name: 'Linux', note: 'script or cargo', anchor: 'linux' },
|
||||||
{ name: 'Windows', note: 'cargo or .exe', anchor: 'windows' },
|
{ name: 'Windows', note: 'cargo or .exe', anchor: 'windows' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ const Hero = () => (
|
|||||||
<Button href="/docs">Read the docs</Button>
|
<Button href="/docs">Read the docs</Button>
|
||||||
<Button href={LINKS.gh}>GitHub</Button>
|
<Button href={LINKS.gh}>GitHub</Button>
|
||||||
</div>
|
</div>
|
||||||
<CommandPill text="brew install michel/tap/soulseek-rs" />
|
<CommandPill text={INSTALL_CMD} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -327,9 +327,10 @@ const GetStarted = () => (
|
|||||||
<Terminal
|
<Terminal
|
||||||
label="quick start"
|
label="quick start"
|
||||||
lines={[
|
lines={[
|
||||||
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
{ t: 'cmd', text: INSTALL_CMD },
|
||||||
{ t: 'cmd', text: 'soulseek-rs' },
|
{ t: 'cmd', text: 'soulseek-rs' },
|
||||||
]}
|
]}
|
||||||
|
wrap
|
||||||
/>
|
/>
|
||||||
</Cols>
|
</Cols>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/lay
|
|||||||
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
||||||
import { Callout } from '@/components/ui/panel'
|
import { Callout } from '@/components/ui/panel'
|
||||||
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
||||||
import { LINKS } from '@/lib/links'
|
import { INSTALL_CMD, LINKS, SITE_URL } from '@/lib/links'
|
||||||
|
|
||||||
const LIB_SRC = `use soulseek_rs::Client;
|
const LIB_SRC = `use soulseek_rs::Client;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -266,7 +266,18 @@ const Steps = () => (
|
|||||||
<div className="flex flex-col gap-9">
|
<div className="flex flex-col gap-9">
|
||||||
<Step n={1} title="Install the client">
|
<Step n={1} title="Install the client">
|
||||||
<p className="text-secondary">
|
<p className="text-secondary">
|
||||||
One command per platform. Cargo needs a Rust toolchain from{' '}
|
One command on macOS and Linux. The script installs through Homebrew when you
|
||||||
|
have it; otherwise it downloads the latest release binary for your platform,
|
||||||
|
checks its sha256, and puts it on your PATH.
|
||||||
|
</p>
|
||||||
|
<Terminal lines={[{ t: 'cmd', text: INSTALL_CMD }]} wrap />
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
No curl? <Code>{`wget -qO- ${SITE_URL}install.sh | sh`}</Code> does the same.
|
||||||
|
Piping to a shell asks for trust; read the script first at{' '}
|
||||||
|
<ExtLink href={`${SITE_URL}install.sh`}>{`${SITE_URL}install.sh`}</ExtLink>.
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary">
|
||||||
|
Or pick a route per platform. Cargo needs a Rust toolchain from{' '}
|
||||||
<ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>; the other routes don’t.
|
<ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>; the other routes don’t.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
@@ -520,6 +531,8 @@ const Uninstall = () => (
|
|||||||
{ t: 'cm', text: '# whichever way you installed it' },
|
{ t: 'cm', text: '# whichever way you installed it' },
|
||||||
{ t: 'cmd', text: 'brew uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'brew uninstall soulseek-rs' },
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
|
{ t: 'cm', text: '# script install without brew: the binary itself' },
|
||||||
|
{ t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' },
|
||||||
{ t: 'cm', text: '# brew only: drop the tap it came from' },
|
{ t: 'cm', text: '# brew only: drop the tap it came from' },
|
||||||
{ t: 'cmd', text: 'brew untap michel/tap' },
|
{ t: 'cmd', text: 'brew untap michel/tap' },
|
||||||
{ t: 'cm', text: '# config and state: searches, downloads, rooms,' },
|
{ t: 'cm', text: '# config and state: searches, downloads, rooms,' },
|
||||||
@@ -560,8 +573,8 @@ const Uninstall = () => (
|
|||||||
export const Install = () => (
|
export const Install = () => (
|
||||||
<>
|
<>
|
||||||
<PageHead eyebrow="install" title="Three ways in.">
|
<PageHead eyebrow="install" title="Three ways in.">
|
||||||
One command with Homebrew or cargo. Build from source if you’d rather. Or depend
|
One command with the install script, Homebrew or cargo. Build from source if
|
||||||
on the library and write your own client.
|
you’d rather. Or depend on the library and write your own client.
|
||||||
</PageHead>
|
</PageHead>
|
||||||
<Steps />
|
<Steps />
|
||||||
<Reachable />
|
<Reachable />
|
||||||
|
|||||||
Reference in New Issue
Block a user