From 714b2da0c7491f32a9001e1207258c3eb7c6ca19 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 11 Oct 2026 12:04:54 +0330 Subject: [PATCH] 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. --- cmd/lazyntfy/main.go | 185 ++++++++++++++++++++++++++++++++++++++ cmd/lazyntfy/main_test.go | 88 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 cmd/lazyntfy/main.go create mode 100644 cmd/lazyntfy/main_test.go diff --git a/cmd/lazyntfy/main.go b/cmd/lazyntfy/main.go new file mode 100644 index 0000000..5d57054 --- /dev/null +++ b/cmd/lazyntfy/main.go @@ -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 +} diff --git a/cmd/lazyntfy/main_test.go b/cmd/lazyntfy/main_test.go new file mode 100644 index 0000000..38082d4 --- /dev/null +++ b/cmd/lazyntfy/main_test.go @@ -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") + } +}