feat(setup): interactive first-run wizard with LLM probe and systemd install
This commit is contained in:
18
cmd/setup.go
Normal file
18
cmd/setup.go
Normal 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
240
internal/setup/setup.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user