28 KiB
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 existinglintjob - 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
--helpworks 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(replaceparse_argsstub) -
Step 1: Replace the
parse_argsstub
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(replacepreflightstub) -
Step 1: Replace the
preflightstub
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(replacedo_uninstallstub) -
Step 1: Replace the
do_uninstallstub
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(replaceinstall_apt_depsstub) -
Step 1: Replace the
install_apt_depsstub
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(replaceresolve_versionstub) -
Step 1: Replace the
resolve_versionstub
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(replacestop_servicestub) -
Step 1: Replace the
stop_servicestub
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(replaceinstall_binarystub) -
Step 1: Replace the
install_binarystub
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(replaceinstall_unitstub) -
Step 1: Replace the
install_unitstub
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(replaceinstall_envstub) -
Step 1: Replace the
install_envstub
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(replaceinstall_configstub) -
Step 1: Replace the
install_configstub
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(replacestart_and_verifystub) -
Step 1: Replace the
start_and_verifystub
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 3–7, file fixes as additional commits and re-run from step 3.