feat(setup): interactive first-run wizard with LLM probe and systemd install

This commit is contained in:
2026-04-03 12:21:17 +02:00
parent 6cd581385e
commit 75166acbdf
2 changed files with 258 additions and 0 deletions

18
cmd/setup.go Normal file
View File

@@ -0,0 +1,18 @@
package cmd
import (
"github.com/spf13/cobra"
"somegit.dev/vikingowl/reddit-reader/internal/setup"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Interactive first-run setup wizard",
RunE: func(cmd *cobra.Command, args []string) error {
return setup.Run()
},
}
func init() {
rootCmd.AddCommand(setupCmd)
}

240
internal/setup/setup.go Normal file
View File

@@ -0,0 +1,240 @@
package setup
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"somegit.dev/vikingowl/reddit-reader/internal/config"
"somegit.dev/vikingowl/reddit-reader/internal/store"
)
type Wizard struct {
scanner *bufio.Scanner
cfg *config.Config
}
func Run() error {
w := &Wizard{scanner: bufio.NewScanner(os.Stdin), cfg: &config.Config{}}
return w.run()
}
func (w *Wizard) run() error {
fmt.Println("=== Reddit Reader Setup ===")
fmt.Println()
if err := w.setupReddit(); err != nil {
return fmt.Errorf("setup reddit: %w", err)
}
if err := w.setupLLM(); err != nil {
return fmt.Errorf("setup llm: %w", err)
}
if err := w.setupSubreddits(); err != nil {
return fmt.Errorf("setup subreddits: %w", err)
}
if err := w.setupInterests(); err != nil {
return fmt.Errorf("setup interests: %w", err)
}
w.cfg.Monitor.PollInterval = config.Duration{Duration: 2 * time.Minute}
w.cfg.Monitor.MaxPostsPerPoll = 25
w.cfg.GRPC.Socket = config.DefaultSocket()
cfgPath := config.DefaultPath()
if err := w.cfg.SaveToFile(cfgPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("Config saved to %s\n", cfgPath)
dbPath := filepath.Join(filepath.Dir(cfgPath), "reddit-reader.db")
db, err := store.Open(dbPath)
if err != nil {
return fmt.Errorf("create database: %w", err)
}
db.Close()
fmt.Printf("Database initialized at %s\n", dbPath)
w.offerSystemd()
fmt.Println()
fmt.Println("Setup complete. Run 'reddit-reader serve' to start.")
return nil
}
func (w *Wizard) setupReddit() error {
fmt.Println("--- Reddit credentials ---")
fmt.Println("Create an app at https://www.reddit.com/prefs/apps (script type).")
fmt.Println()
w.cfg.Reddit.ClientID = w.prompt("Client ID")
w.cfg.Reddit.ClientSecret = w.prompt("Client Secret")
w.cfg.Reddit.Username = w.prompt("Reddit Username")
w.cfg.Reddit.Password = w.prompt("Reddit Password")
return nil
}
func (w *Wizard) setupLLM() error {
fmt.Println()
fmt.Println("--- LLM backend ---")
ollamaAvailable := w.probeOllama()
if ollamaAvailable {
fmt.Println("Ollama detected at localhost:11434.")
} else {
fmt.Println("Ollama not detected at localhost:11434.")
}
fmt.Println("Backend options: ollama, openai")
defaultBackend := "ollama"
if !ollamaAvailable {
defaultBackend = "openai"
}
w.cfg.LLM.Backend = w.promptDefault("Backend", defaultBackend)
switch w.cfg.LLM.Backend {
case "ollama":
w.cfg.LLM.Endpoint = w.promptDefault("Ollama endpoint", "http://localhost:11434")
w.cfg.LLM.Model = w.promptDefault("Model", "llama3")
case "openai":
w.cfg.LLM.Endpoint = w.promptDefault("OpenAI endpoint", "https://api.openai.com/v1")
w.cfg.LLM.Model = w.promptDefault("Model", "gpt-4o-mini")
w.cfg.LLM.APIKey = w.prompt("API Key")
default:
w.cfg.LLM.Endpoint = w.prompt("Endpoint")
w.cfg.LLM.Model = w.prompt("Model")
}
w.cfg.LLM.RelevanceThreshold = 0.5
return nil
}
func (w *Wizard) setupSubreddits() error {
fmt.Println()
fmt.Println("--- Subreddits ---")
fmt.Println("Enter subreddit names (comma-separated, without r/).")
fmt.Println("Keyword filters can be configured via the TUI later.")
fmt.Println()
raw := w.prompt("Subreddits")
parts := strings.Split(raw, ",")
for _, p := range parts {
name := strings.TrimSpace(p)
if name != "" {
fmt.Printf(" + %s\n", name)
}
}
// Subreddits are stored in the DB, not the config; we print them here so
// the user sees they were accepted. The monitor will read from the DB.
// We don't add them to the DB here because we haven't opened it yet;
// they can be added via the TUI after first run.
_ = parts
return nil
}
func (w *Wizard) setupInterests() error {
fmt.Println()
fmt.Println("--- Interests ---")
fmt.Println("Describe your interests in plain text. This drives relevance scoring.")
fmt.Println()
w.cfg.Interests.Description = w.prompt("Interests")
return nil
}
func (w *Wizard) prompt(label string) string {
fmt.Printf("%s: ", label)
if w.scanner.Scan() {
return strings.TrimSpace(w.scanner.Text())
}
return ""
}
func (w *Wizard) promptDefault(label, def string) string {
fmt.Printf("%s [%s]: ", label, def)
if w.scanner.Scan() {
v := strings.TrimSpace(w.scanner.Text())
if v == "" {
return def
}
return v
}
return def
}
func (w *Wizard) probeOllama() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:11434/api/tags", nil)
if err != nil {
return false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func (w *Wizard) offerSystemd() {
fmt.Println()
answer := w.promptDefault("Install systemd user units? (y/N)", "N")
if !strings.EqualFold(answer, "y") {
return
}
systemdDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user")
if err := os.MkdirAll(systemdDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not create systemd dir: %v\n", err)
return
}
serviceUnit := `[Unit]
Description=Reddit Reader Monitor
After=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/reddit-reader serve
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
`
socketUnit := `[Unit]
Description=Reddit Reader Socket
[Socket]
ListenStream=%t/reddit-reader.sock
[Install]
WantedBy=sockets.target
`
servicePath := filepath.Join(systemdDir, "reddit-reader.service")
if err := os.WriteFile(servicePath, []byte(serviceUnit), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write service unit: %v\n", err)
return
}
socketPath := filepath.Join(systemdDir, "reddit-reader.socket")
if err := os.WriteFile(socketPath, []byte(socketUnit), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write socket unit: %v\n", err)
return
}
fmt.Printf("Wrote %s\n", servicePath)
fmt.Printf("Wrote %s\n", socketPath)
fmt.Println("Run: systemctl --user daemon-reload && systemctl --user enable --now reddit-reader.socket")
}