Files
-rpi-sendspin/third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md

28 KiB
Raw Blame History

Pi Quickstart Script Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship scripts/quickstart-pi.sh, a self-contained curl | sudo bash installer that turns a fresh 64-bit Raspberry Pi OS Lite host into a running, mDNS-discoverable sendspin-player daemon using the prebuilt arm64 release tarball.

Architecture: A single Bash script under set -euo pipefail with phase-named functions, fetched verbatim from raw.githubusercontent.com. Reuses every artifact already in the repo: the arm64 release tarball, dist/systemd/sendspin-player.service, dist/systemd/sendspin-player.env, and dist/config/player.example.yaml — all pulled at the resolved release tag's ref. No code changes inside pkg/ or cmd/. Per-task verification is shellcheck; final acceptance is a smoke run inside an arm64v8/debian:bookworm Docker container plus a manual Pi install.

Tech Stack: Bash 5.x, curl, tar, apt-get, systemctl, shellcheck (per-task verify), Docker with QEMU for arm64 emulation (final smoke test). No new runtime dependencies on the user's Pi beyond what apt-get installs.

Spec: docs/superpowers/specs/2026-05-01-quickstart-pi-design.md


File Structure

  • Create: scripts/quickstart-pi.sh — the entire installer (single file, all phases as functions, main() at the bottom)
  • Modify: .github/workflows/ci.yml — add a shellcheck step to the existing lint job
  • Modify: README.md — add a "Quickstart on Raspberry Pi" section near the existing install instructions

The script lives in scripts/ (a new directory) rather than the repo root because the root already hosts install-deps.sh (build-time deps, source-build users) — keeping the prebuilt-binary installer in scripts/ makes the distinction explicit.

Per-task commits use the feat(quickstart): scope on functional additions, chore(ci): for the lint step, and docs(readme): for the README change.

Prerequisite for the implementer: install shellcheck locally (brew install shellcheck / apt-get install shellcheck). Every task ends with a shellcheck invocation.


Task 1: Script skeleton with stubbed phases and --help

Files:

  • Create: scripts/quickstart-pi.sh

  • Step 1: Create the directory and write the skeleton

mkdir -p scripts

Write scripts/quickstart-pi.sh:

#!/usr/bin/env bash
# ABOUTME: One-shot installer for sendspin-player on 64-bit Raspberry Pi OS.
# ABOUTME: Fetches the latest arm64 release tarball, installs systemd unit, starts daemon.

set -euo pipefail

readonly REPO_OWNER="Sendspin"
readonly REPO_NAME="sendspin-go"
readonly REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}"
readonly RAW_URL_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
readonly BINARY_NAME="sendspin-player"
readonly INSTALL_PATH="/usr/local/bin/${BINARY_NAME}"
readonly UNIT_PATH="/etc/systemd/system/${BINARY_NAME}.service"
readonly ENV_PATH="/etc/default/${BINARY_NAME}"
readonly CONFIG_DIR="/etc/sendspin"
readonly CONFIG_PATH="${CONFIG_DIR}/player.yaml"

# Set by parse_args
ARG_NAME=""
ARG_DEVICE=""
ARG_VERSION=""
ARG_UNINSTALL=0

# Resolved by resolve_version
RESOLVED_TAG=""
RESOLVED_REF=""

usage() {
    cat <<EOF
Usage: quickstart-pi.sh [--name <s>] [--device <s>] [--version <tag>] [--uninstall]

Installs sendspin-player as a systemd daemon on 64-bit Raspberry Pi OS.

Options:
  --name <s>       Friendly player name (default: <hostname>-sendspin-player).
  --device <s>     Exact audio device name. Run 'sendspin-player --list-audio-devices' after
                   install to discover available names.
  --version <tag>  Pin to a specific release tag (e.g. v1.6.2). Default: latest.
  --uninstall      Stop the service and remove the binary and unit file. Config is preserved.
  -h, --help       Show this help.

Run with sudo:
  curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash
  curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash -s -- --name "Living Room"
EOF
}

log()  { printf '==> %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*" >&2; }
die()  { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

parse_args()       { :; }
preflight()        { :; }
do_uninstall()     { :; }
install_apt_deps() { :; }
resolve_version()  { :; }
stop_service()     { :; }
install_binary()   { :; }
install_unit()     { :; }
install_env()      { :; }
install_config()   { :; }
start_and_verify() { :; }

main() {
    parse_args "$@"
    preflight
    if [[ "${ARG_UNINSTALL}" -eq 1 ]]; then
        do_uninstall
        exit 0
    fi
    install_apt_deps
    resolve_version
    stop_service
    install_binary
    install_unit
    install_env
    install_config
    start_and_verify
}

main "$@"
  • Step 2: Make executable and run shellcheck
chmod +x scripts/quickstart-pi.sh
shellcheck scripts/quickstart-pi.sh

Expected: clean exit 0, no warnings.

  • Step 3: Verify --help works in isolation
bash scripts/quickstart-pi.sh --help 2>&1 | head -5

Note: this will currently fail because parse_args is a no-op stub — it falls through to preflight which is also a no-op, then continues to install_apt_deps etc. That's expected; we're just verifying the syntax parses. Use bash -n scripts/quickstart-pi.sh to confirm syntax-only:

bash -n scripts/quickstart-pi.sh

Expected: clean exit 0.

  • Step 4: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): script skeleton with stubbed phases"

Task 2: Argument parser and --help handling

Files:

  • Modify: scripts/quickstart-pi.sh (replace parse_args stub)

  • Step 1: Replace the parse_args stub

Find:

parse_args()       { :; }

Replace with:

parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --name)
                [[ $# -ge 2 ]] || die "--name requires a value"
                ARG_NAME="$2"
                shift 2
                ;;
            --device)
                [[ $# -ge 2 ]] || die "--device requires a value"
                ARG_DEVICE="$2"
                shift 2
                ;;
            --version)
                [[ $# -ge 2 ]] || die "--version requires a value (e.g. v1.6.2)"
                ARG_VERSION="$2"
                shift 2
                ;;
            --uninstall)
                ARG_UNINSTALL=1
                shift
                ;;
            -h|--help)
                usage
                exit 0
                ;;
            *)
                printf 'Unknown argument: %s\n\n' "$1" >&2
                usage >&2
                exit 2
                ;;
        esac
    done
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Smoke test the parser by hand
bash scripts/quickstart-pi.sh --help                         # prints usage, exits 0
bash scripts/quickstart-pi.sh --bogus 2>&1 | head -3         # prints "Unknown argument" + usage, exits 2
echo "exit=$?"

Expected: --help exits 0; --bogus exits 2.

(Don't run it with valid args yet — preflight is still a stub and would let the script fall through to apt-get without arch-checking. That's the next task.)

  • Step 4: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): argument parser with --name/--device/--version/--uninstall"

Task 3: Pre-flight checks (root, arch, debian, systemd)

Files:

  • Modify: scripts/quickstart-pi.sh (replace preflight stub)

  • Step 1: Replace the preflight stub

Find:

preflight()        { :; }

Replace with:

preflight() {
    if [[ "${EUID}" -ne 0 ]]; then
        die "Root required. Re-run with sudo:
  curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash"
    fi

    local arch
    arch="$(uname -m)"
    if [[ "${arch}" != "aarch64" ]]; then
        die "Unsupported architecture: ${arch}. This script supports 64-bit
Raspberry Pi OS only (aarch64). For Pi 3 / 4 / 5 / Zero 2 W, install
the 64-bit Pi OS image: https://www.raspberrypi.com/software/operating-systems/
Pi 1 / Zero (v1) / Zero W are not supported (32-bit ARMv6 only)."
    fi

    if [[ ! -f /etc/debian_version ]]; then
        die "Unsupported OS. This script targets Debian-based distros (Pi OS,
Raspberry Pi OS Lite). For other distros see the README install steps:
  ${REPO_URL}#installation"
    fi

    if ! command -v systemctl >/dev/null 2>&1; then
        die "systemctl not found. The quickstart installs sendspin-player as a
systemd service; non-systemd hosts must follow the manual install steps."
    fi
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Verify behavior on the dev machine
bash scripts/quickstart-pi.sh --help     # prints usage, exits 0 (preflight not yet reached)
bash scripts/quickstart-pi.sh 2>&1 | head -3

Expected: the second command fails with "Root required" if you're not root, or with "Unsupported architecture" if you're root on a non-aarch64 dev machine. Either of those is correct — both prove preflight runs.

  • Step 4: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): preflight checks for root, aarch64, debian, systemd"

Task 4: Uninstall short-circuit

Files:

  • Modify: scripts/quickstart-pi.sh (replace do_uninstall stub)

  • Step 1: Replace the do_uninstall stub

Find:

do_uninstall()     { :; }

Replace with:

do_uninstall() {
    log "Uninstalling sendspin-player..."

    if systemctl list-unit-files "${BINARY_NAME}.service" >/dev/null 2>&1; then
        systemctl disable --now "${BINARY_NAME}.service" 2>/dev/null || true
    fi

    rm -f "${INSTALL_PATH}"
    rm -f "${UNIT_PATH}"
    systemctl daemon-reload

    log "Uninstall complete."
    log "Config preserved at ${CONFIG_DIR}/ and ${ENV_PATH}."
    log "Remove manually for a full purge:"
    log "  sudo rm -rf ${CONFIG_DIR} ${ENV_PATH}"
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): --uninstall removes binary and unit, preserves config"

Task 5: Apt runtime deps

Files:

  • Modify: scripts/quickstart-pi.sh (replace install_apt_deps stub)

  • Step 1: Replace the install_apt_deps stub

Find:

install_apt_deps() { :; }

Replace with:

install_apt_deps() {
    log "Installing runtime dependencies..."
    DEBIAN_FRONTEND=noninteractive apt-get update -qq
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        libopus0 \
        libopusfile0 \
        libflac12 \
        libasound2 \
        ca-certificates \
        curl \
        tar
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): install runtime deps via apt-get"

Task 6: Version resolution

Files:

  • Modify: scripts/quickstart-pi.sh (replace resolve_version stub)

  • Step 1: Replace the resolve_version stub

Find:

resolve_version()  { :; }

Replace with:

resolve_version() {
    if [[ -n "${ARG_VERSION}" ]]; then
        RESOLVED_TAG="${ARG_VERSION}"
        RESOLVED_REF="${ARG_VERSION}"
        log "Installing pinned version: ${RESOLVED_TAG}"
    else
        # Use GitHub's latest-release redirect: no API call, no JSON parsing.
        # The redirect target tells us the resolved tag.
        local redirect_url
        redirect_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
            "${REPO_URL}/releases/latest")" \
            || die "Failed to resolve latest release tag from ${REPO_URL}/releases/latest"
        RESOLVED_TAG="${redirect_url##*/}"
        # The dist/ files at "main" are forward-compatible enough for the
        # latest-tagged release; pin them to the same tag for consistency.
        RESOLVED_REF="${RESOLVED_TAG}"
        log "Installing latest release: ${RESOLVED_TAG}"
    fi
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): resolve version via GitHub latest-release redirect"

Task 7: Stop service if running

Files:

  • Modify: scripts/quickstart-pi.sh (replace stop_service stub)

  • Step 1: Replace the stop_service stub

Find:

stop_service()     { :; }

Replace with:

stop_service() {
    if systemctl is-active --quiet "${BINARY_NAME}.service"; then
        log "Stopping running ${BINARY_NAME} service..."
        systemctl stop "${BINARY_NAME}.service"
    fi
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): stop running service before binary swap"

Task 8: Download, extract, and install the binary

Files:

  • Modify: scripts/quickstart-pi.sh (replace install_binary stub)

  • Step 1: Replace the install_binary stub

Find:

install_binary()   { :; }

Replace with:

install_binary() {
    local tarball_url tarball_name tmpdir
    tarball_name="${BINARY_NAME}-linux-arm64.tar.gz"
    tarball_url="${REPO_URL}/releases/download/${RESOLVED_TAG}/${tarball_name}"

    tmpdir="$(mktemp -d)"
    # shellcheck disable=SC2064
    trap "rm -rf '${tmpdir}'" EXIT

    log "Downloading ${tarball_url}..."
    curl -fSL "${tarball_url}" -o "${tmpdir}/${tarball_name}" \
        || die "Failed to download release tarball from ${tarball_url}"

    log "Extracting..."
    tar -xzf "${tmpdir}/${tarball_name}" -C "${tmpdir}"

    if [[ ! -f "${tmpdir}/${BINARY_NAME}" ]]; then
        die "Tarball did not contain expected binary '${BINARY_NAME}'"
    fi

    log "Installing ${INSTALL_PATH}..."
    install -m 755 "${tmpdir}/${BINARY_NAME}" "${INSTALL_PATH}"
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean (the SC2064 disable is for the trap quoting, which is intentional — we want ${tmpdir} expanded at trap-set time, not trap-fire time).

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): download release tarball into tempdir, install binary"

Task 9: Install the systemd unit

Files:

  • Modify: scripts/quickstart-pi.sh (replace install_unit stub)

  • Step 1: Replace the install_unit stub

Find:

install_unit()     { :; }

Replace with:

install_unit() {
    local unit_url
    unit_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.service"
    log "Installing systemd unit ${UNIT_PATH}..."
    curl -fSL "${unit_url}" -o "${UNIT_PATH}" \
        || die "Failed to download unit file from ${unit_url}"
    chmod 644 "${UNIT_PATH}"
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): install systemd unit from tagged ref"

Task 10: Install the env file (with flag handling)

Files:

  • Modify: scripts/quickstart-pi.sh (replace install_env stub)

  • Step 1: Replace the install_env stub

Find:

install_env()      { :; }

Replace with:

# shell_quote: wrap a string in single quotes, escaping any embedded single
# quotes via the standard '\'' pattern. Safe for arbitrary user input.
shell_quote() {
    local s="$1"
    s="${s//\'/\'\\\'\'}"
    printf "'%s'" "${s}"
}

install_env() {
    if [[ -n "${ARG_NAME}" || -n "${ARG_DEVICE}" ]]; then
        log "Writing ${ENV_PATH} with --name/--device from flags..."
        local opts=""
        if [[ -n "${ARG_NAME}" ]]; then
            opts+="--name $(shell_quote "${ARG_NAME}") "
        fi
        if [[ -n "${ARG_DEVICE}" ]]; then
            opts+="--audio-device $(shell_quote "${ARG_DEVICE}") "
        fi
        # Trim trailing space.
        opts="${opts% }"
        cat >"${ENV_PATH}" <<EOF
# /etc/default/sendspin-player
# Written by quickstart-pi.sh. Edit freely; re-running quickstart with
# --name/--device will overwrite this file.
SENDSPIN_PLAYER_OPTS="${opts}"
EOF
        chmod 644 "${ENV_PATH}"
        return
    fi

    if [[ ! -f "${ENV_PATH}" ]]; then
        local env_url
        env_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.env"
        log "Installing example env file ${ENV_PATH}..."
        curl -fSL "${env_url}" -o "${ENV_PATH}" \
            || die "Failed to download env file from ${env_url}"
        chmod 644 "${ENV_PATH}"
    fi
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Sanity-check the quoting logic on the dev machine
bash -c '
    shell_quote() { local s="$1"; s="${s//\'\''/\'\''\\\'\''\'\''}"; printf "%s%s%s" "'\''" "${s}" "'\''"; }
    shell_quote "Bob'\''s Living Room"
'

Expected output: 'Bob'\''s Living Room' — i.e. the single quote inside the name is escaped via the standard '\'' pattern. (You can also test this by writing the function to a tiny standalone script and sourcing it.)

  • Step 4: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): install env file with shell-safe --name/--device handling"

Task 11: Install YAML config (only if missing)

Files:

  • Modify: scripts/quickstart-pi.sh (replace install_config stub)

  • Step 1: Replace the install_config stub

Find:

install_config()   { :; }

Replace with:

install_config() {
    if [[ -f "${CONFIG_PATH}" ]]; then
        log "Preserving existing ${CONFIG_PATH}"
        return
    fi
    local config_url
    config_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/config/player.example.yaml"
    log "Installing example config ${CONFIG_PATH}..."
    install -d -m 755 "${CONFIG_DIR}"
    curl -fSL "${config_url}" -o "${CONFIG_PATH}" \
        || die "Failed to download config from ${config_url}"
    chmod 644 "${CONFIG_PATH}"
}
  • Step 2: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 3: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): install YAML config only if missing"

Task 12: Reload, enable, start, health-check, and success message

Files:

  • Modify: scripts/quickstart-pi.sh (replace start_and_verify stub)

  • Step 1: Replace the start_and_verify stub

Find:

start_and_verify() { :; }

Replace with:

start_and_verify() {
    log "Reloading systemd and starting service..."
    systemctl daemon-reload
    systemctl enable --now "${BINARY_NAME}.service"

    sleep 2

    if ! systemctl is-active --quiet "${BINARY_NAME}.service"; then
        warn "Service failed to come up. Recent logs:"
        journalctl -u "${BINARY_NAME}.service" --no-pager -n 20 || true
        die "${BINARY_NAME} service is not active. See logs above."
    fi

    log ""
    log "sendspin-player ${RESOLVED_TAG} installed and running."
    log "  Binary:   ${INSTALL_PATH}"
    log "  Config:   ${CONFIG_PATH}"
    log "  Env:      ${ENV_PATH}"
    log "  Logs:     journalctl -u ${BINARY_NAME} -f"
    log "  Devices:  ${BINARY_NAME} --list-audio-devices"
}
  • Step 2: Replace the failure-hint trap

Find:

set -euo pipefail

Add immediately after it:

on_exit() {
    local rc=$?
    if [[ "${rc}" -ne 0 ]]; then
        printf '\nIf install failed mid-way, re-running the script is safe — it is idempotent.\n' >&2
    fi
}
trap on_exit EXIT

(Note: this EXIT trap coexists with the install_binary tempdir-cleanup trap. The tempdir trap is set inside that function and replaces this one only for the duration of install_binary. To avoid the conflict, change install_binary's trap to be additive — see step 3.)

  • Step 3: Make install_binary's trap additive

In install_binary, replace:

    # shellcheck disable=SC2064
    trap "rm -rf '${tmpdir}'" EXIT

With:

    # shellcheck disable=SC2064
    trap "rm -rf '${tmpdir}'; on_exit" EXIT

This preserves both behaviors: tempdir cleanup AND the failure hint.

  • Step 4: Run shellcheck
shellcheck scripts/quickstart-pi.sh

Expected: clean.

  • Step 5: Run a syntax check
bash -n scripts/quickstart-pi.sh

Expected: clean exit 0.

  • Step 6: Commit
git add scripts/quickstart-pi.sh
git commit -m "feat(quickstart): start/enable service, health-check, success message"

Task 13: Add shellcheck to CI

Files:

  • Modify: .github/workflows/ci.yml

  • Step 1: Add shellcheck install + step to the lint job

Open .github/workflows/ci.yml. Find the lint job's "Install dependencies" step:

    - name: Install dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y libopus-dev libopusfile-dev libasound2-dev

Replace with:

    - name: Install dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y libopus-dev libopusfile-dev libasound2-dev shellcheck

Then, immediately before the - name: golangci-lint step in the same job, add:

    - name: Shellcheck
      run: shellcheck scripts/*.sh install-deps.sh

(install-deps.sh is included so we get coverage on the existing script too — since shellcheck is now available in CI, there's no reason to leave it unchecked.)

  • Step 2: Validate the YAML locally
python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" \
    && echo "yaml ok"

Expected: yaml ok. (If python isn't available, use yq . .github/workflows/ci.yml or cat .github/workflows/ci.yml and eyeball indentation.)

  • Step 3: Run shellcheck locally on both scripts to confirm both pass
shellcheck scripts/quickstart-pi.sh install-deps.sh

Expected: clean exit 0. (If install-deps.sh produces warnings that aren't introduced by this PR, fix them in a separate commit before merging this task — don't bundle pre-existing fixes into the quickstart change.)

  • Step 4: Commit
git add .github/workflows/ci.yml
git commit -m "chore(ci): run shellcheck on shell scripts in lint job"

Task 14: README quickstart section

Files:

  • Modify: README.md

  • Step 1: Add a "Quickstart on Raspberry Pi" section

Open README.md. Find the existing install instructions (search for the "Install the library" or build-from-source section near the top). Immediately before the build-from-source instructions, add:

## Quickstart on Raspberry Pi

For a 64-bit Raspberry Pi OS (Lite is recommended; Bookworm or newer required):

```bash
curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash

The script installs runtime dependencies, downloads the latest sendspin-player-linux-arm64 release tarball, and registers the player as a systemd service. Add flags after -- to pre-configure the player without editing files afterwards:

curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh \
  | sudo bash -s -- --name "Living Room" --device "USB Audio Device"

Pin to a specific release with --version v1.6.2. Remove the player with --uninstall (config in /etc/sendspin/ is preserved). Supported on Pi 3 / 4 / 5 / Zero 2 W; not supported on 32-bit-only hardware (Pi 1 / Zero v1 / Zero W).

After install:

  • View live logs: journalctl -u sendspin-player -f
  • Discover device names: sendspin-player --list-audio-devices
  • Edit config: sudo nano /etc/sendspin/player.yaml

(Adjust the surrounding heading levels if the README uses `###` for that depth — match what's already there.)

- [ ] **Step 2: Eyeball the rendered output**

```bash
git diff README.md | head -40

Confirm the markdown reads cleanly and the surrounding headings still flow.

  • Step 3: Commit
git add README.md
git commit -m "docs(readme): add Pi quickstart section"

Task 15: Smoke test in arm64 Docker

Files: none (verification-only)

This task verifies the script works end-to-end without needing a physical Pi. It uses Docker's QEMU-based arm64 emulation to simulate the target environment. The smoke test is intentionally not a CI check — it's a developer-machine acceptance step before merge. Systemd flows can't be exercised in a default Docker container, so we cover everything up to the systemd-reload step and stop there.

  • Step 1: Ensure Docker buildx + qemu-static are available
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

(One-time setup on the dev machine. Idempotent.)

  • Step 2: Run an interactive arm64 Bookworm container
docker run --rm -it --platform linux/arm64 \
    -v "$(pwd)/scripts/quickstart-pi.sh:/tmp/quickstart-pi.sh:ro" \
    arm64v8/debian:bookworm bash
  • Step 3: Inside the container, install systemctl shim and run the script

In the container:

apt-get update -qq && apt-get install -y -qq curl ca-certificates
# Provide a no-op systemctl so the script's preflight passes; we'll skip
# the actual systemd interaction in the next step.
cat >/usr/local/bin/systemctl <<'EOF'
#!/bin/bash
echo "systemctl-shim: $*" >&2
case "$1" in
    is-active) exit 1 ;;            # pretend service is not active
    list-unit-files) exit 0 ;;
    *) exit 0 ;;
esac
EOF
chmod +x /usr/local/bin/systemctl
bash /tmp/quickstart-pi.sh --version v1.6.2 --name "Smoke Test"

Expected: the script downloads the v1.6.2 tarball, installs the binary at /usr/local/bin/sendspin-player, writes the unit file, writes /etc/default/sendspin-player with SENDSPIN_PLAYER_OPTS="--name 'Smoke Test'", and writes /etc/sendspin/player.yaml. Final "service failed to come up" warning is expected — the systemctl shim returns non-zero on is-active, which surfaces the health-check failure path. That confirms the health check works.

  • Step 4: Inside the container, sanity-check the installed artifacts
ls -la /usr/local/bin/sendspin-player /etc/systemd/system/sendspin-player.service \
       /etc/default/sendspin-player /etc/sendspin/player.yaml
cat /etc/default/sendspin-player
/usr/local/bin/sendspin-player --version

Expected: all four files present; env file shows --name 'Smoke Test'; binary prints v1.6.2.

  • Step 5: Run idempotency check

In the same container:

bash /tmp/quickstart-pi.sh --version v1.6.2

Expected: clean run, "Preserving existing /etc/sendspin/player.yaml" message, env file not rewritten (still shows the prior Smoke Test name).

  • Step 6: Run uninstall
bash /tmp/quickstart-pi.sh --uninstall
ls /usr/local/bin/sendspin-player /etc/systemd/system/sendspin-player.service 2>&1
ls /etc/sendspin/player.yaml /etc/default/sendspin-player

Expected: binary and unit gone; config dir and env file preserved.

  • Step 7: Final pass — real Pi

On a fresh 64-bit Pi OS Lite install:

curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash

Verify: the player appears in mDNS / Music Assistant within ~10 seconds, audio plays cleanly, journalctl -u sendspin-player -f shows the expected "connected to server" / chunk-decode logs.

  • Step 8: No commit needed for this task — it's verification-only. If anything failed in steps 37, file fixes as additional commits and re-run from step 3.