From 0b97a45d8bb9d03a7d5e8d0f3fca5bfe034f4c63 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 1/7] build: declare Rust 1.91 as the minimum version Duration::from_mins needs it, and without the field cargo on an older toolchain fails on the edition instead of naming the version it wants. --- Cargo.toml | 1 + soulseek-rs-lib/Cargo.toml | 1 + soulseek-rs/Cargo.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 310dc01..47a88b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "19.0.0" authors = ["Michel de Graaf"] edition = "2024" +rust-version = "1.91" license = "MIT" repository = "https://github.com/michel/soulseek-rs" homepage = "https://re-invention.nl/soulseek-rs/" diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml index ebc7d14..659c074 100644 --- a/soulseek-rs-lib/Cargo.toml +++ b/soulseek-rs-lib/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs-lib" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 1465749..23cd1f6 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true From d71172542cdab4df02cab21b880eed7178f74c72 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 2/7] feat(cli): let a build name its own version A nightly printed the same 19.0.0 as the release it followed, so a bug report could not say which one it came from. SOULSEEK_RS_VERSION at compile time replaces the crate version in --version, the TUI and the daemon status. --- soulseek-rs/src/cli.rs | 7 ++++++- soulseek-rs/src/daemon/server/mod.rs | 4 ++-- soulseek-rs/src/ui/downloads.rs | 3 +-- soulseek-rs/src/ui/panes/results_pane.rs | 3 +-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 8c5efd0..196a3c3 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -34,11 +34,16 @@ Exit codes: 0 success 2 usage/config 3 connect or login 4 no results 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; +pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + #[derive(Parser, Debug, Default)] #[command( name = "soulseek-rs", author, - version, + version = VERSION, about = "Soulseek client in Rust 🦀", long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ interactive TUI, or use a subcommand for a scriptable \ diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index ca7fe3c..201a6d0 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -522,7 +522,7 @@ impl Daemon { fn identify(&self) -> AuthResult { AuthResult { protocol: PROTOCOL_VERSION, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), username: self.session.username(), } } @@ -532,7 +532,7 @@ impl Daemon { DaemonStatus { username: self.session.username(), server: self.server.clone(), - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), protocol: PROTOCOL_VERSION, listen_port: self.session.listen_port(), shared_folders: folders, diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index f79f718..24eec71 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,3 +1,4 @@ +use crate::cli::VERSION; use crate::models::DownloadEntry; use crate::ui::{ BYTES_PER_MB, accent_style, dimmed_style, error_style, @@ -14,8 +15,6 @@ use ratatui::{ use soulseek_rs::DownloadStatus; use std::fmt::Write as _; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - /// Bytes transferred, bytes expected and combined speed across the downloads /// that are actually running — or `None` when none are, so the caller can hide /// the progress readout entirely. diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 1b71f59..e111655 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,4 +1,5 @@ use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::cli::VERSION; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, @@ -13,8 +14,6 @@ use ratatui::{ }; use std::collections::HashSet; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - // The file name and its folder share whatever width the fixed columns leave, // the name getting the larger part: it is what a search is after, and a // folder's tail is a scroll away. From 14491768a5815c4c77b32b739b8266da41e88e80 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 3/7] feat(cli): print completion scripts and the man page to stdout `completions install` writes into a home directory, which a package build does not have. `completions print ` and `man` give it the files to put under /usr/share instead. --- Cargo.lock | 31 +++++++++++++++++++------ soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/SKILL.md | 3 +++ soulseek-rs/src/cli.rs | 9 +++++++ soulseek-rs/src/commands/completions.rs | 25 ++++++++++++++++++-- soulseek-rs/src/commands/mod.rs | 3 ++- soulseek-rs/src/run.rs | 11 ++++++--- soulseek-rs/tests/cli_e2e.rs | 22 ++++++++++++++++++ 8 files changed, 92 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e316a04..d57a443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -508,14 +508,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] @@ -524,6 +524,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -2251,6 +2261,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2550,6 +2566,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "directories", "dotenv", diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 23cd1f6..f40af90 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -30,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap_complete = "4.6.7" +clap_mangen = "0.3.3" ratatui = "0.30.2" unicode-width = "0.2" color-eyre = "0.6.5" diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2dc21fc..387db6d 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`, completion script where bash, zsh, or fish looks for it, or take it back again: `shell`, `path`, `action`. For a human at a prompt, not for you. +**`completions print `** and **`man`** — the completion script or the +man page on stdout, raw rather than records. For whoever builds a package. + ## Idioms ```bash diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 196a3c3..37b3270 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -249,6 +249,9 @@ pub enum Commands { /// Tab completion for bash, zsh, and fish #[command(subcommand)] Completions(CompletionsCommand), + + /// Print the man page to stdout, for packaging + Man, } #[derive(Args, Debug, Default)] @@ -315,6 +318,12 @@ pub enum CompletionsCommand { /// Remove the completion script again Uninstall(CompletionsArgs), + + /// Print the completion script to stdout, for packaging + Print { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Args, Debug)] diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs index d6ca9ef..cd5582c 100644 --- a/soulseek-rs/src/commands/completions.rs +++ b/soulseek-rs/src/commands/completions.rs @@ -8,10 +8,10 @@ use super::{unwritable, xdg}; use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; -use crate::output::{CliError, CliResult, CompletionRecord, Out}; +use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out}; use clap::CommandFactory; use directories::UserDirs; -use std::io::Write; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; /// The command being completed, and the file name the scripts are written as. @@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult { match command { CompletionsCommand::Install(args) => install(out, &targets(args)?), CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + CompletionsCommand::Print { shell } => print(&script(*shell)), + } +} + +pub fn man() -> CliResult { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust")) + .render(&mut page) + .expect("a Vec accepts every write"); + print(&page) +} + +fn print(bytes: &[u8]) -> CliResult { + match std::io::stdout().write_all(bytes) { + Err(error) if error.kind() != ErrorKind::BrokenPipe => { + Err(CliError::new( + Exit::Failure, + format!("cannot write stdout: {error}"), + )) + } + _ => Ok(()), } } diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index c5a009e..65192e9 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { | Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) - | Commands::Completions(_) => { + | Commands::Completions(_) + | Commands::Man => { Err(CliError::usage("handled without a session; see main::run")) } } diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index eeecf45..0d653b3 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { } } + match &cli.command { + Some(Commands::Completions(command)) => { + return crate::commands::completions::run(out, command); + } + Some(Commands::Man) => return crate::commands::completions::man(), + _ => {} + } + let config_path = config_path(&cli); let file_config = match &config_path { Some(path) => crate::persist::config::FileConfig::load(path) @@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { resolved.username.as_deref(), ); } - Commands::Completions(ref command) => { - return crate::commands::completions::run(out, command); - } Commands::Config(ConfigCommand::Path) => { return crate::commands::settings::config_path(out, &store); } diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index efea975..6a44531 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() { assert_eq!(action(&again), "absent"); } +#[test] +fn a_package_build_reads_completions_and_the_man_page_from_stdout() { + let dir = Scratch::new("package-build"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "not = [toml").expect("a config file"); + let broken = config.display().to_string(); + + for (shell, marker) in [ + ("bash", "complete -F"), + ("zsh", "#compdef soulseek-rs"), + ("fish", "complete -c soulseek-rs"), + ] { + let output = run(&["--config", &broken, "completions", "print", shell]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(marker), "{shell} script"); + } + + let man = run(&["--config", &broken, "man"]); + assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man)); + assert!(stdout(&man).contains(".TH soulseek-rs 1")); +} + // --- wishlist, the half that only touches the config file ------------------ #[test] From 645d24e29de4bc3f765ce03d9941ffb639824a28 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 4/7] ci(nightly): stamp each build with its commit The version reads 19.0.0+git.: later than the release it follows, earlier than the next, and ordered among nightlies. --- .github/workflows/nightly.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 005f468..e86af72 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,6 +24,7 @@ jobs: timeout-minutes: 5 outputs: sha: ${{ steps.commit.outputs.sha }} + version: ${{ steps.commit.outputs.version }} steps: # workflow_dispatch has a branch selector, but a Nightly run always means # develop. Resolve it once so every target builds the identical commit @@ -36,13 +37,22 @@ jobs: - name: Record source commit id: commit shell: bash - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M) + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]] + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT" binaries: name: ${{ matrix.target }} needs: source runs-on: ${{ matrix.os }} timeout-minutes: 45 + env: + SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }} strategy: fail-fast: false matrix: @@ -219,9 +229,11 @@ jobs: smoke-test: name: Install published nightly - needs: publish + needs: [source, publish] runs-on: ubuntu-latest timeout-minutes: 5 + env: + VERSION: ${{ needs.source.outputs.version }} steps: - uses: actions/checkout@v4 with: @@ -233,7 +245,7 @@ jobs: SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install run: | sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" - name: Install with wget and no curl on PATH env: @@ -246,4 +258,5 @@ jobs: ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" done PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + From f09d550e4b17389d434b21b67bb6283efc5f14e6 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 5/7] ci: attach .deb and .rpm packages to releases and nightlies Built by nfpm from the static musl binaries, with the man page and the bash, zsh and fish completions. The names carry no version, so releases/latest/download/soulseek-rs-amd64.deb stays a working link; the nightly pair replaces itself on the rolling prerelease. --- .github/workflows/nightly.yml | 29 ++++++++++++++ .github/workflows/packaging.yml | 67 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 10 +++++ RELEASE.md | 17 +++++++-- packaging/nfpm.yaml | 55 +++++++++++++++++++++++++++ scripts/package-linux.sh | 54 ++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/packaging.yml create mode 100644 packaging/nfpm.yaml create mode 100755 scripts/package-linux.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e86af72..4a5c9ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -105,6 +105,12 @@ jobs: dry-run: true dry-run-intended: true + - name: Build .deb and .rpm + if: endsWith(matrix.target, '-musl') + env: + TARGET: ${{ matrix.target }} + run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly + - name: Store packaged binary uses: actions/upload-artifact@v4 with: @@ -112,6 +118,8 @@ jobs: path: | ${{ steps.package.outputs.tar || steps.package.outputs.zip }} ${{ steps.package.outputs.sha256 }} + soulseek-rs-nightly-*.deb + soulseek-rs-nightly-*.rpm if-no-files-found: error retention-days: 1 @@ -147,6 +155,10 @@ jobs: soulseek-rs-nightly-aarch64-apple-darwin.sha256 soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + soulseek-rs-nightly-amd64.deb + soulseek-rs-nightly-arm64.deb + soulseek-rs-nightly-x86_64.rpm + soulseek-rs-nightly-aarch64.rpm ) printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" @@ -234,6 +246,7 @@ jobs: timeout-minutes: 5 env: VERSION: ${{ needs.source.outputs.version }} + DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly steps: - uses: actions/checkout@v4 with: @@ -260,3 +273,19 @@ jobs: PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + - name: Install the .deb + run: | + curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb" + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm fedora:latest sh -ec " + dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm' + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..99da052 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,67 @@ +name: Packaging + +on: + pull_request: + paths: + - 'Cargo.lock' + - 'Cargo.toml' + - 'packaging/**' + - 'scripts/package-linux.sh' + - 'soulseek-rs/src/cli.rs' + - 'soulseek-rs/src/run.rs' + - 'soulseek-rs/src/commands/**' + - '.github/workflows/packaging.yml' + +permissions: + contents: read + +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + VERSION: 0.0.0+git202601010000.0000000 + +jobs: + packages: + name: .deb and .rpm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build the packages + env: + SOULSEEK_RS_VERSION: ${{ env.VERSION }} + TARGET: x86_64-unknown-linux-musl + run: | + cargo build --locked --release --target "$TARGET" --package soulseek-rs + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly + + - name: Install the .deb + run: | + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec " + dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac7f993..a7bbc65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,6 +200,16 @@ jobs: ref: refs/tags/${{ needs.release.outputs.tag }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and upload .deb and .rpm + if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist + gh release upload "$TAG" dist/* --clobber + # The tap regenerates its formula from whatever `gh release view` calls latest, # so it only needs a nudge once every archive and .sha256 is on the release — # hence `needs: binaries` rather than firing alongside it. The tap also polls diff --git a/RELEASE.md b/RELEASE.md index b6356d0..941db0f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,6 +28,11 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | + - packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`, + `soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the + bash, zsh and fish completions. The names carry no version, so + `releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working, + - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, - merges `master` back into `develop`, so the branch the work happens on carries the @@ -44,9 +49,15 @@ conflict. `.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every day. It resolves the commit once, builds the same six targets as a numbered -release, verifies all twelve archives and checksum files as one set, then -replaces the mutable `nightly` prerelease and tag. The published release is -smoke-tested through both the curl and wget installer paths. +release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`, +verifies all sixteen files as one set, then replaces the mutable `nightly` +prerelease and tag. Every nightly binary reports `X.Y.Z+git.` +instead of the crate version. The published release is smoke-tested through the +curl and wget installer paths, and by installing the `.deb` and the `.rpm`. + +Never enable GitHub's Immutable Releases on this repository: nightly replaces +its assets and moves its tag, and a tag name burnt by that setting cannot be +reused. Use **Actions → Nightly → Run workflow** to force a build. The workflow always checks out `develop`, regardless of the ref selected in the dispatch form. The diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..1493db5 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,55 @@ +name: soulseek-rs +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +version_schema: none +release: "1" +section: net +priority: optional +maintainer: Michel de Graaf +description: | + Soulseek client for the terminal + A TUI, a scriptable command line and a daemon for the Soulseek + peer-to-peer network. +vendor: Michel de Graaf +homepage: https://re-invention.nl/soulseek-rs/ +license: MIT +suggests: + - gnome-keyring +contents: + - src: target/nfpm/soulseek-rs + dst: /usr/bin/soulseek-rs + file_info: + mode: 0755 + - src: target/nfpm/soulseek-rs.1.gz + dst: /usr/share/man/man1/soulseek-rs.1.gz + - src: target/nfpm/soulseek-rs.bash + dst: /usr/share/bash-completion/completions/soulseek-rs + - src: target/nfpm/soulseek-rs.fish + dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/vendor-completions/_soulseek-rs + packager: deb + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/site-functions/_soulseek-rs + packager: rpm + - dst: /usr/share/doc/soulseek-rs + type: dir + packager: rpm + - dst: /usr/share/licenses/soulseek-rs + type: dir + packager: rpm + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: deb + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: rpm + type: doc + - src: LICENSE + dst: /usr/share/doc/soulseek-rs/copyright + packager: deb + - src: LICENSE + dst: /usr/share/licenses/soulseek-rs/LICENSE + packager: rpm + type: license diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..8216098 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +umask 022 + +[ $# -ge 4 ] || { + echo "usage: $0 []" >&2 + exit 2 +} + +NFPM_VERSION=2.47.0 + +binary=$1 +PKG_VERSION=$2 +target=$3 +out=$4 +package=${5:-soulseek-rs} + +case "$target" in + x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;; + aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;; + *) echo "no package architecture for $target" >&2; exit 1 ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;; + Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;; + *) echo "no pinned nfpm build for this machine" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +stage=$root/target/nfpm +rm -rf "$stage" +mkdir -p "$stage" "$out" +out=$(cd "$out" && pwd) + +cp "$binary" "$stage/soulseek-rs" +"$binary" completions print bash > "$stage/soulseek-rs.bash" +"$binary" completions print zsh > "$stage/_soulseek-rs" +"$binary" completions print fish > "$stage/soulseek-rs.fish" +"$binary" man > "$stage/soulseek-rs.1" +gzip -9n "$stage/soulseek-rs.1" + +archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz +curl -fsSL -o "$work/$archive" \ + "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive" +echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null +tar -xzf "$work/$archive" -C "$work" nfpm + +export PKG_ARCH PKG_VERSION +cd "$root" +"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb" +"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm" From 4c5a808762db62b6849d3902ea2b3827b143c238 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 6/7] build(nix): add a flake nix run github:michel/soulseek-rs/master builds the release branch and /develop the nightly. The lock is not optional: without one nix refuses a github: reference outright and hides the commit from the build, so the version stamp read +dirty. --- .github/workflows/packaging.yml | 20 +++++++++++++ flake.lock | 27 +++++++++++++++++ flake.nix | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 99da052..bb1b304 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -3,6 +3,8 @@ name: Packaging on: pull_request: paths: + - 'flake.nix' + - 'flake.lock' - 'Cargo.lock' - 'Cargo.toml' - 'packaging/**' @@ -65,3 +67,21 @@ jobs: rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs " + + flake: + name: Nix flake + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@v31 + + - name: Build and run + run: | + nix build --print-build-logs + ./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$' + test -s result/share/man/man1/soulseek-rs.1.gz + test -s result/share/fish/vendor_completions.d/soulseek-rs.fish diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c954c07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + in + { + packages = lib.genAttrs systems (system: { + default = nixpkgs.legacyPackages.${system}.callPackage ( + { rustPlatform, installShellFiles }: + rustPlatform.buildRustPackage { + pname = "soulseek-rs"; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ + "--package" + "soulseek-rs" + ]; + doCheck = false; + nativeBuildInputs = [ installShellFiles ]; + env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}"; + postInstall = '' + installShellCompletion --cmd soulseek-rs \ + --bash <($out/bin/soulseek-rs completions print bash) \ + --zsh <($out/bin/soulseek-rs completions print zsh) \ + --fish <($out/bin/soulseek-rs completions print fish) + $out/bin/soulseek-rs man > soulseek-rs.1 + installManPage soulseek-rs.1 + ''; + meta = { + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + homepage = "https://re-invention.nl/soulseek-rs/"; + license = lib.licenses.mit; + mainProgram = "soulseek-rs"; + }; + } + ) { }; + }); + }; +} From b6b4ec04c159edcd9cd49a2c3873b8d5596b61f1 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 21 Sep 2026 12:15:43 +0200 Subject: [PATCH 7/7] docs: install by system, stable and nightly --- README.md | 61 +++++++- website/src/lib/links.ts | 4 + website/src/pages/install-routes.tsx | 218 +++++++++++++++++++++++++++ website/src/pages/install.tsx | 100 ++---------- 4 files changed, 290 insertions(+), 93 deletions(-) create mode 100644 website/src/pages/install-routes.tsx diff --git a/README.md b/README.md index db9eb9d..bf7808b 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,66 @@ numbered release. A failed download, checksum, or startup check leaves an existing binary untouched. Windows archives for both channels are on the [releases page](https://github.com/michel/soulseek-rs/releases). -The other stable routes need no installer script: +Or install it with a package manager. Stable follows the latest release: ```bash -brew install michel/tap/soulseek-rs # macOS and Linux -cargo install soulseek-rs # or from crates.io +# Debian, Ubuntu, Mint (on ARM: arm64 for amd64) +curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb +sudo apt install ./soulseek-rs-amd64.deb + +# Fedora, RHEL (on ARM: aarch64 for x86_64) +sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm + +# Nix +nix run github:michel/soulseek-rs/master + +# Homebrew, on macOS and Linux +brew install michel/tap/soulseek-rs + +# cargo-binstall fetches the release binary and compiles nothing +cargo binstall soulseek-rs + +# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one +cargo install --locked soulseek-rs ``` +Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix, +Homebrew and cargo build it from source: + +```bash +curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb +sudo apt install ./soulseek-rs-nightly-amd64.deb + +sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm + +nix run github:michel/soulseek-rs/develop + +brew install --HEAD michel/tap/soulseek-rs + +cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs +``` + +A `.deb` or `.rpm` does not update itself. Install the newer file over it. A +nightly sorts above the release it follows, so going back to stable is a +downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf +goes ahead. Both carry the man page and completions for bash, zsh and fish. +A release download prints the plain number, `19.0.0`. Any Nix build, and a +nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the +commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports +`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints +`19.0.0` too. + From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, run `cargo add soulseek-rs-lib`. +To package it for a distribution: a musl build is static, a glibc build links +only libc, libm and libgcc_s, +`soulseek-rs completions print ` and `soulseek-rs man` write the +completion scripts and the man page to stdout, and a build with +`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm +recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging). + ## Usage Run it with no subcommand for the interactive TUI: @@ -332,6 +381,8 @@ soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping soulseek-rs skills install|uninstall|list # teach a coding agent this CLI soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish +soulseek-rs completions print # the script on stdout, for packaging +soulseek-rs man # the man page on stdout ``` A script usually starts with `whoami`, which answers "are these credentials @@ -343,9 +394,9 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Twenty need no credentials: `config path|list|get|set`, +Twenty-two need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall`, +`skills install|uninstall|list`, `completions install|uninstall|print`, `man`, `account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index fdb4f37..54385ed 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -29,3 +29,7 @@ export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` export const NIGHTLY_INSTALL_CMD = 'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly' + +export const STABLE_DOWNLOAD = `${LINKS.releases}/latest/download` + +export const NIGHTLY_DOWNLOAD = `${LINKS.releases}/download/nightly` diff --git a/website/src/pages/install-routes.tsx b/website/src/pages/install-routes.tsx new file mode 100644 index 0000000..2aa1ffc --- /dev/null +++ b/website/src/pages/install-routes.tsx @@ -0,0 +1,218 @@ +import { ExtLink } from '@/components/ui/ext-link' +import { Code } from '@/components/ui/inline-code' +import { OsIcon, type OsName } from '@/components/ui/os-icon' +import { Terminal, type TermLine } from '@/components/ui/terminal' +import { LINKS, NIGHTLY_DOWNLOAD, STABLE_DOWNLOAD } from '@/lib/links' + +interface InstallRoute { + id: string + os: OsName + best: string + lines: readonly TermLine[] + alt: React.ReactNode +} + +// aislop-ignore-file code-quality/duplicate-block -- data tables: the INSTALLS and LINUX_ROUTES records repeat the field names their interfaces require. The rule matches punctuation, not logic. +const INSTALLS: readonly InstallRoute[] = [ + { + id: 'macos', + os: 'macOS', + best: 'Homebrew', + lines: [ + { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a + toolchain, cargo install --locked soulseek-rs builds from crates.io instead. + + ), + }, + { + id: 'linux', + os: 'Linux', + best: 'your package manager', + lines: [ + { t: 'cm', text: '# Debian and Ubuntu; the others are below' }, + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + Fedora, Nix, Homebrew and cargo are{' '} + + listed below + + , each with a nightly. Without any of them, the{' '} + releases page has static musl archives for x86-64 + and arm64 that run on any distribution. + + ), + }, + { + id: 'windows', + os: 'Windows', + best: 'cargo or a prebuilt .exe', + lines: [ + { t: 'cmd', text: 'cargo install --locked soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs.exe' }, + ], + alt: ( + <> + Cargo needs the MSVC build tools. To skip both, take the{' '} + pc-windows-msvc zip from the{' '} + releases page, unpack it, and put{' '} + soulseek-rs.exe on your PATH. + + ), + }, +] + +interface LinuxRoute { + id: string + name: string + stable: readonly TermLine[] + nightly: readonly TermLine[] + note: React.ReactNode +} + +const LINUX_ROUTES: readonly LinuxRoute[] = [ + { + id: 'deb', + name: 'Debian, Ubuntu, Mint', + stable: [ + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + ], + nightly: [ + { t: 'cmd', text: `curl -fsSLO ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-nightly-amd64.deb' }, + ], + note: ( + <> + On ARM, arm64 takes the place of amd64. There is no apt + repository behind this, so an update means installing the newer file over the old one. + A nightly sorts above the release it follows, so going back to stable is a downgrade, + and apt asks before doing it. + + ), + }, + { + id: 'rpm', + name: 'Fedora, RHEL', + stable: [{ t: 'cmd', text: `sudo dnf install ${STABLE_DOWNLOAD}/soulseek-rs-x86_64.rpm` }], + nightly: [ + { t: 'cmd', text: `sudo dnf install ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-x86_64.rpm` }, + ], + note: ( + <> + On ARM, aarch64 takes the place of x86_64. Run the same + command again to update. dnf fetches the file each time, so a newer nightly at the same + address is picked up. An older file downgrades without asking. + + ), + }, + { + id: 'nix', + name: 'Nix', + stable: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/master' }], + nightly: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/develop' }], + note: ( + <> + Both build from source. With no branch at all, Nix takes the repository’s + default one, and here that is develop. + + ), + }, + { + id: 'brew', + name: 'Homebrew', + stable: [{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }], + nightly: [{ t: 'cmd', text: 'brew install --HEAD michel/tap/soulseek-rs' }], + note: ( + <> + Homebrew installs under /home/linuxbrew, outside the image that a system + update replaces on Bazzite and Silverblue. --HEAD compiles{' '} + develop with Homebrew’s own Rust. + + ), + }, + { + id: 'cargo', + name: 'cargo', + stable: [{ t: 'cmd', text: 'cargo install --locked soulseek-rs' }], + nightly: [ + { + t: 'cmd', + text: 'cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs', + }, + ], + note: ( + <> + Needs Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one, so take + the toolchain from rustup.rs.{' '} + cargo binstall soulseek-rs fetches the release binary and compiles nothing. + + ), + }, +] + +export const InstallCards = () => ( +
+ {INSTALLS.map((route) => ( +
+
+ +

+ {route.os} +

+
+ + {route.best} + + +

+ {route.alt} +

+
+ ))} +
+) + +export const LinuxRoutes = () => ( +
+

+ Linux, by distribution +

+

+ Stable is the latest release, or for Nix the master branch it was cut + from. Nightly is the tip of develop, rebuilt every day. A release download + prints the plain number, 19.0.0. Any Nix build, and a nightly download, + reads 19.0.0+git202609141144.deb846b: the last release, the + commit’s time in UTC, and the commit. A Homebrew --HEAD build + reports HEAD-deb846b, and a cargo install from git sets no + version, so it prints 19.0.0 too. The .deb and .rpm + carry the man page and completions for bash, zsh and fish. +

+ {LINUX_ROUTES.map((route) => ( +
+ + {route.name} + +
+ + +
+

+ {route.note} +

+
+ ))} +
+) diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index f17cf70..9e03fb0 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -3,10 +3,10 @@ import { FeatureCard } from '@/components/ui/feature-card' import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout' -import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' +import { InstallCards, LinuxRoutes } from '@/pages/install-routes' const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ { t: 'cm', text: '# latest stable release' }, @@ -36,66 +36,6 @@ const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({ text, })) -interface InstallRoute { - id: string - os: OsName - best: string - lines: readonly TermLine[] - alt: React.ReactNode -} - -// aislop-ignore-file code-quality/duplicate-block -- three findings here, all data tables: the INSTALLS records repeat the field names InstallRoute requires, and the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. -const INSTALLS: readonly InstallRoute[] = [ - { - id: 'macos', - os: 'macOS', - best: 'Homebrew', - lines: [ - { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a - toolchain, cargo install soulseek-rs builds from crates.io instead. - - ), - }, - { - id: 'linux', - os: 'Linux', - best: 'cargo', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - Homebrew on Linux works the same as on macOS. Without either, the{' '} - releases page has static musl archives for x86-64 and - arm64 that run on any distribution. - - ), - }, - { - id: 'windows', - os: 'Windows', - best: 'cargo or a prebuilt .exe', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs.exe' }, - ], - alt: ( - <> - Cargo needs the MSVC build tools. To skip both, take the{' '} - pc-windows-msvc zip from the{' '} - releases page, unpack it, and put{' '} - soulseek-rs.exe on your PATH. - - ), - }, -] - interface PlatformNote { id: string name: string @@ -291,32 +231,12 @@ const Steps = () => ( place.

- Or pick a route per platform. Cargo needs a Rust toolchain from{' '} - rustup.rs; the other routes don’t. + Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '} + rustup.rs. Nix and Homebrew{' '} + --HEAD also build from source, but bring their own Rust.

-
- {INSTALLS.map((route) => ( -
-
- -

- {route.os} -

-
- - {route.best} - - -

- {route.alt} -

-
- ))} -
+ +

Every route puts the same binary on your PATH. Run soulseek-rs in a terminal and the TUI opens, in a script or a pipe it wants a subcommand @@ -522,11 +442,13 @@ const Config = () => ( ) +// aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. const Uninstall = () => (

Three things exist on disk: the binary, the config, and the state, plus the completion - scripts and the agent skill if you asked for those. Nothing else, unless you point{' '} + scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page + and its own completions, and takes them back when removed. Nothing else, unless you point{' '} --log-file somewhere. No telemetry to opt out of. @@ -539,6 +461,8 @@ const Uninstall = () => ( { t: 'cm', text: '# whichever way you installed it' }, { t: 'cmd', text: 'brew uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' }, + { t: 'cmd', text: 'sudo apt remove soulseek-rs' }, + { t: 'cmd', text: 'sudo dnf remove 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' }, @@ -581,7 +505,7 @@ const Uninstall = () => ( export const Install = () => ( <> - One command with the install script, Homebrew or cargo. Build from source if + One command with the install script, your package manager, Homebrew or cargo. Build from source if you’d rather. Or depend on the library and write your own client.