# 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: ```go // ApplyEnvAndFile overlays 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`): ```go if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, cfg); err != nil { ``` Replace with: ```go 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`: ```go // Before func (c *PlayerConfigFile) asStringMap() map[string]string { // After func (c *PlayerConfigFile) AsStringMap() map[string]string { ``` In `main.go` (player): ```go 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`): ```go // Before if err := ApplyEnvAndFile(fs, setByUser, cfg); err != nil { // After if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { ``` Line 186 (`TestApplyEnvAndFile_EnvBeatsFile`): ```go // 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`): ```go // 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`): ```go // 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** ```bash 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** ```bash go build -o /dev/null . ``` Expected: no errors. - [ ] **Step 6: Commit** ```bash 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`: ```go // 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: ```go 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** ```bash go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig' -v ``` Expected: all `TestLoadPlayerConfig_*` tests PASS. - [ ] **Step 4: Commit** ```bash 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`: ```go // userConfigPath returns /sendspin/. 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`: ```go func DefaultPlayerConfigPath() (string, error) { return userConfigPath("player.yaml") } ``` In `playerConfigSearchPaths`, replace the `os.UserConfigDir()` block with: ```go if p, err := userConfigPath("player.yaml"); err == nil { paths = append(paths, p) } ``` - [ ] **Step 3: Run the package tests** ```bash go test ./pkg/sendspin/ -v ``` Expected: all tests in the package PASS. - [ ] **Step 4: Commit** ```bash 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`: ```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** ```bash 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`: ```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** ```bash 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** ```bash go test ./pkg/sendspin/ -v ``` Expected: every test in the package PASSES. - [ ] **Step 6: Commit** ```bash 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`): ```go 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: ```go 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: ```go 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): ```go if !useTUI { log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port) } ``` Replace with: ```go 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** ```bash 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)** ```bash ./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: ```yaml port: 9000 ``` Run: ```bash ./sendspin-server --no-tui --config /tmp/s.yaml ``` Expected: banner says `port 9000`. Ctrl+C. - [ ] **Step 8: Smoke test 3 — env beats file** ```bash 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** ```bash 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: ```bash ./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: ```bash ls sendspin-server.log 2>&1 || echo "confirmed: no log file created" ``` - [ ] **Step 11: Commit** ```bash 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** ```ini [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** ```bash 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** ```sh # 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** ```bash 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** ```yaml # sendspin-server configuration # # Search order (first existing file wins; missing is not an error): # 1. --config # 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_ 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: -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** ```bash 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: ```make .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: ```make # 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: ```make # 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: ```make # 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: ```make @echo " make install-daemon - Install as systemd service (Linux, requires root)" @echo " make uninstall-daemon - Remove systemd service" ``` With: ```make @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** ```bash make help ``` Expected: help text lists the new targets; no Make parse errors. - [ ] **Step 7: Dry-run the new targets to confirm they parse** ```bash 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** ```bash 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: ```markdown - `--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: ```markdown #### 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 ` 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_` 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: ```bash 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** ```bash 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** ```bash make lint ``` Expected: no lint failures. - [ ] **Step 3: Build both binaries** ```bash make ``` Expected: `sendspin-player` and `sendspin-server` built successfully. - [ ] **Step 4: End-to-end server smoke — default** ```bash ./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`: ```yaml port: 9000 ``` Verify the three-layer precedence: ```bash ./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** ```bash 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: ```bash 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: ```bash ./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.