feat(cmd): wire the CLI flags into the TUI

Parse flags, load configuration, restore remembered topics, prime the stream cursor from history, and run the Bubble Tea program.
This commit is contained in:
ahp
2026-10-11 12:04:54 +03:30
parent 8673f1e83a
commit 714b2da0c7
2 changed files with 273 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
tea "charm.land/bubbletea/v2"
"lazyntfy/internal/config"
"lazyntfy/internal/history"
"lazyntfy/internal/ntfy"
"lazyntfy/internal/tui"
)
// version is set at build time with -ldflags "-X main.version=...".
var version = "0.1.0"
const usageText = `lazyntfy is a terminal client for ntfy.
Usage:
lazyntfy [flags] [topic...]
With no topics, lazyntfy lists subscriptions, reservations, and grants
from the ntfy account, plus any subscribe: entries in client.yml.
Short topic names expand with default-host.
A token or user from that file, or from --token / --user, is sent as
Authorization and is never printed or written back.
Flags:
--config path client.yml path
--host url override default-host
--token token override default-token
--user user[:pass]
--since value replay window (default all)
--version
`
func main() {
os.Exit(run(os.Args[1:], os.Stderr))
}
func run(args []string, stderr io.Writer) int {
opt, ver, err := parseArgs(args, stderr)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
fmt.Fprintln(stderr, err)
return 2
}
if ver {
fmt.Println("lazyntfy", version)
return 0
}
app, err := config.Load(opt)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := ntfy.NewClient()
hist := openHistory()
if hist != nil {
defer hist.Close()
}
if len(opt.Topics) == 0 {
found, err := client.AccountTopics(ctx, app.DefaultHost, app.Auth)
if err != nil {
app.StartupStatus = err.Error()
} else {
app.Subs = config.MergeSubs(app.Subs, found)
}
app.Subs = config.MergeSubs(app.Subs, rememberedSubs(hist, app.DefaultHost, app.Auth))
}
if len(app.Subs) == 0 && app.StartupStatus == "" {
app.StartupStatus = "no topics yet — press a to add one"
}
mgr := ntfy.NewManager(client)
if app.Since == "all" {
mgr.Prime(newestFromHistory(hist, app.Subs))
}
mgr.Start(ctx, app.Subs, app.Since)
p := tea.NewProgram(tui.New(app, mgr.Notices(), client, mgr, hist))
if _, err := p.Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
// openHistory opens the on-disk history. It is best effort: when it fails the
// app still runs, it just does not remember this session.
func openHistory() *history.DB {
path := history.DefaultPath()
if path == "" {
return nil
}
db, err := history.Open(path)
if err != nil {
return nil
}
return db
}
// rememberedSubs returns topics recorded by earlier sessions. Credentials are
// never read back from disk; the current run's auth is applied instead.
func rememberedSubs(hist *history.DB, defaultHost string, auth config.Auth) []config.Sub {
if hist == nil {
return nil
}
topics, err := hist.Topics()
if err != nil {
return nil
}
var out []config.Sub
for _, sub := range topics {
if sub.Name == "" {
continue
}
if sub.Origin == "" {
origin, name, err := config.Resolve(sub.Name, defaultHost)
if err != nil {
continue
}
sub.Origin, sub.Name = origin, name
}
sub.Auth = auth
out = append(out, sub)
}
return out
}
// newestFromHistory returns the newest persisted event time per origin/topic,
// so the first stream can carry on where the last session stopped instead of
// replaying the server's whole cache.
func newestFromHistory(hist *history.DB, subs []config.Sub) map[string]int64 {
if hist == nil {
return nil
}
out := map[string]int64{}
for _, sub := range subs {
msgs, err := hist.Messages(sub.Name, 1)
if err != nil || len(msgs) == 0 {
continue
}
key := sub.Origin + "/" + sub.Name
if msgs[0].Time > out[key] {
out[key] = msgs[0].Time
}
}
return out
}
func parseArgs(args []string, stderr io.Writer) (config.Options, bool, error) {
fs := flag.NewFlagSet("lazyntfy", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprint(stderr, usageText) }
configPath := fs.String("config", "", "ntfy client.yml path")
host := fs.String("host", "", "override default-host")
token := fs.String("token", "", "override default-token")
user := fs.String("user", "", "user[:password]")
since := fs.String("since", "all", "replay window: all, duration, unix timestamp, or message id")
ver := fs.Bool("version", false, "print version")
if err := fs.Parse(args); err != nil {
return config.Options{}, false, err
}
return config.Options{
Path: *configPath,
PathSet: *configPath != "",
Host: *host,
Token: *token,
User: *user,
Since: *since,
Topics: fs.Args(),
}, *ver, nil
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"path/filepath"
"testing"
"lazyntfy/internal/config"
"lazyntfy/internal/history"
"lazyntfy/internal/store"
)
func TestParseArgsTopics(t *testing.T) {
var errBuf bytes.Buffer
opt, ver, err := parseArgs([]string{"--since", "1h", "--host", "https://ntfy.example", "alerts", "backups"}, &errBuf)
if err != nil || ver {
t.Fatal(err, ver)
}
if opt.Since != "1h" || opt.Host != "https://ntfy.example" || len(opt.Topics) != 2 || opt.Topics[0] != "alerts" {
t.Fatalf("%+v", opt)
}
}
func TestHelpExitsCleanly(t *testing.T) {
var errBuf bytes.Buffer
code := run([]string{"--help"}, &errBuf)
if code != 0 || !bytes.Contains(errBuf.Bytes(), []byte("lazyntfy")) {
t.Fatalf("code %d\n%s", code, errBuf.String())
}
}
func TestRememberedSubsUseCurrentHostAndAuth(t *testing.T) {
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.SaveTopic(config.Sub{Origin: "https://ntfy.example", Name: "alerts", Display: "Alerts"}); err != nil {
t.Fatal(err)
}
if err := db.SaveTopic(config.Sub{Name: "backups"}); err != nil {
t.Fatal(err)
}
auth := config.Auth{Token: "tk_current"}
got := rememberedSubs(db, "https://default.example", auth)
if len(got) != 2 {
t.Fatalf("got %+v", got)
}
byName := map[string]config.Sub{}
for _, sub := range got {
byName[sub.Name] = sub
}
if sub := byName["alerts"]; sub.Origin != "https://ntfy.example" || sub.Auth.Token != "tk_current" {
t.Fatalf("alerts %+v", sub)
}
if sub := byName["backups"]; sub.Origin != "https://default.example" || sub.Auth.Token != "tk_current" {
t.Fatalf("backups %+v", sub)
}
if rememberedSubs(nil, "https://default.example", auth) != nil {
t.Fatal("nil history should yield no topics")
}
}
func TestNewestFromHistory(t *testing.T) {
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.SaveMessage("https://ntfy.example", store.Message{ID: "a", Topic: "alerts", Time: 100}); err != nil {
t.Fatal(err)
}
if err := db.SaveMessage("https://ntfy.example", store.Message{ID: "b", Topic: "alerts", Time: 250}); err != nil {
t.Fatal(err)
}
subs := []config.Sub{{Origin: "https://ntfy.example", Name: "alerts"}}
got := newestFromHistory(db, subs)
if got["https://ntfy.example/alerts"] != 250 {
t.Fatalf("newest %+v", got)
}
if len(newestFromHistory(db, []config.Sub{{Origin: "https://ntfy.example", Name: "empty"}})) != 0 {
t.Fatal("topic without history should not be primed")
}
if newestFromHistory(nil, subs) != nil {
t.Fatal("nil history should yield no cursors")
}
}