Files
-rpi-sendspin/third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md

40 KiB

sendspin-server YAML config + --daemon 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: Give sendspin-server a YAML config file, SENDSPIN_SERVER_* env overlay, and a --daemon flag, plus the distribution artifacts needed to run it under systemd. Parity with sendspin-player.

Architecture: Refactor pkg/sendspin/config.go so the generic machinery (flag overlay, YAML load, config-dir lookup) takes (envPrefix, map[string]string) instead of a typed *PlayerConfigFile. Add a parallel ServerConfigFile / LoadServerConfig surface using the shared helpers. Wire the server CLI to load it. Ship sendspin-server.service, sendspin-server.env, and server.example.yaml; split install-daemon into per-binary leaf targets.

Tech Stack: Go 1.24+, gopkg.in/yaml.v3, Go stdlib flag package, systemd unit files, GNU Make.

Spec: docs/superpowers/specs/2026-04-20-server-config-yaml-design.md

Environment note (from memory/reference_cgo_toolchain.md): on Windows/MSYS2, prepend /c/msys64/mingw64/bin to PATH before any full-module build or test (make, go build ./..., go test ./...). The pkg/sendspin tests alone usually work without it — scope test runs narrowly unless told otherwise.

Flaky test: TestServerStartStop has been flaky on port 8929 on Chris's box (memory/project_test_port_8929.md) — not a regression. If it fails in the final verification step, skip it and move on.


File Map

Modify:

  • pkg/sendspin/config.go — refactor ApplyEnvAndFile signature; extract helpers; add server surface.
  • pkg/sendspin/config_test.go — update existing call-sites for the new signature; add server tests.
  • main.go (player, repo root) — single-line call-site update.
  • cmd/sendspin-server/main.go — add --config and --daemon flags, wire config loading, daemon-mode logging branch.
  • Makefile — split install/uninstall targets.
  • README.md — add Server config-file section + daemon pointer.

Create:

  • dist/systemd/sendspin-server.service
  • dist/systemd/sendspin-server.env
  • dist/config/server.example.yaml

Task 1: Refactor ApplyEnvAndFile to take (envPrefix, fileValues) instead of a typed struct

Files:

  • Modify: pkg/sendspin/config.go:105-127
  • Modify: pkg/sendspin/config_test.go:151, 186, 204, 219
  • Modify: main.go:68 (player repo-root)

This is a pure signature refactor — no new behavior. All changes land in one commit because they have to compile together.

  • Step 1: Update the function signature and body

Replace the existing ApplyEnvAndFile in pkg/sendspin/config.go with:

// ApplyEnvAndFile overlays <envPrefix> env vars and YAML config-file values
// into the given FlagSet, but only for flags the user did NOT set on the CLI.
// Precedence: CLI (untouched here) > env > file > flag default.
//
// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_").
// fileValues is the flat flag-key → value map the caller derives from its
// typed config struct (see PlayerConfigFile.asStringMap and
// ServerConfigFile.asStringMap). A nil map is treated as empty.
//
// setByUser is typically built with flag.Visit before calling this.
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error {
	var firstErr error
	fs.VisitAll(func(f *flag.Flag) {
		if firstErr != nil || setByUser[f.Name] {
			return
		}
		envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
		if val, ok := os.LookupEnv(envKey); ok {
			if err := fs.Set(f.Name, val); err != nil {
				firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err)
			}
			return
		}
		configKey := strings.ReplaceAll(f.Name, "-", "_")
		if val, ok := fileValues[configKey]; ok {
			if err := fs.Set(f.Name, val); err != nil {
				firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err)
			}
		}
	})
	return firstErr
}
  • Step 2: Update player call-site in main.go

Find the line (around main.go:68):

if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, cfg); err != nil {

Replace with:

if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap()); err != nil {

Wait — asStringMap is unexported and lives in package sendspin. From main.go (package main), we can't call it directly. We need either (a) export it, or (b) have the player pass the raw struct through a small exported wrapper.

Choose (a) — rename asStringMap to AsStringMap on *PlayerConfigFile and (in a later task) on *ServerConfigFile. Make this rename part of Step 1: update the method definition in config.go:132 and every reference in config_test.go.

So replace the block above with two edits:

In pkg/sendspin/config.go:132:

// Before
func (c *PlayerConfigFile) asStringMap() map[string]string {

// After
func (c *PlayerConfigFile) AsStringMap() map[string]string {

In main.go (player):

if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
  • Step 3: Update the four existing test call-sites in config_test.go

Line 151 (TestApplyEnvAndFile_FileFillsUnsetFlags):

// Before
if err := ApplyEnvAndFile(fs, setByUser, cfg); err != nil {

// After
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {

Line 186 (TestApplyEnvAndFile_EnvBeatsFile):

// Before
if err := ApplyEnvAndFile(fs, map[string]bool{}, cfg); err != nil {

// After
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {

Line 204 (TestApplyEnvAndFile_NilConfigStillHonorsEnv):

// Before
if err := ApplyEnvAndFile(fs, map[string]bool{}, nil); err != nil {

// After
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil {

Line 219 (TestApplyEnvAndFile_InvalidEnvReturnsError):

// Before
err := ApplyEnvAndFile(fs, map[string]bool{}, nil)

// After
err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil)
  • Step 4: Run the package tests and verify they still pass
go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig|TestApplyEnvAndFile' -v

Expected: all 5 tests PASS (TestLoadPlayerConfig_ExplicitPathWithAllKeys, ..._MissingFileIsNotAnError, ..._InvalidYAMLIsAnError, ..._EnvPathHonored, and the four TestApplyEnvAndFile_* plus TestWriteStringKey_*).

If cgo build errors appear (e.g., miniaudio), prepend /c/msys64/mingw64/bin to PATH per the memory note and retry.

  • Step 5: Build the player to catch any lingering call-site issues
go build -o /dev/null .

Expected: no errors.

  • Step 6: Commit
git add pkg/sendspin/config.go pkg/sendspin/config_test.go main.go
git commit -m "refactor(sendspin): ApplyEnvAndFile takes envPrefix+map, not typed struct

Prepares the way for a second consumer (sendspin-server) to share the
same flag-overlay machinery. Player call-site passes cfg.AsStringMap()
and sendspin.PlayerEnvPrefix explicitly. asStringMap renamed to
AsStringMap so callers outside pkg/sendspin can use it.

No behavior change. All existing tests pass unchanged except for
signature updates at four call-sites."

Task 2: Extract loadYAMLConfig helper

Files:

  • Modify: pkg/sendspin/config.go

Pull the generic "search paths, open first existing, YAML-unmarshal into out" logic out of LoadPlayerConfig so the upcoming LoadServerConfig can call it too. Pure refactor.

  • Step 1: Add the helper function

Add above LoadPlayerConfig:

// loadYAMLConfig walks searchPaths, and for the first one that exists opens
// the file and unmarshals into out. It returns the path that was loaded, or
// empty if no candidate existed. A missing file is not an error; I/O or
// parse errors are returned as-is with the offending path attached.
func loadYAMLConfig(searchPaths []string, out any) (string, error) {
	for _, candidate := range searchPaths {
		if candidate == "" {
			continue
		}
		data, err := os.ReadFile(candidate)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				continue
			}
			return candidate, fmt.Errorf("read %s: %w", candidate, err)
		}
		if err := yaml.Unmarshal(data, out); err != nil {
			return candidate, fmt.Errorf("parse %s: %w", candidate, err)
		}
		return candidate, nil
	}
	return "", nil
}
  • Step 2: Refactor LoadPlayerConfig to use it

Replace the body:

func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) {
	var cfg PlayerConfigFile
	used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg)
	if err != nil {
		return nil, used, err
	}
	if used == "" {
		return nil, "", nil
	}
	return &cfg, used, nil
}
  • Step 3: Run the package tests
go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig' -v

Expected: all TestLoadPlayerConfig_* tests PASS.

  • Step 4: Commit
git add pkg/sendspin/config.go
git commit -m "refactor(sendspin): extract loadYAMLConfig helper

Prepares for a second consumer. No behavior change."

Task 3: Extract userConfigPath helper

Files:

  • Modify: pkg/sendspin/config.go

  • Step 1: Add the helper

Add above DefaultPlayerConfigPath:

// userConfigPath returns <UserConfigDir>/sendspin/<relative>. Matches the
// canonical path layout for both player.yaml and server.yaml.
func userConfigPath(relative string) (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("user config dir: %w", err)
	}
	return filepath.Join(dir, "sendspin", relative), nil
}
  • Step 2: Refactor DefaultPlayerConfigPath and playerConfigSearchPaths

Replace DefaultPlayerConfigPath:

func DefaultPlayerConfigPath() (string, error) {
	return userConfigPath("player.yaml")
}

In playerConfigSearchPaths, replace the os.UserConfigDir() block with:

if p, err := userConfigPath("player.yaml"); err == nil {
	paths = append(paths, p)
}
  • Step 3: Run the package tests
go test ./pkg/sendspin/ -v

Expected: all tests in the package PASS.

  • Step 4: Commit
git add pkg/sendspin/config.go
git commit -m "refactor(sendspin): extract userConfigPath helper

Shared between DefaultPlayerConfigPath and playerConfigSearchPaths;
ready for the server's analogous use. No behavior change."

Task 4: Add ServerConfigFile + LoadServerConfig with TDD tests

Files:

  • Modify: pkg/sendspin/config.go — add server surface.
  • Create: pkg/sendspin/config_server_test.go — new test file, keeps server tests adjacent to player tests but separately readable.

TDD: write the failing tests first, then the implementation.

  • Step 1: Write the failing tests

Create pkg/sendspin/config_server_test.go:

// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server
package sendspin

import (
	"flag"
	"os"
	"path/filepath"
	"testing"
)

func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) {
	path := filepath.Join(t.TempDir(), "server.yaml")
	body := `# Test server
name: "Living Room Server"
port: 9000
log_file: "custom-server.log"
debug: true
no_mdns: true
no_tui: true
audio: "/srv/music/radio.m3u8"
discover_clients: true
daemon: true
`
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		t.Fatalf("seed: %v", err)
	}

	cfg, used, err := LoadServerConfig(path)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if used != path {
		t.Errorf("used = %q, want %q", used, path)
	}
	if cfg == nil {
		t.Fatal("cfg is nil")
	}
	if cfg.Name != "Living Room Server" {
		t.Errorf("name = %q", cfg.Name)
	}
	if cfg.Port == nil || *cfg.Port != 9000 {
		t.Errorf("port = %v, want 9000", cfg.Port)
	}
	if cfg.LogFile != "custom-server.log" {
		t.Errorf("log_file = %q", cfg.LogFile)
	}
	if cfg.Debug == nil || !*cfg.Debug {
		t.Errorf("debug = %v, want true", cfg.Debug)
	}
	if cfg.NoMDNS == nil || !*cfg.NoMDNS {
		t.Errorf("no_mdns = %v, want true", cfg.NoMDNS)
	}
	if cfg.NoTUI == nil || !*cfg.NoTUI {
		t.Errorf("no_tui = %v, want true", cfg.NoTUI)
	}
	if cfg.Audio != "/srv/music/radio.m3u8" {
		t.Errorf("audio = %q", cfg.Audio)
	}
	if cfg.DiscoverClients == nil || !*cfg.DiscoverClients {
		t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients)
	}
	if cfg.Daemon == nil || !*cfg.Daemon {
		t.Errorf("daemon = %v, want true", cfg.Daemon)
	}
}

func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) {
	cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if cfg != nil || used != "" {
		t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
	}
}

func TestLoadServerConfig_EnvPathHonored(t *testing.T) {
	dir := t.TempDir()
	envPath := filepath.Join(dir, "from-env-server.yaml")
	if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil {
		t.Fatalf("seed: %v", err)
	}
	t.Setenv("SENDSPIN_SERVER_CONFIG", envPath)

	cfg, used, err := LoadServerConfig("")
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if used != envPath {
		t.Errorf("used = %q, want %q", used, envPath)
	}
	if cfg.Name != "EnvServer" {
		t.Errorf("name = %q", cfg.Name)
	}
}

// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix
// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves
// are already covered by the player tests; this is pure plumbing.
func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) {
	fs := flag.NewFlagSet("server", flag.ContinueOnError)
	port := fs.Int("port", 8927, "port")
	audio := fs.String("audio", "", "audio source")
	if err := fs.Parse(nil); err != nil {
		t.Fatalf("parse: %v", err)
	}
	t.Setenv("SENDSPIN_SERVER_PORT", "9999")
	t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac")

	if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil {
		t.Fatalf("apply: %v", err)
	}
	if *port != 9999 {
		t.Errorf("port = %d, want 9999", *port)
	}
	if *audio != "/srv/env.flac" {
		t.Errorf("audio = %q", *audio)
	}
}
  • Step 2: Run the tests — confirm they fail to compile
go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v

Expected: compile error — undefined: LoadServerConfig, undefined: ServerEnvPrefix, undefined: ServerConfigFile (fields).

  • Step 3: Add the server surface to config.go

Append to pkg/sendspin/config.go:

// ServerEnvPrefix is the namespace for environment overrides of server
// config values. Env key = ServerEnvPrefix + upper-snake(flag name).
// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS.
const ServerEnvPrefix = "SENDSPIN_SERVER_"

// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values
// that could reasonably be meaningful (bool) are pointers so absence in
// the YAML can be distinguished from an explicit false.
type ServerConfigFile struct {
	Name            string `yaml:"name,omitempty"`
	Port            *int   `yaml:"port,omitempty"`
	LogFile         string `yaml:"log_file,omitempty"`
	Debug           *bool  `yaml:"debug,omitempty"`
	NoMDNS          *bool  `yaml:"no_mdns,omitempty"`
	NoTUI           *bool  `yaml:"no_tui,omitempty"`
	Audio           string `yaml:"audio,omitempty"`
	DiscoverClients *bool  `yaml:"discover_clients,omitempty"`
	Daemon          *bool  `yaml:"daemon,omitempty"`
}

// LoadServerConfig searches for a server.yaml and returns its parsed contents
// along with the path that was loaded (empty if none was found).
//
// Search order (first existing wins):
//  1. explicitPath if non-empty
//  2. $SENDSPIN_SERVER_CONFIG if set
//  3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml
//  4. /etc/sendspin/server.yaml
//
// A missing file is not an error; the caller gets (nil, "", nil).
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) {
	var cfg ServerConfigFile
	used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg)
	if err != nil {
		return nil, used, err
	}
	if used == "" {
		return nil, "", nil
	}
	return &cfg, used, nil
}

// DefaultServerConfigPath returns the canonical user-level server.yaml path
// for this OS.
func DefaultServerConfigPath() (string, error) {
	return userConfigPath("server.yaml")
}

func serverConfigSearchPaths(explicit string) []string {
	paths := make([]string, 0, 4)
	if explicit != "" {
		paths = append(paths, explicit)
	}
	if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" {
		paths = append(paths, env)
	}
	if p, err := userConfigPath("server.yaml"); err == nil {
		paths = append(paths, p)
	}
	paths = append(paths, "/etc/sendspin/server.yaml")
	return paths
}

// AsStringMap returns only the keys the user actually set in the YAML, as
// strings suitable for flag.Set. Absent keys are omitted so the overlay
// correctly falls through to the flag default.
func (c *ServerConfigFile) AsStringMap() map[string]string {
	m := make(map[string]string)
	if c == nil {
		return m
	}
	if c.Name != "" {
		m["name"] = c.Name
	}
	if c.Port != nil {
		m["port"] = strconv.Itoa(*c.Port)
	}
	if c.LogFile != "" {
		m["log_file"] = c.LogFile
	}
	if c.Debug != nil {
		m["debug"] = strconv.FormatBool(*c.Debug)
	}
	if c.NoMDNS != nil {
		m["no_mdns"] = strconv.FormatBool(*c.NoMDNS)
	}
	if c.NoTUI != nil {
		m["no_tui"] = strconv.FormatBool(*c.NoTUI)
	}
	if c.Audio != "" {
		m["audio"] = c.Audio
	}
	if c.DiscoverClients != nil {
		m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients)
	}
	if c.Daemon != nil {
		m["daemon"] = strconv.FormatBool(*c.Daemon)
	}
	return m
}
  • Step 4: Run the server tests — confirm they pass
go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v

Expected: all 4 tests PASS.

  • Step 5: Run the full package test suite to confirm no regressions
go test ./pkg/sendspin/ -v

Expected: every test in the package PASSES.

  • Step 6: Commit
git add pkg/sendspin/config.go pkg/sendspin/config_server_test.go
git commit -m "feat(sendspin): ServerConfigFile + LoadServerConfig

Mirrors LoadPlayerConfig: CLI > env > ~/.config/sendspin/server.yaml >
/etc/sendspin/server.yaml. Pointer bools so absence in YAML is distinct
from explicit false. AsStringMap() feeds the shared ApplyEnvAndFile
helper with SENDSPIN_SERVER_ as the env prefix."

Task 5: Wire --config and --daemon into cmd/sendspin-server/main.go

Files:

  • Modify: cmd/sendspin-server/main.go

This is the single user-visible change to the server binary. Touches flag declarations, logging setup, and the config-overlay call site.

  • Step 1: Add the two new flag declarations

In the var (...) block at the top, add (order matches the player's convention — config first, daemon near no-tui):

var (
	port            = flag.Int("port", 8927, "WebSocket server port")
	name            = flag.String("name", "", "Server friendly name (default: hostname-sendspin-server)")
	logFile         = flag.String("log-file", "sendspin-server.log", "Log file path")
	debug           = flag.Bool("debug", false, "Enable debug logging")
	noMDNS          = flag.Bool("no-mdns", false, "Disable mDNS advertisement")
	noTUI           = flag.Bool("no-tui", false, "Disable TUI, use streaming logs instead")
	audioFile       = flag.String("audio", "", "Audio source to stream (MP3, FLAC, HTTP URL, HLS). Default: test tone")
	discoverClients = flag.Bool("discover-clients", false, "Enable server-initiated discovery: browse _sendspin._tcp and dial out to clients")
	daemon          = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
	configPath      = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
)
  • Step 2: Wire config loading after flag.Parse()

Replace the opening of main() (current lines 30-33) with:

func main() {
	flag.Parse()

	// Overlay YAML file and SENDSPIN_SERVER_* env vars onto flag vars for
	// anything the user didn't set on the CLI. --config is excluded because
	// putting it in the config file would be circular.
	setByUser := map[string]bool{"config": true}
	flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })

	cfg, _, err := sendspin.LoadServerConfig(*configPath)
	if err != nil {
		log.Fatalf("config: %v", err)
	}
	if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.AsStringMap()); err != nil {
		log.Fatalf("config overlay: %v", err)
	}

	useTUI := !(*noTUI || *daemon)

(cfg.AsStringMap() is safe on a nil cfg — returns an empty map.)

  • Step 3: Replace the logging-setup block with a daemon-aware branch

Current block (around lines 35-46) opens the log file unconditionally. Replace with:

	if *daemon {
		// Daemon mode: log to stdout only. systemd/journalctl captures stdout
		// and adds its own timestamps, so we keep ours for grep-ability.
		log.SetOutput(os.Stdout)
	} else {
		f, err := os.OpenFile(*logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
		if err != nil {
			log.Fatalf("error opening log file: %v", err)
		}
		defer f.Close()

		if useTUI {
			// Log to file only when TUI is running; otherwise the log would stomp the TUI
			log.SetOutput(f)
		} else {
			log.SetOutput(io.MultiWriter(os.Stdout, f))
		}
	}
  • Step 4: Add the daemon-mode banner line

Find the existing non-TUI startup log (around line 57-59):

	if !useTUI {
		log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port)
	}

Replace with:

	if !useTUI {
		log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port)
		if *daemon {
			log.Printf("Daemon mode: logging to stdout only")
		}
	}
  • Step 5: Build the server binary
make server

Expected: builds cleanly. If cgo errors appear, prepend /c/msys64/mingw64/bin to PATH first.

  • Step 6: Smoke test 1 — default (no config, no daemon)
./sendspin-server --no-tui

Expected: "Starting Sendspin Server: ... on port 8927" in stdout; sendspin-server.log appears in cwd; mDNS advertisement starts. Ctrl+C to exit.

  • Step 7: Smoke test 2 — config file overrides port

Create /tmp/s.yaml (or equivalent) with:

port: 9000

Run:

./sendspin-server --no-tui --config /tmp/s.yaml

Expected: banner says port 9000. Ctrl+C.

  • Step 8: Smoke test 3 — env beats file
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml

Expected: banner says port 9001.

  • Step 9: Smoke test 4 — CLI beats env + file
SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml

Expected: banner says port 9002.

  • Step 10: Smoke test 5 — daemon mode

Delete any stale sendspin-server.log in cwd first. Then:

./sendspin-server --daemon

Expected: logs to stdout (no TUI), banner includes "Daemon mode: logging to stdout only", no sendspin-server.log file created. Ctrl+C to exit, then:

ls sendspin-server.log 2>&1 || echo "confirmed: no log file created"
  • Step 11: Commit
git add cmd/sendspin-server/main.go
git commit -m "feat(server): --config and --daemon flags

Matches the player's config surface: YAML search (CLI > env >
~/.config/sendspin/server.yaml > /etc/sendspin/server.yaml), env
overlay via SENDSPIN_SERVER_*, and --daemon (stdout-only logging,
no TUI, no log file) for journalctl-friendly operation under systemd."

Task 6: Create dist/systemd/sendspin-server.service

Files:

  • Create: dist/systemd/sendspin-server.service

  • Step 1: Write the unit file

[Unit]
Description=Sendspin Server
Documentation=https://github.com/Sendspin/sendspin-go
After=network-online.target sound.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/sendspin-server --daemon
Restart=on-failure
RestartSec=5
EnvironmentFile=-/etc/default/sendspin-server
# Pass CLI flags via SENDSPIN_SERVER_OPTS in the environment file.
# ExecStart is overridden below to append them.
ExecStart=
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  • Step 2: Commit
git add dist/systemd/sendspin-server.service
git commit -m "feat(dist): systemd unit for sendspin-server

Mirrors sendspin-player.service. ProtectHome=read-only (not =yes) so
operators can use --audio /home/user/Music/... under systemd without
fighting the sandbox."

Task 7: Create dist/systemd/sendspin-server.env

Files:

  • Create: dist/systemd/sendspin-server.env

  • Step 1: Write the env file

# sendspin-server: extra CLI flags appended to ExecStart
# Prefer /etc/sendspin/server.yaml for structured config; use this for
# ad-hoc overrides or keys that aren't (yet) in the YAML.
# SENDSPIN_SERVER_OPTS="--debug --discover-clients"
SENDSPIN_SERVER_OPTS=""
  • Step 2: Commit
git add dist/systemd/sendspin-server.env
git commit -m "feat(dist): /etc/default/sendspin-server starter"

Task 8: Create dist/config/server.example.yaml

Files:

  • Create: dist/config/server.example.yaml

  • Step 1: Write the annotated example

# sendspin-server configuration
#
# Search order (first existing file wins; missing is not an error):
#   1. --config <path>
#   2. $SENDSPIN_SERVER_CONFIG
#   3. ~/.config/sendspin/server.yaml           (user install)
#   4. /etc/sendspin/server.yaml                (daemon / system-wide)
#
# Per-value precedence for every key below:
#   CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > this file > built-in default
#
# Every key is optional. Uncomment and adjust as needed.

# ---------------------------------------------------------------------------
# Identity
# ---------------------------------------------------------------------------

# Friendly name shown in Music Assistant and via mDNS.
# Default: <hostname>-sendspin-server
# name: "Living Room Server"

# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------

# WebSocket server port.
# port: 8927

# Disable mDNS advertisement. Clients must then connect manually.
# no_mdns: false

# Enable server-initiated discovery: browse _sendspin._tcp and dial out
# to clients. Useful for players that can't initiate connections.
# discover_clients: false

# ---------------------------------------------------------------------------
# Audio source
# ---------------------------------------------------------------------------

# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
# Examples:
#   audio: "/srv/music/ambient.flac"
#   audio: "http://example.com/stream.mp3"
#   audio: "https://stream.radiofrance.fr/fip/fip.m3u8"
# audio: ""

# ---------------------------------------------------------------------------
# Runtime / logging
# ---------------------------------------------------------------------------

# Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log
# file. Recommended when running under systemd.
# daemon: false

# Disable the interactive terminal UI, stream logs to stdout instead.
# no_tui: false

# Log file path. Ignored when daemon mode is on.
# log_file: "sendspin-server.log"

# Enable verbose debug logging.
# debug: false
  • Step 2: Commit
git add dist/config/server.example.yaml
git commit -m "docs(server): annotated server.yaml starter"

Task 9: Split Makefile install-daemon / uninstall-daemon into leaves + aggregates

Files:

  • Modify: Makefile (lines ~1-6 phony list, and the block at 85-117)

The existing install-daemon target (line 85) installs the player only. Rewrite so that install-daemon becomes an aggregate of per-binary leaf targets, and a matching install-server-daemon target installs the new artifacts.

  • Step 1: Update the .PHONY declaration

At the top of the Makefile (around line 5), add the new target names:

.PHONY: all clean test test-verbose test-coverage test-race lint help \
	build-all build-linux build-darwin help conformance \
	install-daemon uninstall-daemon \
	install-player-daemon uninstall-player-daemon \
	install-server-daemon uninstall-server-daemon

(Exact existing variable names may differ; preserve the existing list and append the new targets.)

  • Step 2: Rename the existing install-daemon body to install-player-daemon

Replace the current install-daemon: player stanza (lines 85-107) with:

# Install sendspin-player as a systemd daemon
install-player-daemon: player
	@echo "Installing sendspin-player daemon..."
	install -m 755 sendspin-player /usr/local/bin/sendspin-player
	install -m 644 dist/systemd/sendspin-player.service /etc/systemd/system/sendspin-player.service
	@if [ ! -f /etc/default/sendspin-player ]; then \
		install -m 644 dist/systemd/sendspin-player.env /etc/default/sendspin-player; \
		echo "Created /etc/default/sendspin-player — edit this file to configure."; \
	else \
		echo "/etc/default/sendspin-player already exists, not overwriting."; \
	fi
	@if [ ! -f /etc/sendspin/player.yaml ]; then \
		install -d -m 755 /etc/sendspin; \
		install -m 644 dist/config/player.example.yaml /etc/sendspin/player.yaml; \
		echo "Created /etc/sendspin/player.yaml — edit this file to configure."; \
	else \
		echo "/etc/sendspin/player.yaml already exists, not overwriting."; \
	fi
	systemctl daemon-reload
	@echo ""
	@echo "Installed. To start:"
	@echo "  sudo systemctl enable --now sendspin-player"
	@echo ""
	@echo "Configure via /etc/sendspin/player.yaml (preferred) or /etc/default/sendspin-player"
  • Step 3: Add the new install-server-daemon target

Append directly below:

# Install sendspin-server as a systemd daemon
install-server-daemon: server
	@echo "Installing sendspin-server daemon..."
	install -m 755 sendspin-server /usr/local/bin/sendspin-server
	install -m 644 dist/systemd/sendspin-server.service /etc/systemd/system/sendspin-server.service
	@if [ ! -f /etc/default/sendspin-server ]; then \
		install -m 644 dist/systemd/sendspin-server.env /etc/default/sendspin-server; \
		echo "Created /etc/default/sendspin-server — edit this file to configure."; \
	else \
		echo "/etc/default/sendspin-server already exists, not overwriting."; \
	fi
	@if [ ! -f /etc/sendspin/server.yaml ]; then \
		install -d -m 755 /etc/sendspin; \
		install -m 644 dist/config/server.example.yaml /etc/sendspin/server.yaml; \
		echo "Created /etc/sendspin/server.yaml — edit this file to configure."; \
	else \
		echo "/etc/sendspin/server.yaml already exists, not overwriting."; \
	fi
	systemctl daemon-reload
	@echo ""
	@echo "Installed. To start:"
	@echo "  sudo systemctl enable --now sendspin-server"
	@echo ""
	@echo "Configure via /etc/sendspin/server.yaml (preferred) or /etc/default/sendspin-server"

# Aggregate: install both binaries as systemd daemons
install-daemon: install-player-daemon install-server-daemon
  • Step 4: Rename the existing uninstall-daemon body to uninstall-player-daemon and add the server counterpart + aggregate

Replace the current uninstall-daemon stanza (lines 110-117) with:

# Uninstall the sendspin-player systemd daemon
uninstall-player-daemon:
	@echo "Removing sendspin-player daemon..."
	-systemctl stop sendspin-player 2>/dev/null
	-systemctl disable sendspin-player 2>/dev/null
	rm -f /etc/systemd/system/sendspin-player.service
	rm -f /usr/local/bin/sendspin-player
	systemctl daemon-reload
	@echo "Removed. /etc/default/sendspin-player and /etc/sendspin/player.yaml left in place (manual cleanup if desired)."

# Uninstall the sendspin-server systemd daemon
uninstall-server-daemon:
	@echo "Removing sendspin-server daemon..."
	-systemctl stop sendspin-server 2>/dev/null
	-systemctl disable sendspin-server 2>/dev/null
	rm -f /etc/systemd/system/sendspin-server.service
	rm -f /usr/local/bin/sendspin-server
	systemctl daemon-reload
	@echo "Removed. /etc/default/sendspin-server and /etc/sendspin/server.yaml left in place (manual cleanup if desired)."

# Aggregate: uninstall both daemons
uninstall-daemon: uninstall-player-daemon uninstall-server-daemon
  • Step 5: Update the help target

Find the help-text block (around line 168). Replace the single daemon line:

	@echo "  make install-daemon   - Install as systemd service (Linux, requires root)"
	@echo "  make uninstall-daemon - Remove systemd service"

With:

	@echo "  make install-daemon           - Install both player and server as systemd daemons (Linux, requires root)"
	@echo "  make install-player-daemon    - Install only the player daemon"
	@echo "  make install-server-daemon    - Install only the server daemon"
	@echo "  make uninstall-daemon         - Remove both daemons"
	@echo "  make uninstall-player-daemon  - Remove only the player daemon"
	@echo "  make uninstall-server-daemon  - Remove only the server daemon"
  • Step 6: Sanity check — make help still parses
make help

Expected: help text lists the new targets; no Make parse errors.

  • Step 7: Dry-run the new targets to confirm they parse
make -n install-server-daemon
make -n install-daemon
make -n uninstall-server-daemon
make -n uninstall-daemon

Expected: each prints the commands it would run (the install, rm, systemctl calls), without actually executing anything. If Make reports missing variables or syntax errors, fix them before committing.

Not run here: actual make install-daemon execution requires root on a Linux box. Document in the final verification step for a Linux smoke test.

  • Step 8: Commit
git add Makefile
git commit -m "build: split install-daemon into per-binary targets

install-daemon now aggregates install-player-daemon and
install-server-daemon (same for uninstall). Operators can install or
remove either side alone; running 'make install-daemon' still installs
both, preserving existing behavior."

Task 10: README — add Server config-file section and daemon pointer

Files:

  • Modify: README.md (around line 232, right after the "Server Options" list)

  • Step 1: Add --config and --daemon to the Server Options list

Find the Server Options bullet list (around lines 222-232). After the existing --no-tui bullet at line 232, append:

- `--daemon` - Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file. Recommended under systemd.
- `--config` - Path to `server.yaml` config file. Default search: `$SENDSPIN_SERVER_CONFIG`, `~/.config/sendspin/server.yaml`, `/etc/sendspin/server.yaml`.
  • Step 2: Add a Configuration File (server.yaml) subsection after the Server TUI section

After the Server TUI block (around line 243, before the ### Player heading at line 244), insert:

#### Configuration File (`server.yaml`)

Every CLI flag has a matching key in `server.yaml`. Keys use `snake_case` (`--no-mdns``no_mdns`).

A fully-commented starter file lives at [`dist/config/server.example.yaml`](dist/config/server.example.yaml) — copy it to `~/.config/sendspin/server.yaml` (user install) or `/etc/sendspin/server.yaml` (daemon) and uncomment the keys you want to set.

**Search order** (first existing file wins; missing is not an error):

1. `--config <path>` flag
2. `$SENDSPIN_SERVER_CONFIG`
3. `~/.config/sendspin/server.yaml` (macOS: `~/Library/Application Support/sendspin/server.yaml`; Windows: `%AppData%\sendspin\server.yaml`)
4. `/etc/sendspin/server.yaml` (daemon/system-wide)

**Value precedence**, for every flag: CLI > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default.

#### Running under systemd

`make install-daemon` installs both `sendspin-player` and `sendspin-server` as systemd units. To install only one side:

```bash
sudo make install-server-daemon   # server only
sudo make install-player-daemon   # player only

Then enable and start the server:

sudo systemctl enable --now sendspin-server
journalctl -u sendspin-server -f

Configure via /etc/sendspin/server.yaml (preferred) or SENDSPIN_SERVER_OPTS in /etc/default/sendspin-server.


- [ ] **Step 3: Commit**

```bash
git add README.md
git commit -m "docs: document server.yaml config and --daemon flag

Mirrors the existing player config docs. Adds a brief 'Running under
systemd' subsection covering install-daemon and the split per-binary
targets."

Task 11: Final verification

No code changes — this is a verification pass. Run the full test suite and the full build to catch anything the per-task smoke tests missed.

  • Step 1: Run the full test suite
go test ./... -short

Expected: all tests PASS.

Known flake: TestServerStartStop on port 8929 is a pre-existing environment issue on Chris's box (not introduced by this plan). If it's the only failure, record it and move on — do not chase it.

If cgo build errors appear, prepend /c/msys64/mingw64/bin to PATH and retry.

  • Step 2: Run the linter
make lint

Expected: no lint failures.

  • Step 3: Build both binaries
make

Expected: sendspin-player and sendspin-server built successfully.

  • Step 4: End-to-end server smoke — default
./sendspin-server --no-tui

Expected: starts on port 8927, mDNS advertisement logs appear, Ctrl+C exits cleanly.

  • Step 5: End-to-end server smoke — config + env + CLI precedence

Create /tmp/s.yaml:

port: 9000

Verify the three-layer precedence:

./sendspin-server --no-tui --config /tmp/s.yaml
# Expected: port 9000

SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml
# Expected: port 9001

SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml
# Expected: port 9002
  • Step 6: End-to-end server smoke — daemon mode
rm -f sendspin-server.log
./sendspin-server --daemon
# Ctrl+C
ls sendspin-server.log 2>&1 || echo "confirmed: no log file created"

Expected: no log file; stdout had the "Daemon mode: logging to stdout only" banner.

  • Step 7 (Linux-only, manual): systemd install smoke

On a Linux box with the repo checked out:

sudo make install-server-daemon
sudo systemctl enable --now sendspin-server
journalctl -u sendspin-server -f
# Expected: "Starting Sendspin Server: ..." and "Daemon mode: ..." lines
sudo systemctl stop sendspin-server
sudo make uninstall-server-daemon
# Expected: /etc/sendspin/server.yaml and /etc/default/sendspin-server remain; binary + unit removed

If you're not on Linux, note this step as pending and flag it in the PR description.

  • Step 8: Player regression smoke

Because Task 1 touched the player call-site, confirm the player still starts and connects:

./sendspin-player --no-tui --server ws://localhost:8927

Expected: mDNS-skipped direct connect, normal startup output. Ctrl+C exits cleanly.

  • Step 9: Final commit (only if anything was found and fixed)

If steps 1-8 revealed issues that needed fixes, commit them with a descriptive message. If everything passed, nothing to commit here.


Out-of-scope reminders

  • No server_id persistent identity — explicitly deferred in the spec.
  • No README deep-dive on daemon operation beyond the brief pointer above.
  • No refactor of pkg/sendspin into a configfile sub-package — YAGNI until a third consumer arrives.