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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user