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

1013 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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**
```bash
mkdir -p scripts
```
Write `scripts/quickstart-pi.sh`:
```bash
#!/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**
```bash
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
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
bash -n scripts/quickstart-pi.sh
```
Expected: clean exit 0.
- [ ] **Step 4: Commit**
```bash
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:
```bash
parse_args() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Smoke test the parser by hand**
```bash
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**
```bash
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:
```bash
preflight() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Verify behavior on the dev machine**
```bash
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**
```bash
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:
```bash
do_uninstall() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
install_apt_deps() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
resolve_version() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
stop_service() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
install_binary() { :; }
```
Replace with:
```bash
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**
```bash
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**
```bash
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:
```bash
install_unit() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
install_env() { :; }
```
Replace with:
```bash
# 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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Sanity-check the quoting logic on the dev machine**
```bash
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**
```bash
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:
```bash
install_config() { :; }
```
Replace with:
```bash
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**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 3: Commit**
```bash
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:
```bash
start_and_verify() { :; }
```
Replace with:
```bash
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:
```bash
set -euo pipefail
```
Add immediately after it:
```bash
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:
```bash
# shellcheck disable=SC2064
trap "rm -rf '${tmpdir}'" EXIT
```
With:
```bash
# shellcheck disable=SC2064
trap "rm -rf '${tmpdir}'; on_exit" EXIT
```
This preserves both behaviors: tempdir cleanup AND the failure hint.
- [ ] **Step 4: Run shellcheck**
```bash
shellcheck scripts/quickstart-pi.sh
```
Expected: clean.
- [ ] **Step 5: Run a syntax check**
```bash
bash -n scripts/quickstart-pi.sh
```
Expected: clean exit 0.
- [ ] **Step 6: Commit**
```bash
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:
```yaml
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libopusfile-dev libasound2-dev
```
Replace with:
```yaml
- 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:
```yaml
- 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**
```bash
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**
```bash
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**
```bash
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:
```markdown
## 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:
```bash
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**
```bash
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**
```bash
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**
```bash
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:
```bash
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**
```bash
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
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
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:
```bash
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.