diff --git a/.surface b/.surface index 6019717f..68d0edfd 100644 --- a/.surface +++ b/.surface @@ -129,6 +129,10 @@ hey labels --all hey labels --limit hey move hey move --to +hey omarchy +hey omarchy poll +hey omarchy poll --limit +hey omarchy poll --notify hey recordings hey recordings --all hey recordings --ends-on diff --git a/README.md b/README.md index 987cff93..1adc2921 100644 --- a/README.md +++ b/README.md @@ -368,23 +368,30 @@ read from `~/.local/state/omarchy/current/theme/`, and restyles live when you ru — an explicitly chosen file is trusted as written — or `NO_COLOR=1` to turn color off. ```bash -yay -S hey-cli # hey-cli is on the AUR +omarchy pkg aur add hey-cli # hey-cli is on the AUR +omarchy plugin add https://github.com/basecamp/omarchy-hey-plugin.git --enable hey setup omarchy # install into the desktop -hey setup omarchy --notify # also toast new Imbox mail (--no-notify turns it off) +hey setup omarchy --notify # toast new Imbox mail (--no-notify turns it off) hey setup omarchy --remove # take it all out again ``` -Setup installs a `HEY TUI` launcher entry, a `HEY` row in the SUPER+SPACE menu, a bar -indicator that lights when the Imbox has unread mail (no count, by design), and a +The bar gets the [HEY plugin](https://github.com/basecamp/omarchy-hey-plugin): the HEY +logo lights when the Imbox has unseen mail and opens a panel of recent threads. hey-cli is +its engine — the plugin runs `hey omarchy poll` for the Imbox and the toasts, and +`hey tui` nudges it to refresh the moment you archive or mark a thread. + +Setup installs a `HEY TUI` launcher entry, a `HEY` row in the SUPER+SPACE menu, and a `hey.toml.tpl` theme template so theme authors can tune the overlay. It prints the `bindings.lua` snippet for a keybinding rather than editing your file. Omarchy's shipped HEY web app, its SUPER+SHIFT+E binding and the mailto handler are left untouched. -`--notify` turns on new-mail toasts, off by default: the bar indicator's poll also sends -at most one notification per interval — `Sender — Subject` for one new thread, `N new in -Imbox` for more — replacing the previous toast rather than stacking, and clicking it -focuses the TUI. Omarchy's notification silencing (SUPER+CTRL+comma) mutes them like any -other app. See [docs/omarchy.md](docs/omarchy.md) for the details and what is planned next. +`--notify` turns on new-mail toasts, off by default, by flipping the plugin's `notify` +setting (the panel's toggle and `omarchy bar set 37signals.hey notify true --json` do the +same): each poll sends at most one notification — `Sender — Subject` for one new thread, +`N new in Imbox` for more — replacing the previous toast rather than stacking, and +clicking it focuses the TUI. Omarchy's notification silencing (SUPER+CTRL+comma) mutes +them like any other app. See [docs/omarchy.md](docs/omarchy.md) for the details and what +is planned next. ## Agent Skill diff --git a/docs/omarchy.md b/docs/omarchy.md index fd6ad376..24000542 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -68,7 +68,7 @@ failing step does not stop the others. |---|---|---| | Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` | | Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open | -| Bar indicator | inline command module `hey-unread` in `~/.config/omarchy/shell.json` | runs `hey omarchy bar-status` every 3 minutes; click focuses or launches the TUI. `--notify` / `--no-notify` toggle new-mail toasts by rewriting the module's exec — enablement lives where it acts, no config key | +| Bar plugin | the `37signals.hey` entry in `~/.config/omarchy/shell.json`'s bar layout | not installed by setup — `omarchy plugin add https://github.com/basecamp/omarchy-hey-plugin.git --enable` does that — but configured by it: `--notify` / `--no-notify` set or delete the entry's `notify` key, which the shell hot-reloads and the plugin passes to `hey omarchy poll`. An earlier inline `hey-unread` module is removed on sight, its notify choice carried over | | Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` | | Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match | @@ -77,30 +77,29 @@ Every surface — launcher, menu, bar click, keybinding — uses the same app-id rather than `TUI.float`: the float class is shared by every floating TUI, and focus-or-launch would grab whichever one was open. -If the user's `shell.json` has no `bar.layout` yet, the default layout from -`$OMARCHY_PATH/config/omarchy/shell.json` is copied in first; the shell treats a missing -layout as "use the defaults", so adding one module means spelling out the rest. A +Setup never adds a bar module of its own any more, so it never has to seed a layout. The +one place it still reads Omarchy's default layout is the legacy removal: the old install +copied the defaults in just to hold its module, and if removing the module leaves exactly +the current defaults, the layout goes too and the user is back to inheriting them. A `shell.json` that is not plain JSON is left alone and the step reports failure. -### `hey omarchy bar-status` +### `hey omarchy poll` -Hidden command the bar module runs. Prints -`{"text":"","tooltip":"Unread in Imbox","class":"active"}` when the Imbox has unread -mail and nothing otherwise (the `text` is the nf-fa-envelope glyph U+F0E0, which most -browsers render as nothing — it is not empty). HEY orders Imbox postings unseen-first, -so one page decides: any unread mail is on page 1. Logged out or offline also prints nothing and exits 0 — a bar -is no place for an error message. Credentials come from the keyring or the -`credentials.json` fallback exactly as for any other command, so it works from the -shell's headless context; token refresh happens in-process. +The engine under the bar plugin, and the one command the plugin depends on for the Imbox. +`hey omarchy poll --limit N --json` answers with the same `data` `hey box imbox --json` +does — `{"ok":true,"data":{...box, "postings":[...]}}`, the box and its postings newest +first, byte-identical for the same Imbox, cut to `--limit` with `next_history_url` cleared +when the cut dropped postings — so the plugin's parser did not change. The envelope +around it is the poll's own: a summary, a truncation notice that says to raise `--limit` +rather than pass `--all`, and none of `hey box`'s breadcrumbs. Errors are errors: logged out is the `auth` envelope the plugin turns into its +sign-in button, a global config that cannot be loaded is `config_error`, and a later page +that cannot be fetched fails the poll rather than handing the panel a short list to replace +its last complete one. The one thing that never fails the command is the toast. -### New-mail toasts (default off) - -`hey setup omarchy --notify` rewrites the bar module's exec to -`hey omarchy bar-status --notify`: the same 3-minute poll that lights the indicator also -diffs the unseen Imbox postings against a fingerprint file -(`~/.local/state/hey-cli/omarchy-poll.json`) and sends **at most one toast per tick** via -`omarchy-notification-send` — sparse notices, never a per-message firehose. One Imbox -fetch serves both the indicator and the toasts. +`--notify` makes the same read diff the unseen Imbox postings against a fingerprint file +(`~/.local/state/hey-cli/omarchy-poll.json`) and send **at most one toast per poll** via +`omarchy-notification-send` — sparse notices, never a per-message firehose. One Imbox fetch +serves the panel, the icon and the toasts. - **What counts as new**: an unseen posting not fingerprinted yet, or one whose `visible_entry_count` grew (a new reply on a known thread). Fingerprints avoid @@ -110,34 +109,69 @@ fetch serves both the indicator and the toasts. nothing — never toast the backlog. The fingerprints carry the identity they were taken for — server, account filter and the signed-in user's id — so after `hey accounts use`, a base URL change, or signing in as someone else by any route - (login, logout, `HEY_TOKEN`) the next tick reseeds silently instead of toasting the - other identity's backlog. Re-enabling with `--notify` after a `--no-notify` stretch - drops stale fingerprints for the same reason, and `--remove` keeps them while the bar - module could not actually be removed. + (login, logout, `HEY_TOKEN`) the next poll reseeds silently instead of toasting the + other identity's backlog. +- **A poll without `--notify` forgets the seed.** Toasts can be turned off by any route — + `hey setup omarchy --no-notify`, the plugin's own toggle, `omarchy bar set` — and every + one of them means the plugin starts polling without the flag. Those polls delete the + fingerprint file, so turning toasts back on always starts from a silent seed, whichever + route turned them on. (`hey setup omarchy --notify` drops the file too, belt and braces.) - **The whole unseen set is read when seeding.** HEY sorts Imbox postings unseen-first, so the poll follows pages while they are all-unseen and stops at the first seen posting. A seed (first run, or a new identity) reads them all, so no pre-existing - thread can later surface as new; a steady-state tick stops at ten pages, because new - mail always lands on page 1 and older threads are already fingerprinted. The - indicator-only path reads one page. Fingerprints prune to the postings still unseen - once the snapshot is complete; a truncated snapshot (cap reached, a page fetch - failed) keeps absent fingerprints instead. + thread can later surface as new; a steady-state poll stops at ten pages, because new + mail always lands on page 1 and older threads are already fingerprinted. Pages read + for the panel's `--limit` and pages read for the toasts are the same pages; whichever + consumer wants more decides. Fingerprints prune to the postings still unseen once the + snapshot is complete; a truncated snapshot (cap reached, a page fetch failed) keeps + absent fingerprints instead. - **One toast, replaced not stacked.** `Sender — Subject` for one new thread, `N new in Imbox` with the first few senders for more. The daemon's printed id (`-r -p`, the - `omarchy-display-text-size` pattern) is cached so the next tick replaces the on-screen + `omarchy-display-text-size` pattern) is cached so the next poll replaces the on-screen toast instead of stacking; a stale id after a shell restart just makes a fresh toast. +- **One toast across monitors.** The shell builds its bar once per monitor, so a + two-monitor desktop runs two plugin instances and two concurrent polls. The diff and the + send happen under a `flock` on the sidecar `omarchy-poll.json.lock` next to the state + file, so the second poll reads the fingerprints the first one just wrote and finds + nothing new. The sidecar is never unlinked while polls run (only `--remove` takes it + out) — a fresh inode would be a fresh lock. - **DND is honored.** The toast passes `--app-name HEY` deliberately: omarchy's default app-name `omarchy-action` bypasses notification silencing, so identifying as HEY is what makes SUPER+CTRL+comma mute the toasts (into history) like any other app. - **Clicking focuses the TUI** via the shared `omarchy-launch-or-focus-tui` exec hint, - which the shell runs itself so it survives shell restarts. -- **Same silence discipline as the bar**: any error — auth, network, a failed send — - produces no output beyond the bar JSON and exits 0. A failed fetch leaves the - fingerprints untouched, and a failed send keeps the undelivered postings out of them - so the toast retries on the next tick. - -`hey setup omarchy --no-notify` reverts the exec; a plain re-run leaves it as it is; -`--remove` deletes the state file along with everything else. + which the shell runs itself so it survives shell restarts. The plugin's panel is one + click away on the bar; the toast takes you to the thread's home. +- **A failed send is silent and retried**: it produces no error beyond the envelope the + panel already got, and the undelivered postings keep their previous fingerprints so + the toast comes back on the next poll. + +### The bar plugin + +[`basecamp/omarchy-hey-plugin`](https://github.com/basecamp/omarchy-hey-plugin) is the +face: the HEY logo in the bar (tinted when there is unseen mail), the panel with its +account switcher, `New for you` / `Previously seen` tabs, the Screener count, mark-as-seen, +and the setup flow that installs `hey-cli` from the AUR and signs you in. hey-cli is the +engine: `hey omarchy poll` for the Imbox and the toasts, `hey screener list --count` for +the Screener, `hey seen` for marking, `hey accounts list` and `hey auth status` for the +rest. The division was settled when both sides turned out to have built a bar indicator +without knowing of the other — two HEY icons, two Imbox pollers, two product stances in +one slot — and the answer was: **the CLI is the engine, the plugin is the face; one Imbox +fetch serves the panel, the icon and the toasts.** + +Settings flow the way every Omarchy bar widget's do: the plugin's manifest declares +`notify` (default off) alongside its refresh interval and thread limit, and the values +live as extra keys on the `{"id":"37signals.hey"}` entry in `shell.json`, hot-reloaded by +the shell. Three ways to flip a key, all equivalent: `hey setup omarchy --notify`, the +toggle in the panel header, or `omarchy bar set 37signals.hey notify true --json`. + +Freshness is pushed, not polled: after every posting mutation in `hey tui` — seen, trash, +spam, mute, every box move — and after every Screener decision, the TUI runs +`omarchy-shell -q 37signals.hey refresh` (3 s timeout, output discarded, a no-op off +Omarchy), so the icon goes dark the moment you archive the last unread thread instead of +at the next ten-minute poll. The plugin broadcasts that refresh to every per-monitor +instance and coalesces one that arrives mid-fetch into a follow-up, so a burst of +mutations costs a handful of IPC calls, not a fetch storm, and none of them is lost. CLI +mutations (`hey seen`, `hey move`) do not push yet; agents driving the CLI are a follow-up. ## Decisions @@ -149,8 +183,15 @@ fetch serves both the indicator and the toasts. is the cautionary precedent. - **Complement the shipped web app, never replace it.** Distinct desktop name, printed keybinding, the mailto handler left alone. -- **No HTML scraping to feed widgets.** The indicator uses the same typed SDK read as +- **No HTML scraping to feed widgets.** The poll uses the same typed SDK read as `hey box imbox`. +- **CLI is the engine, plugin is the face.** The plugin owns rendering and settings; the + CLI owns what needs HEY's semantics — pagination, what counts as new, a toast that + honors DND — where it is Go-tested once instead of re-derived in QML. The plugin's panel + does show an unread *number* per account; the bar icon itself stays a glyph that lights + or does not, and the toast never carries the unread total either — `N new in Imbox` is + how many threads arrived since the last poll, not how many are waiting. That divergence + is the plugin's call and is recorded here rather than papered over. ## Follow-ups, in rough order @@ -162,16 +203,16 @@ fetch serves both the indicator and the toasts. `claude.json.tpl`; an Install-menu TUI row; possibly branching the mailto handler to the TUI when installed. (An AUR package already ships: `yay -S hey-cli`, published by the release workflow.) -4. **Shell plugin graduation** for the bar widget: `manifest.json`, a settings panel, - and event-driven freshness — refreshing the indicator the moment a thread is - archived in the TUI. That needs a real widget plugin: inline `command` modules are - interval-only, with no IPC to force a re-run (`Bar.qml` has no `IpcHandler` and - `omarchy bar` has no refresh verb), which is also why the toasts share the interval - poll rather than pushing. +4. **Plugin singletons**: the shell builds a bar per monitor, so a two-monitor desktop + polls twice. The toasts are safe (one `flock`), the refresh is broadcast, but a single + polling leader per shell would halve the API load; that is plugin architecture and + lives in `omarchy-hey-plugin`. +5. **Push from the CLI too**: `hey seen` and `hey move` could nudge the plugin the way + the TUI does, so an agent archiving mail keeps the bar honest. ## Anti-features, recorded -- No unread **count**. +- No unread **count** in the bar icon or the toast. - No per-message notification firehose. - No full hex theme port. - No auto-editing `~/.config/hypr/bindings.lua`. diff --git a/internal/cmd/accounts_test.go b/internal/cmd/accounts_test.go index 1b94f582..c3fa4e1f 100644 --- a/internal/cmd/accounts_test.go +++ b/internal/cmd/accounts_test.go @@ -28,7 +28,7 @@ func TestCommandAccountScopePolicy(t *testing.T) { {args: []string{"accounts", "list"}, want: false}, {args: []string{"auth", "status"}, want: false}, {args: []string{"config", "show"}, want: false}, - {args: []string{"omarchy", "bar-status"}, want: false}, + {args: []string{"omarchy", "poll"}, want: false}, } { command, _, err := root.Find(test.args) if err != nil { diff --git a/internal/cmd/box.go b/internal/cmd/box.go index fd284686..c10ad15d 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "io" "net/url" "strconv" "strings" @@ -84,21 +85,7 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { notice := boxTruncationNotice(len(postings), total, hasMore, c.all) if writer.IsStyled() { - fmt.Fprintf(cmd.OutOrStdout(), "Box: %s (%s)\n\n", resp.Name, resp.Kind) - - table := newTable(cmd.OutOrStdout()) - table.addRow([]string{"Thread", "From", "Summary", "Date"}) - for _, p := range postings { - displayID := resolvePostingTopicID(p) - if displayID == 0 { - displayID = p.Id - } - table.addRow([]string{fmt.Sprintf("%d", displayID), p.Creator.Name, truncate(p.Summary, 60), formatDate(p.CreatedAt)}) - } - table.print() - if notice != "" { - fmt.Fprintln(cmd.OutOrStdout(), notice) - } + printBoxTable(cmd.OutOrStdout(), resp, postings, notice) return nil } @@ -106,26 +93,38 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { return writeOK(resp, output.WithSummary(boxSummary(len(postings), resp.Name)), output.WithNotice(notice), - output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "read", - Command: "hey threads ", - Description: "Read an email thread", - }, - output.Breadcrumb{ - Action: "move", - Command: "hey move --to ", - Description: "Move an email thread to another box", - }, - output.Breadcrumb{ - Action: "compose", - Command: "hey compose --to --subject ", - Description: "Compose a new message", - }, - ), + output.WithBreadcrumbs(boxBreadcrumbs()...), ) } +// boxBreadcrumbs are the next actions an agent can take from a box listing; +// hey omarchy poll shares them, being a box listing too. +func boxBreadcrumbs() []output.Breadcrumb { + return []output.Breadcrumb{ + {Action: "read", Command: "hey threads ", Description: "Read an email thread"}, + {Action: "move", Command: "hey move --to ", Description: "Move an email thread to another box"}, + {Action: "compose", Command: "hey compose --to --subject ", Description: "Compose a new message"}, + } +} + +func printBoxTable(w io.Writer, resp *generated.BoxShowResponse, postings []generated.Posting, notice string) { + fmt.Fprintf(w, "Box: %s (%s)\n\n", resp.Name, resp.Kind) + + table := newTable(w) + table.addRow([]string{"Thread", "From", "Summary", "Date"}) + for _, p := range postings { + displayID := resolvePostingTopicID(p) + if displayID == 0 { + displayID = p.Id + } + table.addRow([]string{fmt.Sprintf("%d", displayID), p.Creator.Name, truncate(p.Summary, 60), formatDate(p.CreatedAt)}) + } + table.print() + if notice != "" { + fmt.Fprintln(w, notice) + } +} + func boxSummary(count int, name string) string { return fmt.Sprintf("%d %s in %s", count, threadNoun(count), name) } diff --git a/internal/cmd/omarchy.go b/internal/cmd/omarchy.go index 5b5b4ee1..3f02ed15 100644 --- a/internal/cmd/omarchy.go +++ b/internal/cmd/omarchy.go @@ -16,14 +16,12 @@ import ( "github.com/spf13/cobra" - "github.com/basecamp/hey-sdk/go/pkg/generated" - "github.com/basecamp/hey-cli/internal/output" ) // Omarchy integration: `hey setup omarchy` installs hey-cli into the desktop -// (launcher entry, menu rows, bar indicator, theme template) and `hey omarchy -// bar-status` is the command the bar indicator runs. +// (launcher entry, menu rows, theme template) and configures the 37signals.hey +// bar plugin, whose engine is `hey omarchy poll` (omarchy_poll.go). // // Omarchy already ships HEY as a web app (SUPER+SHIFT+E, the mailto handler, a // HEY.desktop). Everything here complements that under its own names and never @@ -35,7 +33,8 @@ var omarchyThemeTemplate string const ( omarchyAppID = "org.omarchy.hey" omarchyDesktopName = "HEY TUI" - omarchyBarModuleID = "hey-unread" + omarchyBarModuleID = "hey-unread" // the inline module earlier releases installed; removed on sight + omarchyBarPluginID = "37signals.hey" // the bar plugin's module id in shell.json's layout omarchyMenuBegin = " // >>> hey-cli — managed by `hey setup omarchy`, do not edit between the markers" omarchyMenuEnd = " // <<< hey-cli" omarchyFocusCommand = "omarchy-launch-or-focus-tui --app-id=" + omarchyAppID + " hey tui" @@ -159,31 +158,28 @@ type omarchyStep struct { type omarchySetup struct { env omarchyEnv - notify *bool // nil keeps the bar module's current exec as it is + notify *bool // nil keeps the bar plugin's notify setting as it is } func (s omarchySetup) apply() []omarchyStep { - return []omarchyStep{ - s.installDesktop(), - s.installMenu(), - s.installBar(), - s.installTemplate(), - } + steps := []omarchyStep{s.installDesktop(), s.installMenu()} + steps = append(steps, s.configureBarPlugin()...) + return append(steps, s.installTemplate()) } func (s omarchySetup) remove() []omarchyStep { - steps := []omarchyStep{ - s.removeDesktop(), - s.removeMenu(), - s.removeBar(), - s.removeTemplate(), - } - // The fingerprints go only once the module that uses them is gone: while a - // failed bar removal leaves the poller scheduled, deleting them would make - // its next tick reseed and swallow the mail that arrived in between. - if steps[2].failure != nil { - return append(steps, omarchyStep{Name: "poll state", Path: omarchyPollStatePath(), Status: "kept", - Detail: "bar module still installed; fingerprints kept for its next tick"}) + steps := []omarchyStep{s.removeDesktop(), s.removeMenu()} + steps = append(steps, s.removeBar()...) + steps = append(steps, s.removeTemplate()) + // The fingerprints go only once the notify setting that uses them is gone: + // while a failed bar step leaves the plugin polling with --notify, deleting + // them would make its next poll reseed and swallow the mail that arrived + // in between. + for _, step := range steps { + if step.Name == "bar plugin" && step.failure != nil { + return append(steps, omarchyStep{Name: "poll state", Path: omarchyPollStatePath(), Status: "kept", + Detail: "bar plugin still notifying; fingerprints kept for its next poll"}) + } } return append(steps, s.removePollState()) } @@ -341,28 +337,16 @@ func stripMenuBlock(content string) string { return content[:start] + after } -// Bar: an inline command module in shell.json's bar layout. The shell hot-reloads -// the file, so the indicator appears as soon as it is written. Toast enablement -// lives in the module's exec string — no config key, visible where it acts, -// removed with --remove. +// Bar: the unread indicator is the 37signals.hey bar plugin +// (github.com/basecamp/omarchy-hey-plugin), which runs `hey omarchy poll`. Setup +// does not install it — a plugin is a git clone the user adds with `omarchy +// plugin add` — but it finds the plugin's layout entry in shell.json and flips +// its `notify` setting there, the way `omarchy bar set` would; the shell +// hot-reloads the file. Earlier releases installed an inline `hey-unread` +// command module instead, which setup now removes — two pollers in one slot — +// carrying its notify choice over to the plugin when the plugin has none. -func omarchyBarExec(notify bool) string { - if notify { - return "hey omarchy bar-status --notify" - } - return "hey omarchy bar-status" -} - -func omarchyBarModule(notify bool) map[string]any { - return map[string]any{ - "id": omarchyBarModuleID, - "type": "command", - "exec": omarchyBarExec(notify), - "interval": 180, - "tooltip": "HEY", - "onClick": omarchyFocusCommand, - } -} +const omarchyBarPluginInstall = "omarchy plugin add https://github.com/basecamp/omarchy-hey-plugin.git --enable" func notifyDetail(notify bool) string { if notify { @@ -371,93 +355,134 @@ func notifyDetail(notify bool) string { return "notifications off" } -func (s omarchySetup) installBar() omarchyStep { +// configureBarPlugin reports one step for the plugin and, only when there was +// one to remove, a step for the legacy module before it. +func (s omarchySetup) configureBarPlugin() []omarchyStep { path := s.env.shellPath() shell, err := s.loadShellConfig() if err != nil { - return stepResult("bar indicator", path, false, err, "", "") + return []omarchyStep{stepResult("bar plugin", path, false, err, "", "")} } - layout, err := s.barLayout(shell) + layout, err := existingBarLayout(shell) if err != nil { - return stepResult("bar indicator", path, false, err, "", "") + return []omarchyStep{stepResult("bar plugin", path, false, err, "", "")} } - module := barLayoutModule(layout, omarchyBarModuleID) - notify := s.notify != nil && *s.notify - if module == nil { - if notify { - // Enabling toasts with stale fingerprints around would toast the - // accumulated diff; if they cannot be dropped, do not enable. - if _, err := removeFileIfPresent(omarchyPollStatePath()); err != nil { - return stepResult("bar indicator", path, false, fmt.Errorf("cannot drop stale poll state: %w", err), "", "") - } - } - right, ok := layout["right"].([]any) - if raw, present := layout["right"]; present && raw != nil && !ok { - return stepResult("bar indicator", path, false, fmt.Errorf("shell.json: bar.layout.right is %T, not a list", raw), "", "") + legacy, legacyNotified := s.removeLegacyBarModule(shell, layout) + var steps []omarchyStep + if legacy { + steps = append(steps, omarchyStep{Name: "bar indicator", Path: path, Status: "removed", + Detail: "hey-unread module removed; the " + omarchyBarPluginID + " plugin replaces it"}) + } + + plugin := barLayoutModule(layout, omarchyBarPluginID) + if plugin == nil { + detail := "install the bar plugin: " + omarchyBarPluginInstall + if legacyNotified { + detail += "; then hey setup omarchy --notify to keep the toasts" } - layout["right"] = append([]any{omarchyBarModule(notify)}, right...) - changed, err := writeJSONFile(path, shell) - step := stepResult("bar indicator", path, changed, err, "installed", "unchanged") - if err == nil && s.notify != nil { - step.Detail = notifyDetail(notify) + steps = append(steps, omarchyStep{Name: "bar plugin", Path: path, Status: "skipped", Detail: detail}) + return s.writeBarSteps(steps, shell, legacy) + } + + want := s.notify + if want == nil && legacyNotified { + // The legacy module was toasting and the plugin has not been told + // either way: keep the user's choice rather than silently turning + // the toasts off with the module. + if _, has := plugin["notify"]; !has { + on := true + want = &on } - return step } - // An existing module is reconciled field by field, keeping its section and - // position, so a re-run after an upgrade picks up a changed exec, click - // command or interval; only the notify choice is preserved when the caller - // did not state one. - exec, _ := module["exec"].(string) - wasNotifying := strings.HasSuffix(exec, " --notify") - if s.notify == nil { - notify = wasNotifying - } - if notify && !wasNotifying { - // Turning toasts (back) on: drop any stale fingerprints so the first - // tick reseeds from the current Imbox instead of toasting whatever - // accumulated while they were off. If they cannot be dropped, fail - // rather than enable a poller that would toast the backlog. - if _, err := removeFileIfPresent(omarchyPollStatePath()); err != nil { - return stepResult("bar indicator", path, false, fmt.Errorf("cannot drop stale poll state: %w", err), "", "") + // The plugin step reports its own setting only: a legacy removal in the + // same pass is the other step's news. + settingChanged := false + if want != nil { + current, has := plugin["notify"] + switch { + case *want && current != true: + // Turning toasts (back) on: drop any stale fingerprints so the first + // poll reseeds from the current Imbox instead of toasting whatever + // accumulated while they were off. If they cannot be dropped, fail + // rather than enable a poll that would toast the backlog. + if err := removeOmarchyPollState(); err != nil { + steps = append(steps, stepResult("bar plugin", path, false, fmt.Errorf("cannot drop stale poll state: %w", err), "", "")) + return s.writeBarSteps(steps, shell, legacy) + } + plugin["notify"] = true + settingChanged = true + case !*want && has: + delete(plugin, "notify") + settingChanged = true } } - desired := omarchyBarModule(notify) - changed := !sameJSON(module, desired) - if changed { - clear(module) - for key, value := range desired { - module[key] = value - } - if _, err := writeJSONFile(path, shell); err != nil { - return stepResult("bar indicator", path, false, err, "", "") - } + step := omarchyStep{Name: "bar plugin", Path: path, Status: "unchanged", Detail: notifyDetail(barPluginNotifies(plugin))} + if settingChanged { + step.Status = "installed" } - step := stepResult("bar indicator", path, changed, nil, "installed", "unchanged") - if s.notify != nil { - step.Detail = notifyDetail(notify) + return s.writeBarSteps(append(steps, step), shell, legacy || settingChanged) +} + +// writeBarSteps writes shell.json when anything changed and turns every +// reported step into a failure when the write did not land — a removal or a +// setting that is not on disk did not happen. +func (s omarchySetup) writeBarSteps(steps []omarchyStep, shell map[string]any, changed bool) []omarchyStep { + if !changed { + return steps } - return step + if err := writeJSONFile(s.env.shellPath(), shell); err != nil { + for i := range steps { + steps[i].Status, steps[i].Detail, steps[i].failure = "failed", err.Error(), err + } + } + return steps } -// sameJSON compares two values by their JSON encoding, which is what makes a -// decoded float64(180) and a literal 180 read as equal. -func sameJSON(a, b any) bool { - left, errA := json.Marshal(a) - right, errB := json.Marshal(b) - return errA == nil && errB == nil && bytes.Equal(left, right) +// barPluginNotifies reads the plugin entry's notify setting as the plugin does: +// only a JSON true turns toasts on. +func barPluginNotifies(plugin map[string]any) bool { + notify, _ := plugin["notify"].(bool) + return notify } -func (s omarchySetup) removeBar() omarchyStep { +// removeBar takes out what setup wrote into the bar layout: a legacy hey-unread +// module and the plugin entry's notify key. The plugin entry itself stays — it +// is the user's, added with omarchy plugin add. +func (s omarchySetup) removeBar() []omarchyStep { path := s.env.shellPath() shell, err := s.loadShellConfig() if err != nil { - return stepResult("bar indicator", path, false, err, "", "") + return []omarchyStep{stepResult("bar indicator", path, false, err, "", ""), stepResult("bar plugin", path, false, err, "", "")} } bar, _ := shell["bar"].(map[string]any) layout, _ := bar["layout"].(map[string]any) - if barLayoutModule(layout, omarchyBarModuleID) == nil { - return stepResult("bar indicator", path, false, nil, "", "absent") + legacy, _ := s.removeLegacyBarModule(shell, layout) + notified := false + if plugin := barLayoutModule(layout, omarchyBarPluginID); plugin != nil { + _, notified = plugin["notify"] + delete(plugin, "notify") + } + steps := []omarchyStep{ + stepResult("bar indicator", path, legacy, nil, "removed", "absent"), + stepResult("bar plugin", path, notified, nil, "removed", "absent"), + } + return s.writeBarSteps(steps, shell, legacy || notified) +} + +// removeLegacyBarModule drops the inline hey-unread module earlier releases +// installed, reporting whether there was one and whether it was toasting. +// Only the map form is ours: a string-form entry sharing the id is unowned +// and stays. Install used to seed the layout from Omarchy's defaults just to +// hold the module; if what remains is exactly the current defaults, the +// layout goes too, so the user is back to inheriting future default-layout +// changes. +func (s omarchySetup) removeLegacyBarModule(shell, layout map[string]any) (removed, notified bool) { + module := barLayoutModule(layout, omarchyBarModuleID) + if module == nil { + return false, false } + exec, _ := module["exec"].(string) + notified = strings.HasSuffix(exec, " --notify") for section, entries := range layout { list, ok := entries.([]any) if !ok { @@ -465,25 +490,28 @@ func (s omarchySetup) removeBar() omarchyStep { } kept := make([]any, 0, len(list)) for _, entry := range list { - // Only the map form is ours: install treats string-form entries as - // unowned, so removal must too. if _, isMap := entry.(map[string]any); !isMap || barEntryID(entry) != omarchyBarModuleID { kept = append(kept, entry) } } layout[section] = kept } - // Install may have seeded the layout from Omarchy's defaults just to hold our - // module. If what remains is exactly the current defaults, drop it so the user - // goes back to inheriting future default-layout changes. - if defaults, defErr := s.defaultBarLayout(); defErr == nil && sameJSON(layout, defaults) { + if defaults, err := s.defaultBarLayout(); err == nil && sameJSON(layout, defaults) { + bar, _ := shell["bar"].(map[string]any) delete(bar, "layout") if len(bar) == 0 { delete(shell, "bar") } } - changed, err := writeJSONFile(path, shell) - return stepResult("bar indicator", path, changed, err, "removed", "absent") + return true, notified +} + +// sameJSON compares two values by their JSON encoding, which is what makes a +// decoded float64(180) and a literal 180 read as equal. +func sameJSON(a, b any) bool { + left, errA := json.Marshal(a) + right, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(left, right) } // loadShellConfig reads the user's shell.json. The shell ignores any config @@ -529,32 +557,24 @@ func decodeJSONObject(data []byte) (map[string]any, error) { return object, nil } -// barLayout returns the user's bar layout, seeding it from Omarchy's default layout -// when the user has never customized the bar — the shell treats a missing layout -// as "use the defaults", so adding one module means spelling the rest out too. -func (s omarchySetup) barLayout(shell map[string]any) (map[string]any, error) { - // A present value of the wrong type is someone's configuration, not an - // absence: refuse to replace it. +// existingBarLayout returns the user's bar layout, or nil when shell.json has +// none — the shell then uses its defaults, and a plugin entry can only be in a +// layout that is spelled out. A present value of the wrong type is someone's +// configuration, not an absence: refuse to touch the file. +func existingBarLayout(shell map[string]any) (map[string]any, error) { bar, ok := shell["bar"].(map[string]any) if raw, present := shell["bar"]; present && raw != nil && !ok { return nil, fmt.Errorf("shell.json: bar is %T, not an object", raw) } - if bar == nil { - bar = map[string]any{} - shell["bar"] = bar - } layout, ok := bar["layout"].(map[string]any) if raw, present := bar["layout"]; present && raw != nil && !ok { return nil, fmt.Errorf("shell.json: bar.layout is %T, not an object", raw) } - if ok { - return layout, nil - } - layout, err := s.defaultBarLayout() - if err != nil { - return nil, fmt.Errorf("no bar layout in shell.json to extend: %w", err) + for section, entries := range layout { + if _, isList := entries.([]any); !isList && entries != nil { + return nil, fmt.Errorf("shell.json: bar.layout.%s is %T, not a list", section, entries) + } } - bar["layout"] = layout return layout, nil } @@ -668,13 +688,21 @@ func (s omarchySetup) removeTemplate() omarchyStep { return stepResult("theme template", path, changed, err, "removed", "absent") } -// Poll state: the new-mail fingerprint file bar-status --notify keeps. Setup -// never creates it, but --remove takes it out with everything else. +// Poll state: the new-mail fingerprint file `hey omarchy poll --notify` keeps. +// Setup never creates it, but --remove takes it out with everything else. func (s omarchySetup) removePollState() omarchyStep { path := omarchyPollStatePath() - changed, err := removeFileIfPresent(path) - return stepResult("poll state", path, changed, err, "removed", "absent") + existed := false + if _, err := os.Stat(path); err == nil { + existed = true + } + err := removeOmarchyPollState() + // The lock sidecar goes only here, at teardown: a live poller holding it + // would otherwise be left with an unlinked inode while the next poll + // locks a fresh one, and the two would diff at once. + _, _ = removeFileIfPresent(omarchyPollLockPath()) + return stepResult("poll state", path, existed && err == nil, err, "removed", "absent") } // --- File helpers --- @@ -727,15 +755,16 @@ func removeFileIfPresent(path string) (bool, error) { return err == nil, err } -func writeJSONFile(path string, value any) (bool, error) { +func writeJSONFile(path string, value any) error { var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetIndent("", " ") encoder.SetEscapeHTML(false) if err := encoder.Encode(value); err != nil { - return false, err + return err } - return writeFileIfChanged(path, buf.Bytes(), 0o644) + _, err := writeFileIfChanged(path, buf.Bytes(), 0o644) + return err } // --- hey setup omarchy --- @@ -755,12 +784,15 @@ func newSetupOmarchyCommand() *setupOmarchyCommand { Args: cobra.NoArgs, Short: "Install hey into the Omarchy desktop", Long: `Install hey into the Omarchy desktop: a launcher entry, rows in the SUPER+SPACE -menu, an unread indicator on the bar, and a theme template so themes can tune the -TUI's accent colors. Every piece is idempotent and --remove takes them all out again. +menu, and a theme template so themes can tune the TUI's accent colors. Every piece +is idempotent and --remove takes them all out again. ---notify also toasts new Imbox mail on the indicator's poll — at most one toast per -interval, replaced rather than stacked, silenced by the notification DND toggle. ---no-notify turns the toasts back off; a plain re-run leaves them as they are. +The bar indicator is the 37signals.hey plugin, which runs hey omarchy poll; install +it with omarchy plugin add https://github.com/basecamp/omarchy-hey-plugin.git --enable. +--notify turns on its new-mail toasts — at most one toast per poll, replaced rather +than stacked, silenced by the notification DND toggle — by setting notify on the +plugin's entry in shell.json. --no-notify turns the toasts back off; a plain re-run +leaves them as they are. Theming needs none of this: on Omarchy the TUI already follows the active theme.`, Example: ` hey setup omarchy @@ -772,7 +804,7 @@ Theming needs none of this: on Omarchy the TUI already follows the active theme. RunE: setupOmarchyCommand.run, } setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.remove, "remove", false, "Remove everything hey setup omarchy installed") - setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.notify, "notify", false, "Toast new Imbox mail when the bar indicator polls") + setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.notify, "notify", false, "Toast new Imbox mail when the bar plugin polls") setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.noNotify, "no-notify", false, "Turn new-mail toasts back off") setupOmarchyCommand.cmd.MarkFlagsMutuallyExclusive("notify", "no-notify") setupOmarchyCommand.cmd.MarkFlagsMutuallyExclusive("notify", "remove") @@ -855,148 +887,3 @@ func (c *setupOmarchyCommand) run(cmd *cobra.Command, args []string) error { } return writeOK(data, output.WithSummary(summary)) } - -// --- hey omarchy bar-status --- - -type omarchyCommand struct { - cmd *cobra.Command -} - -func newOmarchyCommand() *omarchyCommand { - omarchyCommand := &omarchyCommand{} - omarchyCommand.cmd = &cobra.Command{ - Use: "omarchy", - Short: "Commands the Omarchy desktop integration runs", - Hidden: true, - } - omarchyCommand.cmd.AddCommand(newOmarchyBarStatusCommand().cmd) - return omarchyCommand -} - -type omarchyBarStatusCommand struct { - cmd *cobra.Command - notify bool - env omarchyEnv -} - -func newOmarchyBarStatusCommand() *omarchyBarStatusCommand { - omarchyBarStatusCommand := &omarchyBarStatusCommand{env: liveOmarchyEnv()} - omarchyBarStatusCommand.cmd = &cobra.Command{ - Use: "bar-status", - Short: "Print the bar indicator for unread Imbox mail", - Long: `Print a Waybar-style JSON module when the Imbox has unread mail and nothing when -it does not. Never fails: when hey is logged out or offline the indicator simply -stays dark, because a bar is no place for an error message. - -With --notify, also toast newly unseen Imbox mail via omarchy-notification-send — -at most one toast per run, replacing the previous one rather than stacking.`, - Args: cobra.NoArgs, - RunE: omarchyBarStatusCommand.run, - } - omarchyBarStatusCommand.cmd.Flags().BoolVar(&omarchyBarStatusCommand.notify, "notify", false, "Toast new unseen Imbox mail") - return omarchyBarStatusCommand -} - -// configDegraded is set by the root pre-run when the global configuration could -// not be loaded and a config-ignoring command went on with the defaults. The -// poller then stays dark: lighting the indicator against a guessed server would -// be a lie, and an error is not an option either. -var configDegraded bool - -func (c *omarchyBarStatusCommand) run(cmd *cobra.Command, args []string) error { - if configDegraded || !authMgr.IsAuthenticated() { - return nil - } - // The omarchy command is exempt from pre-run account scoping so a failed - // selection (offline, account gone) can never surface as an error here; - // the configured account still applies when it can be selected. - if err := selectConfiguredAccount(cmd.Context()); err != nil { - return nil //nolint:nilerr // a bar is no place for an error message - } - // The indicator only needs to know whether anything is unseen, which the - // first page answers. Toasts need the unseen set: capped on a steady-state - // tick (new mail always lands on page 1), exhaustive when seeding — a first - // run or a new identity — so no pre-existing thread can later read as new. - pages, identity, notify := 1, "", c.notify - if notify { - var ok bool - if identity, ok = omarchyPollIdentity(cmd.Context()); !ok { - notify = false - } else if state, existed := loadOmarchyPollState(); !existed || state.Identity != identity { - pages = unseenSeedPageCap - } else { - pages = unseenPageCap - } - } - unseen, complete, ok := unseenImboxPostings(cmd.Context(), pages) - if !ok { - return nil - } - if notify { - notifyNewMail(c.env, identity, unseen, complete) - } - if len(unseen) == 0 { - return nil - } - _, err := fmt.Fprintln(cmd.OutOrStdout(), omarchyBarModuleJSON()) - return err -} - -// omarchyBarModuleJSON is the Waybar-style module the bar shows when mail is -// unread. The "active" class is what the shell's command widget highlights. -func omarchyBarModuleJSON() string { - module, _ := json.Marshal(map[string]string{ // fixed strings cannot fail to marshal - "text": omarchyBarGlyph, - "tooltip": "Unread in Imbox", - "class": "active", - }) - return string(module) -} - -// Page limits for following an all-unseen Imbox. A steady-state tick stops at -// ten pages — three hundred unseen threads — because new mail always lands on -// page 1 and older threads are already fingerprinted. Seeding reads the whole -// unseen set (bounded only as the box command is) so that no pre-existing -// thread can later surface as new; it happens once per identity. -var ( - unseenPageCap = 10 - // The +1 is the initial page: maxAdditionalPages caps pages fetched after - // it (as in paginateBoxPostings), so a box whose unseen set spans exactly - // the cap still finds its closing seen page and seeds completely. - unseenSeedPageCap = maxAdditionalPages + 1 -) - -// unseenImboxPostings returns the unseen Imbox postings, whether they are the -// complete unseen set, and whether the fetch succeeded. HEY orders Imbox -// postings unseen-first, so a page holding any seen posting (or nothing at -// all) closes the unseen set; while a page is all unseen the next one is -// fetched, up to maxPages. Unknown (offline, server error) counts as clear: -// the indicator stays dark rather than lying either way loudly, and the notify -// fingerprints stay untouched. -func unseenImboxPostings(ctx context.Context, maxPages int) (unseen []generated.Posting, complete, ok bool) { - page, err := sdk.Boxes().GetImbox(ctx, nil) - if err != nil || page == nil { - return nil, false, false - } - for pages := 1; ; pages++ { - seenOnPage := false - for _, posting := range page.Postings { - if posting.Seen { - seenOnPage = true - } else { - unseen = append(unseen, posting) - } - } - if seenOnPage || len(page.Postings) == 0 || page.NextHistoryUrl == "" { - return unseen, true, true - } - if pages >= maxPages { - return unseen, false, true - } - // A page that cannot be fetched leaves what was read as a truncated - // snapshot: still enough to light the bar, not enough to prune by. - if page, err = fetchNextBoxPage(ctx, page.NextHistoryUrl); err != nil || page == nil { - return unseen, false, true - } - } -} diff --git a/internal/cmd/omarchy_lock_other.go b/internal/cmd/omarchy_lock_other.go new file mode 100644 index 00000000..aa8b6a41 --- /dev/null +++ b/internal/cmd/omarchy_lock_other.go @@ -0,0 +1,6 @@ +//go:build !unix + +package cmd + +// Omarchy is Linux; elsewhere there is no second bar instance to race. +func withOmarchyPollLock(fn func()) { fn() } diff --git a/internal/cmd/omarchy_lock_unix.go b/internal/cmd/omarchy_lock_unix.go new file mode 100644 index 00000000..faa5c000 --- /dev/null +++ b/internal/cmd/omarchy_lock_unix.go @@ -0,0 +1,37 @@ +//go:build unix + +package cmd + +import ( + "os" + "path/filepath" + "syscall" +) + +// withOmarchyPollLock serializes the fingerprint diff and toast across +// concurrent polls. The Omarchy bar is built once per monitor, so two plugin +// instances can poll at the same moment; without the lock both would read the +// same fingerprints, both find the same thread new, and toast it twice. The +// lock is advisory and best-effort: if it cannot be taken the poll still +// notifies, because a missed lock is rarer than a missed toast is bad. +func withOmarchyPollLock(fn func()) { + path := omarchyPollLockPath() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + fn() + return + } + // Read-only: the descriptor exists to be flocked, nothing is ever written + // through it, so there is nothing a failed close could lose. + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o600) //nolint:gosec // G304: fixed path under the state dir + if err != nil { + fn() + return + } + defer file.Close() //nolint:errcheck // read-only handle, nothing to flush + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { //nolint:gosec // G115: a descriptor fits in an int + fn() + return + } + defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) //nolint:errcheck,gosec // released with the descriptor anyway; G115 as above + fn() +} diff --git a/internal/cmd/omarchy_lock_unix_test.go b/internal/cmd/omarchy_lock_unix_test.go new file mode 100644 index 00000000..5b0a8cf3 --- /dev/null +++ b/internal/cmd/omarchy_lock_unix_test.go @@ -0,0 +1,36 @@ +//go:build unix + +package cmd + +import ( + "sync" + "testing" + "time" +) + +func TestOmarchyPollLockSerializesConcurrentPolls(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + var mu sync.Mutex + inside, overlapped := 0, false + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + withOmarchyPollLock(func() { + mu.Lock() + inside++ + if inside > 1 { + overlapped = true + } + mu.Unlock() + time.Sleep(20 * time.Millisecond) + mu.Lock() + inside-- + mu.Unlock() + }) + }) + } + wg.Wait() + if overlapped { + t.Error("two polls must never diff the fingerprints at once — that is the duplicate toast") + } +} diff --git a/internal/cmd/omarchy_notify.go b/internal/cmd/omarchy_notify.go index 41e87abf..30ea9f02 100644 --- a/internal/cmd/omarchy_notify.go +++ b/internal/cmd/omarchy_notify.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -85,6 +86,38 @@ func omarchyPollStatePath() string { return filepath.Join(config.StateDir(), "omarchy-poll.json") } +func omarchyPollLockPath() string { + return omarchyPollStatePath() + ".lock" +} + +// omarchyPollStateUsable reports whether the state directory resolves to an +// absolute path. config.StateDir is empty without HOME and relative with a +// relative XDG_STATE_HOME; either would put the fingerprints — and their +// deletion — in the working directory, next to whatever file happens to share +// the name. +func omarchyPollStateUsable() bool { + return filepath.IsAbs(config.StateDir()) +} + +var errPollStateDir = errors.New("no absolute state directory: set HOME or XDG_STATE_HOME") + +// removeOmarchyPollState forgets the fingerprints. A poll that is not +// notifying calls this on every run, so turning toasts on — by any route: hey +// setup omarchy --notify, the plugin's own toggle, omarchy bar set — always +// starts from a silent seed instead of toasting whatever accumulated while +// they were off. It takes the poll lock, so a notifying poll on another +// monitor that is mid-diff finishes writing before the file goes, and it +// leaves the lock sidecar alone: unlinking an inode another poller holds would +// let the next poll lock a fresh one and the two diff at once. +func removeOmarchyPollState() error { + if !omarchyPollStateUsable() { + return errPollStateDir + } + var err error + withOmarchyPollLock(func() { _, err = removeFileIfPresent(omarchyPollStatePath()) }) + return err +} + // loadOmarchyPollState returns the saved state and whether a state file // existed. No file means first run: seed the fingerprints, toast nothing — // never toast the backlog. @@ -104,8 +137,10 @@ func loadOmarchyPollState() (omarchyPollState, bool) { } func saveOmarchyPollState(state omarchyPollState) error { - _, err := writeJSONFile(omarchyPollStatePath(), state) - return err + if !omarchyPollStateUsable() { + return errPollStateDir + } + return writeJSONFile(omarchyPollStatePath(), state) } // notifyNewMail diffs the unseen postings against the fingerprint file and diff --git a/internal/cmd/omarchy_notify_test.go b/internal/cmd/omarchy_notify_test.go index 12a49ee2..1d28425e 100644 --- a/internal/cmd/omarchy_notify_test.go +++ b/internal/cmd/omarchy_notify_test.go @@ -1,11 +1,7 @@ package cmd import ( - "encoding/json" "errors" - "fmt" - "net/http" - "net/http/httptest" "os" "path/filepath" "slices" @@ -175,33 +171,14 @@ func TestPollIdentityIsKeyedOnTheUser(t *testing.T) { } } -func TestBarStatusNotifySkipsWhenTheIdentityIsUnavailable(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/imbox.json" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 5, "name": "Invoice #4021", "seen": false}]}`)) - return - } - w.WriteHeader(500) // the identity endpoint is down - })) - defer server.Close() - - out, err := runBarStatus(t, server.URL, true, "--notify") - if err != nil || !strings.Contains(out, "active") { - t.Errorf("the bar must still light, got %q, %v", out, err) - } - if _, existed := loadOmarchyPollState(); existed { - t.Error("without knowing who the poll runs as, the fingerprints must stay untouched") - } -} - func TestOmarchyRemoveKeepsPollStateWhileBarRemovalFails(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root can write anywhere") } env, _ := testOmarchyEnv(t) - setup := omarchySetup{env: env} - setup.apply() + writeShell(t, env, pluginShellJSON) + on := true + omarchySetup{env: env, notify: &on}.apply() if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{"1": 1}}); err != nil { t.Fatal(err) } @@ -210,12 +187,12 @@ func TestOmarchyRemoveKeepsPollStateWhileBarRemovalFails(t *testing.T) { } t.Cleanup(func() { _ = os.Chmod(env.configDir(), 0o700) }) - steps := statuses(setup.remove()) - if steps["bar indicator"] != "failed" || steps["poll state"] != "kept" { - t.Errorf("while the bar module cannot be removed the fingerprints must stay, got %v", steps) + steps := statuses(omarchySetup{env: env}.remove()) + if steps["bar plugin"] != "failed" || steps["poll state"] != "kept" { + t.Errorf("while the notify setting cannot be cleared the fingerprints must stay, got %v", steps) } if _, existed := loadOmarchyPollState(); !existed { - t.Error("poll state was deleted although the poller is still scheduled") + t.Error("poll state was deleted although the plugin still polls with --notify") } } @@ -388,155 +365,14 @@ func TestNotifyNewMailReseedsWhenIdentityChanges(t *testing.T) { } } -func TestBarStatusNotifySeedsEveryUnseenPage(t *testing.T) { - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case r.URL.Path == "/identity.json": - _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) - case r.URL.Path == "/imbox.json" && r.URL.Query().Get("page") == "": - // An all-unseen first page: the unseen set may continue. - fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, - "postings": [{"id": 1, "name": "Newest", "seen": false, "visible_entry_count": 1}, - {"id": 2, "name": "Newer", "seen": false, "visible_entry_count": 1}]}`, - server.URL+"/imbox.json?page=2") - case r.URL.Path == "/imbox.json" && r.URL.Query().Get("page") == "2": - // Older unseen threads, then the first seen one closes the set. - _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": "", - "postings": [{"id": 3, "name": "Older but unseen", "seen": false, "visible_entry_count": 4}, - {"id": 4, "name": "Already read", "seen": true, "visible_entry_count": 1}]}`)) - default: - w.WriteHeader(404) - } - })) - defer server.Close() - - out, err := runBarStatus(t, server.URL, true, "--notify") - if err != nil || !strings.Contains(out, "active") { - t.Fatalf("bar JSON unchanged by pagination, got %q, %v", out, err) - } - state, existed := loadOmarchyPollState() - if !existed || state.Seen["1"] != 1 || state.Seen["2"] != 1 || state.Seen["3"] != 4 { - t.Errorf("the first seed must fingerprint every unseen thread across pages, got %+v", state) - } - if _, fingerprinted := state.Seen["4"]; fingerprinted { - t.Errorf("seen threads are not fingerprinted: %+v", state) - } -} - // unseenPagesServer serves `pages` all-unseen Imbox pages (one posting each, -// ids 1..pages) followed by a page whose first posting is seen, and counts -// the page fetches. -func unseenPagesServer(t *testing.T, pages int) (*httptest.Server, *int) { - t.Helper() - fetched := 0 - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/identity.json" { - _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) - return - } - if r.URL.Path != "/imbox.json" { - w.WriteHeader(404) - return - } - fetched++ - page := 1 - if p := r.URL.Query().Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - if page > pages { - _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 999, "name": "Already read", "seen": true}]}`)) - return - } - fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, - "postings": [{"id": %d, "name": "Thread %d", "seen": false, "visible_entry_count": 1}]}`, - fmt.Sprintf("%s/imbox.json?page=%d", server.URL, page+1), page, page) - })) - t.Cleanup(server.Close) - return server, &fetched -} - -func TestBarStatusWithoutNotifyReadsOnlyOnePage(t *testing.T) { - server, fetched := unseenPagesServer(t, 3) - - out, err := runBarStatus(t, server.URL, true) - if err != nil || !strings.Contains(out, "active") { - t.Fatalf("bar should light, got %q, %v", out, err) - } - if *fetched != 1 { - t.Errorf("the indicator needs one page, fetched %d", *fetched) - } -} - -func TestBarStatusNotifySeedsExhaustivelyThenCapsSteadyTicks(t *testing.T) { - server, fetched := unseenPagesServer(t, 3) - old := unseenPageCap - unseenPageCap = 2 - t.Cleanup(func() { unseenPageCap = old }) - - // First seed: every unseen page is read, cap or no cap. - stateHome := t.TempDir() - if _, err := runBarStatusWithState(t, stateHome, server.URL, true, "--notify"); err != nil { - t.Fatal(err) - } - state, _ := loadOmarchyPollState() - if len(state.Seen) != 3 { - t.Errorf("the seed must fingerprint every unseen thread, got %+v", state) - } - if *fetched != 4 { - t.Errorf("the seed should read all three unseen pages and the closing one, fetched %d", *fetched) - } - - // Steady tick: the cap applies and the snapshot is incomplete, so the - // thread beyond the cap keeps its fingerprint rather than being pruned. - *fetched = 0 - if _, err := runBarStatusWithState(t, stateHome, server.URL, true, "--notify"); err != nil { - t.Fatal(err) - } - if *fetched != 2 { - t.Errorf("a steady tick stops at the cap, fetched %d", *fetched) - } - if state, _ = loadOmarchyPollState(); len(state.Seen) != 3 { - t.Errorf("an incomplete snapshot must keep the fingerprints it could not see, got %+v", state) - } -} - -func TestSeedPageCapCountsTheInitialPage(t *testing.T) { - // Two all-unseen pages plus the closing seen page: a seed cap of three - // total pages must reach the closing page and complete. - server, fetched := unseenPagesServer(t, 2) - old := unseenSeedPageCap - unseenSeedPageCap = 3 - t.Cleanup(func() { unseenSeedPageCap = old }) - - if _, err := runBarStatus(t, server.URL, true, "--notify"); err != nil { - t.Fatal(err) - } - if state, existed := loadOmarchyPollState(); !existed || len(state.Seen) != 2 { - t.Errorf("a seed whose unseen set fits the cap must complete, got existed=%v %+v", existed, state) - } - if *fetched != 3 { - t.Errorf("the closing page is within the cap, fetched %d", *fetched) - } - - // One page tighter and the seed is incomplete: nothing may be persisted. - unseenSeedPageCap = 2 - if _, err := runBarStatus(t, server.URL, true, "--notify"); err != nil { - t.Fatal(err) - } - if _, existed := loadOmarchyPollState(); existed { - t.Error("an over-cap seed is incomplete and must not be persisted") - } -} func TestOmarchySetupNotifyFailsWhenStaleStateCannotBeDropped(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root can remove anything") } env, _ := testOmarchyEnv(t) + writeShell(t, env, pluginShellJSON) on := true omarchySetup{env: env}.apply() if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{"1": 1}}); err != nil { @@ -549,85 +385,11 @@ func TestOmarchySetupNotifyFailsWhenStaleStateCannotBeDropped(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(stateDir, 0o700) }) steps := statuses(omarchySetup{env: env, notify: &on}.apply()) - if steps["bar indicator"] != "failed" { - t.Errorf("enabling toasts over undroppable stale fingerprints must fail, got %q", steps["bar indicator"]) - } - if module := readText(t, env.shellPath()); strings.Contains(module, "--notify") { - t.Error("the module must not be switched to --notify when the reseed could not be prepared") - } -} - -func TestBarStatusNotifySeedsStateAndPrintsBarJSON(t *testing.T) { - server := imboxServer(t, `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 2}]`) - defer server.Close() - - out, err := runBarStatus(t, server.URL, true, "--notify") - if err != nil || !strings.Contains(out, "active") { - t.Errorf("bar JSON must be unchanged by --notify, got %q, %v", out, err) - } - state, existed := loadOmarchyPollState() - if !existed || state.Seen["5"] != 2 { - t.Errorf("--notify should seed state on first run, got %+v (existed=%v)", state, existed) - } -} - -func TestBarStatusNotifySilentWhenUnauthenticatedOrOffline(t *testing.T) { - server := imboxServer(t, `[]`) - - out, err := runBarStatus(t, server.URL, false, "--notify") - if err != nil || out != "" { - t.Errorf("logged out should stay silent, got %q, %v", out, err) - } - if _, existed := loadOmarchyPollState(); existed { - t.Error("logged out must not touch state") - } - - server.Close() - out, err = runBarStatus(t, server.URL, true, "--notify") - if err != nil || out != "" { - t.Errorf("offline should stay silent, got %q, %v", out, err) - } - if _, existed := loadOmarchyPollState(); existed { - t.Error("offline must not touch state") - } -} - -func TestOmarchySetupNotifyTogglesBarExec(t *testing.T) { - env, _ := testOmarchyEnv(t) - on, off := true, false - - barExec := func() string { - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - layout := shell["bar"].(map[string]any)["layout"].(map[string]any) - return barLayoutModule(layout, omarchyBarModuleID)["exec"].(string) - } - - omarchySetup{env: env}.apply() - if barExec() != "hey omarchy bar-status" { - t.Fatalf("default install must not notify, exec = %q", barExec()) - } - - steps := statuses(omarchySetup{env: env, notify: &on}.apply()) - if steps["bar indicator"] != "installed" || barExec() != "hey omarchy bar-status --notify" { - t.Errorf("--notify should rewrite the exec, got %q / %q", steps["bar indicator"], barExec()) - } - - steps = statuses(omarchySetup{env: env, notify: &on}.apply()) - if steps["bar indicator"] != "unchanged" { - t.Errorf("--notify twice should be idempotent, got %q", steps["bar indicator"]) - } - - steps = statuses(omarchySetup{env: env}.apply()) - if steps["bar indicator"] != "unchanged" || barExec() != "hey omarchy bar-status --notify" { - t.Errorf("a plain re-run must leave notifications as they are, got %q / %q", steps["bar indicator"], barExec()) + if steps["bar plugin"] != "failed" { + t.Errorf("enabling toasts over undroppable stale fingerprints must fail, got %q", steps["bar plugin"]) } - - steps = statuses(omarchySetup{env: env, notify: &off}.apply()) - if steps["bar indicator"] != "installed" || barExec() != "hey omarchy bar-status" { - t.Errorf("--no-notify should revert the exec, got %q / %q", steps["bar indicator"], barExec()) + if _, has := pluginEntry(t, env)["notify"]; has { + t.Error("notify must not be switched on when the reseed could not be prepared") } } @@ -654,6 +416,7 @@ func TestNotifyNewMailSkipsTheToastWhenStateCannotBeSaved(t *testing.T) { func TestOmarchySetupNotifyReenableReseedsState(t *testing.T) { env, _ := testOmarchyEnv(t) + writeShell(t, env, pluginShellJSON) on, off := true, false omarchySetup{env: env, notify: &on}.apply() @@ -668,56 +431,6 @@ func TestOmarchySetupNotifyReenableReseedsState(t *testing.T) { omarchySetup{env: env, notify: &on}.apply() if _, existed := loadOmarchyPollState(); existed { - t.Error("turning toasts back on must drop stale fingerprints so the first tick reseeds") - } -} - -func TestOmarchySetupNotifyOnFreshInstall(t *testing.T) { - env, _ := testOmarchyEnv(t) - on := true - - omarchySetup{env: env, notify: &on}.apply() - - shell := readText(t, env.shellPath()) - if !strings.Contains(shell, "hey omarchy bar-status --notify") { - t.Errorf("fresh install with --notify should enable toasts:\n%s", shell) - } -} - -func TestOmarchySetupReconcileKeepsNotifyChoice(t *testing.T) { - env, _ := testOmarchyEnv(t) - if err := os.MkdirAll(env.configDir(), 0o755); err != nil { - t.Fatal(err) - } - stale := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[ - {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify","interval":60, - "tooltip":"HEY","onClick":"omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey"}, - {"id":"omarchy.tray"}]}}}` - if err := os.WriteFile(env.shellPath(), []byte(stale), 0o644); err != nil { - t.Fatal(err) - } - - steps := statuses(omarchySetup{env: env}.apply()) - if steps["bar indicator"] != "installed" { - t.Errorf("a stale module should be rewritten on a plain re-run, got %q", steps["bar indicator"]) - } - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - layout := shell["bar"].(map[string]any)["layout"].(map[string]any) - module := barLayoutModule(layout, omarchyBarModuleID) - if module["onClick"] != omarchyFocusCommand || module["interval"] != float64(180) { - t.Errorf("click command and interval should be reconciled: %v", module) - } - if module["exec"] != "hey omarchy bar-status --notify" { - t.Errorf("the notify choice must survive a plain re-run: %v", module) - } - if right := layout["right"].([]any); len(right) != 2 || barEntryID(right[1]) != "omarchy.tray" { - t.Errorf("module position and neighbours must be kept: %v", right) - } - - if again := statuses(omarchySetup{env: env}.apply()); again["bar indicator"] != "unchanged" { - t.Errorf("reconciled module must be stable, got %q", again["bar indicator"]) + t.Error("turning toasts back on must drop stale fingerprints so the first poll reseeds") } } diff --git a/internal/cmd/omarchy_poll.go b/internal/cmd/omarchy_poll.go new file mode 100644 index 00000000..6826488e --- /dev/null +++ b/internal/cmd/omarchy_poll.go @@ -0,0 +1,225 @@ +package cmd + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/config" + "github.com/basecamp/hey-cli/internal/output" +) + +// hey omarchy poll is the engine under the 37signals.hey bar plugin +// (github.com/basecamp/omarchy-hey-plugin): one Imbox read returns the +// postings the panel renders and, with --notify, diffs them against the +// fingerprint file to toast new mail. The plugin owns the rendering — icon, +// panel, settings — and hey-cli owns what needs HEY's semantics: pagination, +// what counts as new, and a toast that honors Do Not Disturb. + +type omarchyCommand struct { + cmd *cobra.Command +} + +func newOmarchyCommand() *omarchyCommand { + omarchyCommand := &omarchyCommand{} + omarchyCommand.cmd = &cobra.Command{ + Use: "omarchy", + Short: "Omarchy shell integration, used by the 37signals.hey bar plugin", + } + omarchyCommand.cmd.AddCommand(newOmarchyPollCommand().cmd) + return omarchyCommand +} + +type omarchyPollCommand struct { + cmd *cobra.Command + limit int + notify bool + env omarchyEnv +} + +// omarchyPollEnv supplies the environment the poll toasts through; tests swap +// in a recorder so no notification daemon is ever reached. +var omarchyPollEnv = liveOmarchyEnv + +func newOmarchyPollCommand() *omarchyPollCommand { + omarchyPollCommand := &omarchyPollCommand{env: omarchyPollEnv()} + omarchyPollCommand.cmd = &cobra.Command{ + Use: "poll", + Short: "Read the Imbox for the bar plugin, toasting new mail with --notify", + Long: `Read the Imbox the way hey box imbox --json does, for the Omarchy bar plugin to +render. The response is the same shape: the box with its postings, newest first, +truncated to --limit. + +With --notify, the same read also toasts newly unseen Imbox mail via +omarchy-notification-send: at most one toast per poll, replacing the previous one +rather than stacking, identified as HEY so notification silencing applies. The +first poll seeds silently and never toasts the backlog, and a poll without +--notify forgets the seed, so turning toasts on always starts silent. A toast that +cannot be sent is retried on the next poll; it never fails the command.`, + Example: ` hey omarchy poll --limit 50 --json + hey omarchy poll --limit 50 --notify --json`, + Annotations: map[string]string{ + "agent_notes": "Run by the 37signals.hey Omarchy bar plugin on its poll interval. Prefer hey box imbox for reading the Imbox yourself; --notify keeps per-user toast state in the state directory.", + }, + Args: cobra.NoArgs, + RunE: omarchyPollCommand.run, + } + omarchyPollCommand.cmd.Flags().IntVar(&omarchyPollCommand.limit, "limit", 50, "Maximum number of threads to return") + omarchyPollCommand.cmd.Flags().BoolVar(&omarchyPollCommand.notify, "notify", false, "Toast new unseen Imbox mail") + return omarchyPollCommand +} + +// errConfigDegraded is set by the root pre-run when the global configuration could +// not be loaded and a config-ignoring command went on with the defaults. The +// poll then reports it instead of answering for a guessed server. +var errConfigDegraded error + +func (c *omarchyPollCommand) run(cmd *cobra.Command, args []string) error { + if c.limit < 1 { + return output.ErrUsage("--limit must be at least 1") + } + if errConfigDegraded != nil { + return &output.Error{Code: "config_error", Message: "configuration could not be loaded: " + errConfigDegraded.Error(), + Hint: "fix " + filepath.Join(config.ConfigDir(), "config.json") + " and poll again", Cause: errConfigDegraded} + } + if err := requireAuth(); err != nil { + return err + } + // The omarchy command is exempt from pre-run account scoping so the auth + // check above comes first; the configured account still applies. + ctx := cmd.Context() + if err := selectConfiguredAccount(ctx); err != nil { + return err + } + // Toasts need the unseen set: capped on a steady-state poll (new mail always + // lands on page 1), exhaustive when seeding — a first run or a new identity — + // so no pre-existing thread can later read as new. + // With no absolute state directory (HOME and XDG_STATE_HOME unset, or a + // relative XDG_STATE_HOME) the state would land in the working directory, + // wherever the shell started us: no fingerprints are touched at all then, + // and the toasts stay off the way they do when the identity is unknown. + identity, notifyPages := "", 0 + switch { + case !omarchyPollStateUsable(): + case !c.notify: + _ = removeOmarchyPollState() + default: + id, ok := omarchyPollIdentity(ctx) + if !ok { + break + } + identity = id + if state, existed := loadOmarchyPollState(); !existed || state.Identity != identity { + notifyPages = unseenSeedPageCap + } else { + notifyPages = unseenPageCap + } + } + resp, unseen, complete, err := pollImbox(ctx, c.limit, notifyPages) + if identity != "" && resp != nil { + // Also after a later page failed: the pages that were read are a valid + // (incomplete) snapshot, and notifyNewMail keeps fingerprints it could + // not see rather than pruning them. + withOmarchyPollLock(func() { notifyNewMail(c.env, identity, unseen, complete) }) + } + if err != nil { + return err + } + + fetched := len(resp.Postings) + hasMore := resp.NextHistoryUrl != "" + if fetched > c.limit { + resp.Postings = resp.Postings[:c.limit] + // As in hey box: a client-side cut clears next_history_url so a + // consumer following it cannot skip the truncated postings. + resp.NextHistoryUrl = "" + } + notice := pollTruncationNotice(len(resp.Postings), fetched, hasMore) + + if writer.IsStyled() { + printBoxTable(cmd.OutOrStdout(), resp, resp.Postings, notice) + return nil + } + return writeOK(resp, + output.WithSummary(boxSummary(len(resp.Postings), resp.Name)), + output.WithNotice(notice), + output.WithBreadcrumbs(boxBreadcrumbs()...), + ) +} + +func pollTruncationNotice(shown, fetched int, hasMore bool) string { + if shown < fetched || hasMore { + return fmt.Sprintf("Showing %d results. More available; raise --limit to fetch more.", shown) + } + return "" +} + +// Page limits for following an all-unseen Imbox. A steady-state poll stops at +// ten pages — three hundred unseen threads — because new mail always lands on +// page 1 and older threads are already fingerprinted. Seeding reads the whole +// unseen set (bounded only as the box command is) so that no pre-existing +// thread can later surface as new; it happens once per identity. +var ( + unseenPageCap = 10 + // The +1 is the initial page: maxAdditionalPages caps pages fetched after + // it (as in paginateBoxPostings), so a box whose unseen set spans exactly + // the cap still finds its closing seen page and seeds completely. + unseenSeedPageCap = maxAdditionalPages + 1 +) + +// pollImbox reads the Imbox once for both consumers. Pages are followed while +// the panel still wants postings (fewer than limit so far, capped as hey box +// is) or while the toasts still need them: HEY orders Imbox postings +// unseen-first, so a page holding any seen posting (or nothing at all) closes +// the unseen set, and an all-unseen page means the next one is fetched, up to +// notifyPages (zero when not notifying). The response carries every posting +// read and the last page's next_history_url; unseen is the unseen postings +// among them and complete reports whether they are the whole unseen Imbox. A +// later page that cannot be fetched is returned as the error alongside what +// was read — the panel keeps its last complete list rather than showing a +// short one, while the toasts still get their (incomplete) snapshot. +func pollImbox(ctx context.Context, limit, notifyPages int) (resp *generated.BoxShowResponse, unseen []generated.Posting, complete bool, err error) { + resp, err = sdk.Boxes().GetImbox(ctx, nil) + if err != nil { + return nil, nil, false, convertSDKError(err) + } + if resp == nil { + return nil, nil, false, output.ErrAPI(0, "empty Imbox response") + } + page, postings := resp, resp.Postings + for pages, additional := 1, 0; ; pages++ { + for _, posting := range page.Postings { + if posting.Seen { + complete = true + } else { + unseen = append(unseen, posting) + } + } + if len(page.Postings) == 0 || page.NextHistoryUrl == "" { + complete = true + break + } + wantForPanel := len(postings) < limit && additional < maxAdditionalPages + wantForToasts := !complete && pages < notifyPages + if !wantForPanel && !wantForToasts { + break + } + next, ferr := fetchNextBoxPage(ctx, page.NextHistoryUrl) + if ferr == nil && next == nil { + ferr = output.ErrAPI(0, "empty Imbox page") + } + if ferr != nil { + err = ferr + break + } + page, additional = next, additional+1 + postings = append(postings, page.Postings...) + } + resp.Postings = postings + resp.NextHistoryUrl = page.NextHistoryUrl + return resp, unseen, complete, err +} diff --git a/internal/cmd/omarchy_poll_test.go b/internal/cmd/omarchy_poll_test.go new file mode 100644 index 00000000..144b13a3 --- /dev/null +++ b/internal/cmd/omarchy_poll_test.go @@ -0,0 +1,598 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +// imboxServer answers the identity endpoint and a one-page Imbox. +func imboxServer(t *testing.T, postings string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/identity.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + case r.Method == "GET" && r.URL.Path == "/imbox.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": ` + postings + `}`)) + default: + w.WriteHeader(404) + } + })) +} + +// unseenPagesServer serves `pages` all-unseen Imbox pages (one posting each, +// ids 1..pages) followed by a page whose first posting is seen, and counts +// the page fetches. A page listed in failing answers 500 instead. +func unseenPagesServer(t *testing.T, pages int, failing ...int) (*httptest.Server, *int) { + t.Helper() + fetched := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/identity.json" { + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + return + } + if r.URL.Path != "/imbox.json" { + w.WriteHeader(404) + return + } + fetched++ + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if slices.Contains(failing, page) { + w.WriteHeader(500) + return + } + if page > pages { + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 999, "name": "Already read", "seen": true}]}`)) + return + } + fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, + "postings": [{"id": %d, "name": "Thread %d", "seen": false, "visible_entry_count": 1}]}`, + fmt.Sprintf("%s/imbox.json?page=%d", server.URL, page+1), page, page) + })) + t.Cleanup(server.Close) + return server, &fetched +} + +// testPollEnv swaps the poll's Omarchy environment for a recorder, so a toast +// is observed rather than sent to a notification daemon. +func testPollEnv(t *testing.T) *[][]string { + t.Helper() + env, calls := testNotifyEnv("42\n") + previous := omarchyPollEnv + omarchyPollEnv = func() omarchyEnv { return env } + t.Cleanup(func() { omarchyPollEnv = previous }) + return calls +} + +type pollResponse struct { + OK bool `json:"ok"` + Notice string `json:"notice"` + Data struct { + Name string `json:"name"` + NextHistoryURL string `json:"next_history_url"` + Postings []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Seen bool `json:"seen"` + } `json:"postings"` + } `json:"data"` + raw json.RawMessage +} + +// runHey runs the root command in a sandboxed config, against the given +// server and state directory, and returns what it printed. +func runHey(t *testing.T, stateHome, serverURL string, authenticated bool, args ...string) (string, error) { + t.Helper() + if authenticated { + t.Setenv("HEY_TOKEN", "test-token") + } else { + t.Setenv("HEY_TOKEN", "") + } + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append(args, "--base-url", serverURL, "--json")) + err := root.Execute() + return buf.String(), err +} + +// runPoll runs hey omarchy poll against a state directory, so a test can poll +// more than once over the same fingerprints. +func runPoll(t *testing.T, stateHome, serverURL string, authenticated bool, extraArgs ...string) (pollResponse, error) { + t.Helper() + out, err := runHey(t, stateHome, serverURL, authenticated, append([]string{"omarchy", "poll"}, extraArgs...)...) + var resp pollResponse + if err == nil { + if uerr := json.Unmarshal([]byte(out), &resp); uerr != nil { + t.Fatalf("poll output is not JSON: %q", out) + } + var envelope struct { + Data json.RawMessage `json:"data"` + } + _ = json.Unmarshal([]byte(out), &envelope) + resp.raw = envelope.Data + } + return resp, err +} + +func TestPollAnswersLikeBoxImbox(t *testing.T) { + server := imboxServer(t, `[{"id": 1, "name": "Lunch on Thursday?", "seen": true, "creator": {"name": "Maria Delgado"}}, + {"id": 2, "name": "Invoice #4021", "seen": false, "account_id": 5, "summary": "Your August invoice is attached."}]`) + defer server.Close() + + poll, err := runPoll(t, t.TempDir(), server.URL, true) + if err != nil { + t.Fatal(err) + } + box, err := runHey(t, t.TempDir(), server.URL, true, "box", "imbox") + if err != nil { + t.Fatal(err) + } + var boxEnvelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal([]byte(box), &boxEnvelope); err != nil { + t.Fatal(err) + } + // The plugin parses the data of hey box imbox --json; the poll's data has + // to be the same, byte for byte, so the panel renders either. The envelope + // around it (summary, notice, breadcrumbs) is the poll's own. + if !bytes.Equal(poll.raw, boxEnvelope.Data) { + t.Errorf("poll data differs from hey box imbox:\n%s\n%s", poll.raw, boxEnvelope.Data) + } + if !poll.OK || len(poll.Data.Postings) != 2 || poll.Data.Postings[1].ID != 2 { + t.Errorf("unexpected poll response: %+v", poll) + } +} + +func TestPollLimitTruncatesAndClearsTheNextPage(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, + "postings": [{"id": 1, "name": "Newest", "seen": false}, {"id": 2, "name": "Newer", "seen": false}, {"id": 3, "name": "New", "seen": true}]}`, + server.URL+"/imbox.json?page=2") + })) + defer server.Close() + + resp, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "2") + if err != nil { + t.Fatal(err) + } + if len(resp.Data.Postings) != 2 || resp.Data.Postings[1].ID != 2 { + t.Errorf("--limit 2 should keep the first two postings, got %+v", resp.Data.Postings) + } + if resp.Data.NextHistoryURL != "" { + t.Errorf("a client-side cut must clear next_history_url so the cut postings are not skipped, got %q", resp.Data.NextHistoryURL) + } + if !strings.Contains(resp.Notice, "raise --limit") { + t.Errorf("the truncation should be noticed, got %q", resp.Notice) + } + + if _, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "0"); err == nil || output.AsError(err).Code != "usage" { + t.Errorf("--limit 0 is a usage error, got %v", err) + } +} + +func TestPollPagesForTheLimitPastTheFirstSeenPosting(t *testing.T) { + var server *httptest.Server + fetched := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/identity.json" { + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + return + } + fetched++ + switch r.URL.Query().Get("page") { + case "": + fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, + "postings": [{"id": 1, "name": "Newest", "seen": false, "visible_entry_count": 1}, {"id": 2, "name": "Read already", "seen": true}]}`, + server.URL+"/imbox.json?page=2") + default: + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 3, "name": "Older", "seen": true}]}`)) + } + })) + defer server.Close() + testPollEnv(t) + + // The unseen set closes on page 1, but the panel asked for three threads. + resp, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "3", "--notify") + if err != nil { + t.Fatal(err) + } + if fetched != 2 || len(resp.Data.Postings) != 3 { + t.Errorf("the poll should page on for --limit, fetched %d pages and %d postings", fetched, len(resp.Data.Postings)) + } + state, existed := loadOmarchyPollState() + if !existed || len(state.Seen) != 1 || state.Seen["1"] != 1 { + t.Errorf("only the unseen posting is fingerprinted, got existed=%v %+v", existed, state) + } +} + +func TestPollWithoutNotifyReadsOnlyWhatTheLimitNeeds(t *testing.T) { + server, fetched := unseenPagesServer(t, 3) + + resp, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "1") + if err != nil || !resp.OK { + t.Fatalf("got %+v, %v", resp, err) + } + if *fetched != 1 { + t.Errorf("one thread needs one page, fetched %d", *fetched) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("without --notify the fingerprints are never touched") + } +} + +func TestPollNotifySeedsSilentlyThenToastsOnce(t *testing.T) { + postings := `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 2, "creator": {"name": "Northwind Invoicing"}}]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/identity.json" { + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + return + } + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": ` + postings + `}`)) + })) + defer server.Close() + calls := testPollEnv(t) + stateHome := t.TempDir() + + resp, err := runPoll(t, stateHome, server.URL, true, "--notify") + if err != nil || len(resp.Data.Postings) != 1 { + t.Fatalf("the response is unchanged by --notify, got %+v, %v", resp, err) + } + if len(*calls) != 0 { + t.Fatalf("the first poll seeds and never toasts the backlog, ran %v", *calls) + } + state, existed := loadOmarchyPollState() + if !existed || state.Seen["5"] != 2 || state.Identity != pollIdentity(server.URL, "all", "7") { + t.Errorf("the first poll should seed the fingerprints under the poll's identity, got %+v (existed=%v)", state, existed) + } + + postings = `[{"id": 6, "name": "Lunch on Thursday?", "seen": false, "visible_entry_count": 1, "creator": {"name": "Maria Delgado"}},` + postings[1:] + if _, err := runPoll(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 { + t.Fatalf("a new thread toasts exactly once, ran %v", *calls) + } + argv := (*calls)[0] + if argv[0] != "omarchy-notification-send" || !slices.Contains(argv, "Maria Delgado — Lunch on Thursday?") { + t.Errorf("unexpected toast: %v", argv) + } + for flag, value := range map[string]string{"--app-name": "HEY", "--exec": omarchyFocusCommand, "-u": "low"} { + if i := slices.Index(argv, flag); i < 0 || argv[i+1] != value { + t.Errorf("%s %q missing from %v", flag, value, argv) + } + } + + // Same Imbox again: nothing new, nothing sent, and the toast id is kept so + // the next toast replaces this one. + if _, err := runPoll(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 { + t.Errorf("an unchanged Imbox must not toast again, ran %v", *calls) + } + if state, _ := loadOmarchyPollState(); state.ToastID != 42 { + t.Errorf("the toast id should be cached, got %+v", state) + } +} + +func TestPollNotifyReseedsWhenTheIdentityChanges(t *testing.T) { + server := imboxServer(t, `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 1}]`) + defer server.Close() + calls := testPollEnv(t) + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + // Fingerprints taken for someone else: the backlog must not toast. + if err := saveOmarchyPollState(omarchyPollState{Identity: "https://app.hey.com all user:1002", Seen: map[string]int32{}}); err != nil { + t.Fatal(err) + } + + if _, err := runPoll(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if len(*calls) != 0 { + t.Errorf("another identity's backlog must reseed silently, ran %v", *calls) + } + if state, _ := loadOmarchyPollState(); state.Identity != pollIdentity(server.URL, "all", "7") || state.Seen["5"] != 1 { + t.Errorf("the state should now fingerprint the poll's identity, got %+v", state) + } +} + +func TestPollNotifySkipsWhenTheIdentityIsUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/imbox.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 5, "name": "Invoice #4021", "seen": false}]}`)) + return + } + w.WriteHeader(500) // the identity endpoint is down + })) + defer server.Close() + testPollEnv(t) + + resp, err := runPoll(t, t.TempDir(), server.URL, true, "--notify") + if err != nil || len(resp.Data.Postings) != 1 { + t.Errorf("the panel still gets its postings, got %+v, %v", resp, err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("without knowing who the poll runs as, the fingerprints must stay untouched") + } +} + +func TestPollNotifySeedsEveryUnseenPageThenCapsSteadyPolls(t *testing.T) { + server, fetched := unseenPagesServer(t, 3) + testPollEnv(t) + old := unseenPageCap + unseenPageCap = 2 + t.Cleanup(func() { unseenPageCap = old }) + + // First seed: every unseen page is read, cap or no cap, and the closing + // seen page proves the set complete. + stateHome := t.TempDir() + if _, err := runPoll(t, stateHome, server.URL, true, "--limit", "1", "--notify"); err != nil { + t.Fatal(err) + } + state, existed := loadOmarchyPollState() + if !existed || len(state.Seen) != 3 { + t.Errorf("the seed must fingerprint every unseen thread, got %+v", state) + } + if *fetched != 4 { + t.Errorf("the seed should read all three unseen pages and the closing one, fetched %d", *fetched) + } + + // Steady poll: the cap applies and the snapshot is incomplete, so the + // thread beyond the cap keeps its fingerprint rather than being pruned. + *fetched = 0 + resp, err := runPoll(t, stateHome, server.URL, true, "--limit", "1", "--notify") + if err != nil { + t.Fatal(err) + } + if *fetched != 2 { + t.Errorf("a steady poll stops at the cap, fetched %d", *fetched) + } + if state, _ = loadOmarchyPollState(); len(state.Seen) != 3 { + t.Errorf("an incomplete snapshot must keep the fingerprints it could not see, got %+v", state) + } + // The pages read for the toasts are the panel's too, cut to its limit — + // and a cut clears the next page, as in hey box. + if len(resp.Data.Postings) != 1 || resp.Data.NextHistoryURL != "" || !strings.Contains(resp.Notice, "raise --limit") { + t.Errorf("the panel gets its --limit with the cut noticed, got %+v %q", resp.Data, resp.Notice) + } +} + +func TestPollSeedPageCapCountsTheInitialPage(t *testing.T) { + // Two all-unseen pages plus the closing seen page: a seed cap of three + // total pages must reach the closing page and complete. + server, fetched := unseenPagesServer(t, 2) + testPollEnv(t) + old := unseenSeedPageCap + unseenSeedPageCap = 3 + t.Cleanup(func() { unseenSeedPageCap = old }) + + if _, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "1", "--notify"); err != nil { + t.Fatal(err) + } + if state, existed := loadOmarchyPollState(); !existed || len(state.Seen) != 2 { + t.Errorf("a seed whose unseen set fits the cap must complete, got existed=%v %+v", existed, state) + } + if *fetched != 3 { + t.Errorf("the closing page is within the cap, fetched %d", *fetched) + } + + // One page tighter and the seed is incomplete: nothing may be persisted. + unseenSeedPageCap = 2 + if _, err := runPoll(t, t.TempDir(), server.URL, true, "--limit", "1", "--notify"); err != nil { + t.Fatal(err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("an over-cap seed is incomplete and must not be persisted") + } +} + +func TestPollFailsClosedWhenALaterPageFails(t *testing.T) { + server, fetched := unseenPagesServer(t, 3, 3) + calls := testPollEnv(t) + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + // A thread fingerprinted earlier that sits beyond the failed page. + if err := saveOmarchyPollState(omarchyPollState{Identity: pollIdentity(server.URL, "all", "7"), Seen: map[string]int32{"3": 1}}); err != nil { + t.Fatal(err) + } + + // The panel must not swap its last complete list for a short one: the + // poll reports the failed page. The toasts still get the pages that were + // read — the two new threads on them toast, and the fingerprint beyond the + // failed page is kept rather than pruned. + _, err := runPoll(t, stateHome, server.URL, true, "--limit", "5", "--notify") + if err == nil { + t.Fatal("a failed later page must fail the poll") + } + if *fetched != 3 { + t.Errorf("fetched %d pages, want the failing third", *fetched) + } + if len(*calls) != 1 || !slices.Contains((*calls)[0], "2 new in Imbox") { + t.Errorf("the threads on the pages that were read toast once, ran %v", *calls) + } + if state, _ := loadOmarchyPollState(); state.Seen["3"] != 1 || state.Seen["1"] != 1 || state.Seen["2"] != 1 { + t.Errorf("an incomplete snapshot keeps the fingerprints it could not see and adds what it saw, got %+v", state) + } +} + +func TestPollWithoutNotifyForgetsTheFingerprints(t *testing.T) { + server := imboxServer(t, `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 1}]`) + defer server.Close() + calls := testPollEnv(t) + stateHome := t.TempDir() + + if _, err := runPoll(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if _, existed := loadOmarchyPollState(); !existed { + t.Fatal("--notify should have seeded") + } + // Toasts turned off by any route — the plugin's toggle, omarchy bar set — + // mean polls without --notify, which disarm the seed… + if _, err := runPoll(t, stateHome, server.URL, true); err != nil { + t.Fatal(err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("a poll without --notify must forget the fingerprints") + } + // …so turning them back on starts from a silent seed, never the backlog. + if _, err := runPoll(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if len(*calls) != 0 { + t.Errorf("re-enabling must reseed silently, ran %v", *calls) + } +} + +func TestPollReportsErrorsTheBarOnceSwallowed(t *testing.T) { + server := imboxServer(t, `[]`) + calls := testPollEnv(t) + + _, err := runPoll(t, t.TempDir(), server.URL, false, "--notify") + if err == nil || output.AsError(err).Code != "auth" { + t.Errorf("logged out should answer with the auth envelope the plugin branches on, got %v", err) + } + if _, existed := loadOmarchyPollState(); existed || len(*calls) != 0 { + t.Error("logged out must neither toast nor touch state") + } + + server.Close() + _, err = runPoll(t, t.TempDir(), server.URL, true, "--notify") + if err == nil || output.AsError(err).Code == "auth" { + t.Errorf("offline should answer with a non-auth error, got %v", err) + } + if _, existed := loadOmarchyPollState(); existed || len(*calls) != 0 { + t.Error("offline must neither toast nor touch state") + } +} + +func TestPollReportsAMalformedGlobalConfig(t *testing.T) { + server := imboxServer(t, `[{"id": 1, "name": "Invoice #4021", "seen": false}]`) + defer server.Close() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + if err := os.MkdirAll(filepath.Join(configHome, "hey-cli"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configHome, "hey-cli", "config.json"), []byte("{broken"), 0o644); err != nil { + t.Fatal(err) + } + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"omarchy", "poll", "--base-url", server.URL, "--json"}) + err := root.Execute() + if err == nil || output.AsError(err).Code != "config_error" { + t.Fatalf("with no trustworthy configuration the poll must say so rather than answer for a guessed server, got %v", err) + } + + root = newRootCmd() + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"boxes"}) + if err := root.Execute(); err == nil { + t.Error("ordinary commands still report a broken global config") + } +} + +func TestPollLeavesTheWorkingDirectoryAloneWithoutAStateDir(t *testing.T) { + server := imboxServer(t, `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 1}]`) + defer server.Close() + calls := testPollEnv(t) + cwd := t.TempDir() + t.Chdir(cwd) + // A sanitized environment: no HOME, no XDG_STATE_HOME. config.StateDir() + // is then empty and the state path would resolve into the working + // directory, where an unrelated file may share the name. + t.Setenv("HOME", "") + bystander := filepath.Join(cwd, "omarchy-poll.json") + if err := os.WriteFile(bystander, []byte("not ours"), 0o644); err != nil { + t.Fatal(err) + } + + resp, err := runPoll(t, "", server.URL, true) + if err != nil || len(resp.Data.Postings) != 1 { + t.Fatalf("the poll still answers, got %+v, %v", resp, err) + } + if data, _ := os.ReadFile(bystander); string(data) != "not ours" { + t.Error("a non-notify poll must not delete from the working directory") + } + if _, err := runPoll(t, "", server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if data, _ := os.ReadFile(bystander); string(data) != "not ours" { + t.Error("a notify poll must not write into the working directory") + } + if _, err := os.Stat(filepath.Join(cwd, "omarchy-poll.json.lock")); !os.IsNotExist(err) { + t.Error("no lock file may be created in the working directory") + } + if len(*calls) != 0 { + t.Errorf("without a state directory the toasts stay off, ran %v", *calls) + } + + // A relative XDG_STATE_HOME is the same thing. + if _, err := runPoll(t, "state", server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cwd, "state")); !os.IsNotExist(err) { + t.Error("a relative XDG_STATE_HOME must not be created under the working directory") + } +} + +func TestPollSharesTheBoxBreadcrumbs(t *testing.T) { + server := imboxServer(t, `[]`) + defer server.Close() + + out, err := runHey(t, t.TempDir(), server.URL, true, "omarchy", "poll") + if err != nil { + t.Fatal(err) + } + var envelope struct { + Breadcrumbs []struct{ Command string } `json:"breadcrumbs"` + } + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Breadcrumbs) != 3 || envelope.Breadcrumbs[0].Command != "hey threads " { + t.Errorf("an agent reading the poll should see the box's next actions, got %+v", envelope.Breadcrumbs) + } +} diff --git a/internal/cmd/omarchy_test.go b/internal/cmd/omarchy_test.go index e6c4a6db..60f41a07 100644 --- a/internal/cmd/omarchy_test.go +++ b/internal/cmd/omarchy_test.go @@ -4,8 +4,6 @@ import ( "bytes" "encoding/json" "errors" - "net/http" - "net/http/httptest" "os" "path/filepath" "strings" @@ -73,14 +71,23 @@ func readText(t *testing.T, path string) string { func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { env, ran := testOmarchyEnv(t) + writeShell(t, env, pluginShellJSON) setup := omarchySetup{env: env} - first := statuses(setup.apply()) - for _, name := range []string{"desktop entry", "menu", "bar indicator", "theme template"} { - if first[name] != "installed" { - t.Errorf("%s: first run = %q, want installed", name, first[name]) + first := setup.apply() + for _, name := range []string{"desktop entry", "menu", "theme template"} { + if status := statuses(first)[name]; status != "installed" { + t.Errorf("%s: first run = %q, want installed", name, status) } } + // The plugin entry is already there and nothing asked for a change: the + // step reports the current notify setting without rewriting shell.json. + if bar := stepNamed(first, "bar plugin"); bar.Status != "unchanged" || bar.Detail != "notifications off" { + t.Errorf("bar plugin: first run = %q %q, want unchanged / notifications off", bar.Status, bar.Detail) + } + if readText(t, env.shellPath()) != pluginShellJSON { + t.Error("a plain run must not rewrite shell.json") + } desktop := readText(t, env.desktopPath()) if !strings.Contains(desktop, "Exec=xdg-terminal-exec --app-id=org.omarchy.hey -e hey tui") { @@ -95,18 +102,6 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { t.Errorf("menu block not written:\n%s", menu) } - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - right := shell["bar"].(map[string]any)["layout"].(map[string]any)["right"].([]any) - if barEntryID(right[0]) != "hey-unread" || barEntryID(right[1]) != "omarchy.tray" { - t.Errorf("bar module should lead the right section seeded from defaults: %v", right) - } - if module := right[0].(map[string]any); module["exec"] != "hey omarchy bar-status" || module["type"] != "command" { - t.Errorf("bar module malformed: %v", module) - } - if readText(t, env.templatePath()) != omarchyThemeTemplate { t.Error("theme template not written") } @@ -127,7 +122,9 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { env, _ := testOmarchyEnv(t) - setup := omarchySetup{env: env} + writeShell(t, env, pluginShellJSON) + on := true + setup := omarchySetup{env: env, notify: &on} menuBefore := "{\n // my rows\n \"notes\": {\"icon\":\"\",\"label\":\"Notes\",\"action\":\"omarchy-launch-editor ~/notes\"},\n}\n" if err := os.MkdirAll(filepath.Dir(env.menuPath()), 0o755); err != nil { @@ -144,9 +141,18 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { if menu := readText(t, env.menuPath()); !strings.Contains(menu, `"notes"`) || !strings.Contains(menu, `"hey-tui"`) { t.Errorf("install should keep the user's rows alongside ours:\n%s", menu) } + if entry := pluginEntry(t, env); entry["notify"] != true { + t.Fatalf("--notify should set notify on the plugin entry, got %v", entry) + } - removed := statuses(setup.remove()) + removed := statuses(omarchySetup{env: env}.remove()) for name, status := range removed { + if name == "bar indicator" { + if status != "absent" { + t.Errorf("no legacy module was installed, got %q", status) + } + continue + } if status != "removed" { t.Errorf("%s: remove = %q, want removed", name, status) } @@ -163,11 +169,17 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { if menu := readText(t, env.menuPath()); menu != menuBefore { t.Errorf("menu should be restored byte for byte:\n%s", menu) } - if shell := readText(t, env.shellPath()); strings.Contains(shell, "hey-unread") { - t.Errorf("bar module still present:\n%s", shell) + // The plugin entry is the user's (omarchy plugin add wrote it); only our + // notify key goes. + entry := pluginEntry(t, env) + if entry == nil { + t.Fatal("remove must not delete the plugin's layout entry") + } + if _, has := entry["notify"]; has { + t.Errorf("remove should delete the notify key, got %v", entry) } - again := statuses(setup.remove()) + again := statuses(omarchySetup{env: env}.remove()) for name, status := range again { if status != "absent" { t.Errorf("%s: second remove = %q, want absent", name, status) @@ -175,31 +187,6 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { } } -func TestOmarchySetupKeepsExistingBarLayout(t *testing.T) { - env, _ := testOmarchyEnv(t) - if err := os.MkdirAll(env.configDir(), 0o755); err != nil { - t.Fatal(err) - } - custom := `{"version":1,"bar":{"layout":{"left":[{"id":"omarchy.menu"}],"center":[],"right":["omarchy.audio"]}},"idle":{"lock":600}}` - if err := os.WriteFile(env.shellPath(), []byte(custom), 0o644); err != nil { - t.Fatal(err) - } - - omarchySetup{env: env}.apply() - - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - if shell["idle"].(map[string]any)["lock"] != float64(600) { - t.Error("unrelated settings must survive") - } - right := shell["bar"].(map[string]any)["layout"].(map[string]any)["right"].([]any) - if len(right) != 2 || barEntryID(right[1]) != "omarchy.audio" { - t.Errorf("string-form entries must be kept: %v", right) - } -} - func TestOmarchySetupRejectsNonJSONShellConfig(t *testing.T) { env, _ := testOmarchyEnv(t) if err := os.MkdirAll(env.configDir(), 0o755); err != nil { @@ -209,8 +196,8 @@ func TestOmarchySetupRejectsNonJSONShellConfig(t *testing.T) { t.Fatal(err) } steps := statuses(omarchySetup{env: env}.apply()) - if steps["bar indicator"] != "failed" { - t.Errorf("a shell.json we cannot round-trip must fail rather than be rewritten, got %q", steps["bar indicator"]) + if steps["bar plugin"] != "failed" { + t.Errorf("a shell.json we cannot round-trip must fail rather than be rewritten, got %q", steps["bar plugin"]) } if steps["menu"] != "installed" { t.Error("one failing step must not stop the others") @@ -227,8 +214,8 @@ func TestOmarchySetupRejectsMalformedShellConfig(t *testing.T) { if err := os.WriteFile(env.shellPath(), []byte("null"), 0o644); err != nil { t.Fatal(err) } - if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "failed" { - t.Errorf("a null shell.json must fail the bar step, got %q", steps["bar indicator"]) + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar plugin"] != "failed" { + t.Errorf("a null shell.json must fail the bar step, got %q", steps["bar plugin"]) } // A version-less object is ignored by the shell; refuse to edit it. @@ -237,7 +224,7 @@ func TestOmarchySetupRejectsMalformedShellConfig(t *testing.T) { } steps := omarchySetup{env: env}.apply() for _, step := range steps { - if step.Name == "bar indicator" { + if step.Name == "bar plugin" { if step.Status != "failed" || !strings.Contains(step.Detail, "version") { t.Errorf("a version-less shell.json must fail with a version hint, got %q %q", step.Status, step.Detail) } @@ -259,8 +246,8 @@ func TestOmarchySetupRefusesWrongTypedBarFields(t *testing.T) { if err := os.WriteFile(env.shellPath(), []byte(shell), 0o644); err != nil { t.Fatal(err) } - if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "failed" { - t.Errorf("%s: a wrong-typed value must fail, not be replaced, got %q", shell, steps["bar indicator"]) + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar plugin"] != "failed" { + t.Errorf("%s: a wrong-typed value must fail, not be replaced, got %q", shell, steps["bar plugin"]) } if readText(t, env.shellPath()) != shell { t.Errorf("%s: the file must be left untouched", shell) @@ -268,38 +255,6 @@ func TestOmarchySetupRefusesWrongTypedBarFields(t *testing.T) { } } -func TestBarStatusIgnoresRepositoryLocalConfig(t *testing.T) { - repo := t.TempDir() - if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(repo, ".hey", "config.json"), []byte(`{"base_url":"https://untrusted.example.com"}`), 0o644); err != nil { - t.Fatal(err) - } - t.Chdir(repo) - server := imboxServer(t, `[]`) - defer server.Close() - - // An untrusted checkout must neither fail the poller at the trust gate - // nor point it at the checkout's server. - out, err := runBarStatus(t, server.URL, true) - if err != nil || out != "" { - t.Errorf("the poller must stay dark and silent from an untrusted checkout, got %q, %v", out, err) - } - if cfg.UntrustedLocalConfig() != nil || cfg.SourceOf("base_url") != config.SourceFlag { - t.Errorf("the poller must load global configuration only, got base_url from %v", cfg.SourceOf("base_url")) - } - - root := newRootCmd() - command, _, _ := root.Find([]string{"setup", "omarchy"}) - if !commandIgnoresLocalConfig(command) { - t.Error("setup omarchy edits fixed desktop paths only and must ignore a checkout's config too") - } - if boxes, _, _ := root.Find([]string{"boxes"}); commandIgnoresLocalConfig(boxes) { - t.Error("ordinary commands keep honouring repository-local config") - } -} - func TestSetupOmarchyRemoveIgnoresMalformedLocalConfig(t *testing.T) { repo := t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { @@ -347,111 +302,6 @@ func TestSetupOmarchyFailsWithoutAHomeDirectory(t *testing.T) { } } -func TestOmarchySetupSeedsVersionAndRestoresDefaultsOnRemove(t *testing.T) { - env, _ := testOmarchyEnv(t) - setup := omarchySetup{env: env} - - setup.apply() - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - if shell["version"] != float64(1) { - t.Errorf(`a freshly created shell.json needs "version": 1 or the shell ignores it: %v`, shell) - } - - removed := setup.remove() - if steps := statuses(removed); steps["bar indicator"] != "removed" { - for _, step := range removed { - t.Logf("%s: %s %s", step.Name, step.Status, step.Detail) - } - t.Fatalf("bar step = %q, want removed", steps["bar indicator"]) - } - var after map[string]any // a fresh map: Unmarshal into a non-nil map merges keys - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &after); err != nil { - t.Fatal(err) - } - if _, has := after["bar"]; has { - t.Errorf("removing from a defaults-seeded layout should restore inheriting the defaults: %v", after) - } -} - -func TestOmarchySetupReconcilesStaleBarModule(t *testing.T) { - env, _ := testOmarchyEnv(t) - if err := os.MkdirAll(env.configDir(), 0o755); err != nil { - t.Fatal(err) - } - stale := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[ - {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --old-flag","interval":60, - "tooltip":"HEY","onClick":"omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey"}, - {"id":"omarchy.tray"}]}}}` - if err := os.WriteFile(env.shellPath(), []byte(stale), 0o644); err != nil { - t.Fatal(err) - } - - if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { - t.Errorf("a stale module should be rewritten, got %q", steps["bar indicator"]) - } - var shell map[string]any - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - layout := shell["bar"].(map[string]any)["layout"].(map[string]any) - module := barLayoutModule(layout, omarchyBarModuleID) - if module["exec"] != "hey omarchy bar-status" || module["onClick"] != omarchyFocusCommand || module["interval"] != float64(180) { - t.Errorf("stale fields should be reconciled: %v", module) - } - if right := layout["right"].([]any); len(right) != 2 || barEntryID(right[1]) != "omarchy.tray" { - t.Errorf("module position and neighbours must be kept: %v", right) - } - if neighbour := barLayoutModule(layout, "omarchy.tray"); neighbour == nil || neighbour["id"] != "omarchy.tray" { - t.Errorf("other inline modules must be findable too: %v", neighbour) - } - if again := statuses(omarchySetup{env: env}.apply()); again["bar indicator"] != "unchanged" { - t.Errorf("reconciled module must be stable, got %q", again["bar indicator"]) - } - // A string-form entry that happens to share our id is not ours: neither - // found by the module lookup nor deleted on removal. - layout["left"] = append(layout["left"].([]any), "hey-unread") - if barLayoutModule(layout, omarchyBarModuleID)["type"] != "command" { - t.Error("the string-form entry must not shadow the managed map") - } - withString, err := json.Marshal(shell) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(env.shellPath(), withString, 0o644); err != nil { - t.Fatal(err) - } - setup := omarchySetup{env: env} - if steps := statuses(setup.remove()); steps["bar indicator"] != "removed" { - t.Errorf("remove should still find the managed map, got %q", steps["bar indicator"]) - } - if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { - t.Fatal(err) - } - left := shell["bar"].(map[string]any)["layout"].(map[string]any)["left"].([]any) - if len(left) != 1 || left[0] != "hey-unread" { - t.Errorf("a string-form entry sharing our id is unowned and must survive removal: %v", left) - } -} - -func TestOmarchyDefaultShellPathFallsBackToUserTree(t *testing.T) { - env, _ := testOmarchyEnv(t) - env.omarchyPath = "" // no OMARCHY_PATH, as in a non-login or agent shell - userTree := filepath.Join(env.home, ".local", "share", "omarchy", "config", "omarchy") - if err := os.MkdirAll(userTree, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(userTree, "shell.json"), []byte(defaultShellJSON), 0o644); err != nil { - t.Fatal(err) - } - - if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { - t.Errorf("the per-user omarchy tree should seed the layout, got %q", steps["bar indicator"]) - } -} - func TestOmarchyRemoveTemplateReportsDeferredRender(t *testing.T) { env, _ := testOmarchyEnv(t) env.run = func(name string, args ...string) error { return errors.New("not on PATH") } @@ -665,17 +515,12 @@ func TestSetupOmarchyIsNotBlockedByAnUntrustedLocalConfig(t *testing.T) { func TestOmarchySetupPreservesLargeIntegersInShellConfig(t *testing.T) { env, _ := testOmarchyEnv(t) - if err := os.MkdirAll(env.configDir(), 0o755); err != nil { - t.Fatal(err) - } // 2^53+1 is not representable as a float64; a naive round trip would round it. - shell := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[]}},"custom":{"token":9007199254740993}}` - if err := os.WriteFile(env.shellPath(), []byte(shell), 0o644); err != nil { - t.Fatal(err) - } + writeShell(t, env, `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[{"id":"37signals.hey"}]}},"custom":{"token":9007199254740993}}`) + on := true - if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { - t.Fatalf("bar step = %q", steps["bar indicator"]) + if steps := statuses(omarchySetup{env: env, notify: &on}.apply()); steps["bar plugin"] != "installed" { + t.Fatalf("bar step = %q", steps["bar plugin"]) } if out := readText(t, env.shellPath()); !strings.Contains(out, "9007199254740993") { t.Errorf("an unrelated large integer must survive the rewrite exactly:\n%s", out) @@ -725,43 +570,6 @@ func TestOmarchySetupKeepsDanglingTemplateSymlink(t *testing.T) { } } -func TestBarStatusStaysDarkOnAMalformedGlobalConfig(t *testing.T) { - server := imboxServer(t, `[{"id": 1, "name": "Invoice #4021", "seen": false}]`) - defer server.Close() - t.Setenv("HEY_TOKEN", "test-token") - t.Setenv("HEY_NO_KEYRING", "1") - t.Setenv("HEY_BASE_URL", "") - configHome := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", configHome) - t.Setenv("XDG_STATE_HOME", t.TempDir()) - if err := os.MkdirAll(filepath.Join(configHome, "hey-cli"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(configHome, "hey-cli", "config.json"), []byte("{broken"), 0o644); err != nil { - t.Fatal(err) - } - - root := newRootCmd() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetErr(&buf) - root.SetArgs([]string{"omarchy", "bar-status", "--base-url", server.URL}) - if err := root.Execute(); err != nil { - t.Fatalf("a broken global config must not make the poller exit nonzero, got %v", err) - } - if buf.String() != "" { - t.Errorf("with no trustworthy configuration the poller must stay dark, not light against a guessed server: %q", buf.String()) - } - - root = newRootCmd() - root.SetOut(&buf) - root.SetErr(&buf) - root.SetArgs([]string{"boxes"}) - if err := root.Execute(); err == nil { - t.Error("ordinary commands still report a broken global config") - } -} - func TestOmarchySetupKeepsForeignTemplate(t *testing.T) { env, ran := testOmarchyEnv(t) foreign := "# my own hey theme template\naccent = \"#ff00ff\"\n" @@ -847,91 +655,233 @@ func TestSetupOmarchyRequiresOmarchy(t *testing.T) { } } -func imboxServer(t *testing.T, postings string) *httptest.Server { +// runBarStatusWithState runs bar-status against a given state directory, so a +// pluginShellJSON is a shell.json whose bar layout carries the 37signals.hey +// plugin entry, as `omarchy plugin add --enable` writes it. +const pluginShellJSON = `{"version":1,"bar":{"layout":{"left":[{"id":"omarchy.menu"}],"center":[{"id":"omarchy.clock"}],"right":[{"id":"37signals.hey"},{"id":"omarchy.tray"}]}}}` + +// legacyShellJSON is what an earlier `hey setup omarchy` wrote: the layout +// seeded from Omarchy's defaults with the inline hey-unread module in front. +const legacyShellJSON = `{"version":1,"bar":{"layout":{"left":[{"id":"omarchy.menu"}],"center":[{"id":"omarchy.clock"}],"right":[ + {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify","interval":180,"tooltip":"HEY","onClick":"omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui"}, + {"id":"omarchy.tray"},{"id":"omarchy.power"}]}}}` + +func writeShell(t *testing.T, env omarchyEnv, content string) { t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/identity.json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) - case r.Method == "GET" && r.URL.Path == "/imbox.json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": ` + postings + `}`)) - default: - w.WriteHeader(404) - } - })) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.shellPath(), []byte(content), 0o644); err != nil { + t.Fatal(err) + } } -func runBarStatus(t *testing.T, serverURL string, authenticated bool, extraArgs ...string) (string, error) { +func readShell(t *testing.T, env omarchyEnv) map[string]any { t.Helper() - return runBarStatusWithState(t, t.TempDir(), serverURL, authenticated, extraArgs...) + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + return shell } -// runBarStatusWithState runs bar-status against a given state directory, so a -// test can tick more than once over the same fingerprints. -func runBarStatusWithState(t *testing.T, stateHome, serverURL string, authenticated bool, extraArgs ...string) (string, error) { +// pluginEntry returns the 37signals.hey layout entry, or nil. +func pluginEntry(t *testing.T, env omarchyEnv) map[string]any { t.Helper() - if authenticated { - t.Setenv("HEY_TOKEN", "test-token") - } else { - t.Setenv("HEY_TOKEN", "") + bar, _ := readShell(t, env)["bar"].(map[string]any) + layout, _ := bar["layout"].(map[string]any) + return barLayoutModule(layout, omarchyBarPluginID) +} + +func stepNamed(steps []omarchyStep, name string) omarchyStep { + for _, step := range steps { + if step.Name == name { + return step + } } - t.Setenv("HEY_NO_KEYRING", "1") - t.Setenv("HEY_BASE_URL", "") - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - t.Setenv("XDG_STATE_HOME", stateHome) - t.Setenv("XDG_CACHE_HOME", tmpDir) + return omarchyStep{} +} - root := newRootCmd() - var buf bytes.Buffer - root.SetOut(&buf) - root.SetErr(&buf) - root.SetArgs(append([]string{"omarchy", "bar-status", "--base-url", serverURL}, extraArgs...)) - err := root.Execute() - return buf.String(), err +func TestOmarchySetupSkipsTheBarWithoutThePlugin(t *testing.T) { + env, _ := testOmarchyEnv(t) + on := true + + // No shell.json at all: nothing to configure, and nothing is created — + // setup never adds a layout entry, the plugin install does. + bar := stepNamed(omarchySetup{env: env, notify: &on}.apply(), "bar plugin") + if bar.Status != "skipped" || !strings.Contains(bar.Detail, omarchyBarPluginInstall) { + t.Errorf("without the plugin the bar step should say how to install it, got %q %q", bar.Status, bar.Detail) + } + if _, err := os.Stat(env.shellPath()); !os.IsNotExist(err) { + t.Error("setup must not create a shell.json just to skip") + } + + // A layout without the plugin entry is the same thing. + writeShell(t, env, defaultShellJSON) + if bar := stepNamed(omarchySetup{env: env}.apply(), "bar plugin"); bar.Status != "skipped" { + t.Errorf("a layout without the plugin should be skipped, got %q", bar.Status) + } + if readText(t, env.shellPath()) != defaultShellJSON { + t.Error("skipping must leave shell.json untouched") + } } -func TestBarStatusLightsOnUnread(t *testing.T) { - server := imboxServer(t, `[{"id": 1, "name": "Lunch on Thursday?", "seen": true}, {"id": 2, "name": "Invoice #4021", "seen": false}]`) - defer server.Close() +func TestOmarchySetupNotifyTogglesThePluginSetting(t *testing.T) { + env, _ := testOmarchyEnv(t) + writeShell(t, env, pluginShellJSON) + on, off := true, false - out, err := runBarStatus(t, server.URL, true) - if err != nil { - t.Fatal(err) + bar := stepNamed(omarchySetup{env: env, notify: &on}.apply(), "bar plugin") + if bar.Status != "installed" || bar.Detail != "notifications on" || pluginEntry(t, env)["notify"] != true { + t.Errorf("--notify should set notify on the entry, got %q %q %v", bar.Status, bar.Detail, pluginEntry(t, env)) + } + if bar := stepNamed(omarchySetup{env: env, notify: &on}.apply(), "bar plugin"); bar.Status != "unchanged" { + t.Errorf("--notify twice should be idempotent, got %q", bar.Status) + } + bar = stepNamed(omarchySetup{env: env}.apply(), "bar plugin") + if bar.Status != "unchanged" || bar.Detail != "notifications on" || pluginEntry(t, env)["notify"] != true { + t.Errorf("a plain re-run must leave notifications as they are, got %q %q", bar.Status, bar.Detail) } - var module map[string]any - if err := json.Unmarshal([]byte(out), &module); err != nil { - t.Fatalf("output is not JSON: %q", out) + bar = stepNamed(omarchySetup{env: env, notify: &off}.apply(), "bar plugin") + if _, has := pluginEntry(t, env)["notify"]; bar.Status != "installed" || bar.Detail != "notifications off" || has { + t.Errorf("--no-notify should delete the key, got %q %q %v", bar.Status, bar.Detail, pluginEntry(t, env)) } - if module["class"] != "active" || module["text"] != omarchyBarGlyph { - t.Errorf("unexpected module: %v", module) + if bar := stepNamed(omarchySetup{env: env, notify: &off}.apply(), "bar plugin"); bar.Status != "unchanged" { + t.Errorf("--no-notify twice should be idempotent, got %q", bar.Status) + } + // Only a JSON true counts, as in the plugin's own setting() read. + writeShell(t, env, `{"version":1,"bar":{"layout":{"right":[{"id":"37signals.hey","notify":"yes"}]}}}`) + if bar := stepNamed(omarchySetup{env: env}.apply(), "bar plugin"); bar.Detail != "notifications off" { + t.Errorf("a non-boolean notify is off, got %q", bar.Detail) } } -func TestBarStatusSilentWhenClear(t *testing.T) { - server := imboxServer(t, `[{"id": 1, "name": "Lunch on Thursday?", "seen": true}]`) - defer server.Close() +func TestOmarchySetupFindsThePluginInAnySection(t *testing.T) { + env, _ := testOmarchyEnv(t) + writeShell(t, env, `{"version":1,"bar":{"layout":{"left":[{"id":"omarchy.menu"}],"center":[{"id":"37signals.hey"},{"id":"omarchy.clock"}],"right":[]}}}`) + on := true + + if bar := stepNamed(omarchySetup{env: env, notify: &on}.apply(), "bar plugin"); bar.Status != "installed" { + t.Fatalf("bar step = %q %q", bar.Status, bar.Detail) + } + center := readShell(t, env)["bar"].(map[string]any)["layout"].(map[string]any)["center"].([]any) + if entry := center[0].(map[string]any); entry["id"] != "37signals.hey" || entry["notify"] != true { + t.Errorf("the entry should be updated in place: %v", center) + } +} + +func TestOmarchySetupRemovesTheLegacyBarModule(t *testing.T) { + env, _ := testOmarchyEnv(t) + writeShell(t, env, legacyShellJSON) + + // No plugin yet: the legacy module goes, reported as its own step, and the + // plugin step says what to install — and how to keep the toasts the module + // had on. The layout equalled the defaults once the module was out, so it + // goes too and the user is back to inheriting Omarchy's defaults. + steps := omarchySetup{env: env}.apply() + if legacy := stepNamed(steps, "bar indicator"); legacy.Status != "removed" || !strings.Contains(legacy.Detail, "hey-unread") { + t.Errorf("legacy removal should be its own step, got %q %q", legacy.Status, legacy.Detail) + } + if bar := stepNamed(steps, "bar plugin"); bar.Status != "skipped" || !strings.Contains(bar.Detail, omarchyBarPluginInstall) || !strings.Contains(bar.Detail, "--notify") { + t.Errorf("the plugin step should say how to install and how to keep notifying, got %q %q", bar.Status, bar.Detail) + } + if shell := readShell(t, env); shell["bar"] != nil { + t.Errorf("a defaults-seeded layout should be dropped with the module: %v", shell) + } + steps = omarchySetup{env: env}.apply() + if stepNamed(steps, "bar indicator").Name != "" || stepNamed(steps, "bar plugin").Status != "skipped" { + t.Errorf("once migrated there is no legacy step and the plugin step skips, got %v", statuses(steps)) + } + + // With the plugin present and silent on notify, the legacy module's + // --notify carries over; a string-form entry sharing its id is unowned + // and survives. + writeShell(t, env, `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[ + {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify","interval":180}, + {"id":"37signals.hey"},"hey-unread",{"id":"omarchy.tray"}]}}}`) + steps = omarchySetup{env: env}.apply() + if bar := stepNamed(steps, "bar plugin"); bar.Status != "installed" || bar.Detail != "notifications on" { + t.Errorf("the notify choice should move to the plugin, got %q %q", bar.Status, bar.Detail) + } + right := readShell(t, env)["bar"].(map[string]any)["layout"].(map[string]any)["right"].([]any) + if len(right) != 3 || right[0].(map[string]any)["notify"] != true || right[1] != "hey-unread" || barEntryID(right[2]) != "omarchy.tray" { + t.Errorf("only the map-form legacy module goes and notify lands on the plugin: %v", right) + } + + // An explicit plugin setting, or an explicit flag, wins over the legacy + // module's choice. + writeShell(t, env, `{"version":1,"bar":{"layout":{"right":[{"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify"},{"id":"37signals.hey","notify":false}]}}}`) + if bar := stepNamed(omarchySetup{env: env}.apply(), "bar plugin"); bar.Detail != "notifications off" || bar.Status != "unchanged" { + t.Errorf("an explicit plugin setting is kept, and the legacy removal is the other step's news, got %q %q", bar.Status, bar.Detail) + } + off := false + writeShell(t, env, `{"version":1,"bar":{"layout":{"right":[{"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify"},{"id":"37signals.hey"}]}}}`) + if bar := stepNamed(omarchySetup{env: env, notify: &off}.apply(), "bar plugin"); bar.Detail != "notifications off" { + t.Errorf("--no-notify wins over the legacy choice, got %q", bar.Detail) + } + if _, has := pluginEntry(t, env)["notify"]; has { + t.Error("--no-notify must not leave a notify key behind") + } + + // --remove takes the legacy module out as well. + writeShell(t, env, legacyShellJSON) + if legacy := stepNamed(omarchySetup{env: env}.remove(), "bar indicator"); legacy.Status != "removed" { + t.Errorf("remove should take the legacy module out, got %q", legacy.Status) + } + if strings.Contains(readText(t, env.shellPath()), "hey-unread") { + t.Error("legacy module still present after remove") + } +} - out, err := runBarStatus(t, server.URL, true) - if err != nil || out != "" { - t.Errorf("clear imbox should print nothing and succeed, got %q, %v", out, err) +func TestOmarchyDefaultShellPathFallsBackToUserTree(t *testing.T) { + env, _ := testOmarchyEnv(t) + env.omarchyPath = "" // no OMARCHY_PATH, as in a non-login or agent shell + userTree := filepath.Join(env.home, ".local", "share", "omarchy", "config", "omarchy") + if err := os.MkdirAll(userTree, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTree, "shell.json"), []byte(defaultShellJSON), 0o644); err != nil { + t.Fatal(err) + } + writeShell(t, env, legacyShellJSON) + + if legacy := stepNamed(omarchySetup{env: env}.apply(), "bar indicator"); legacy.Status != "removed" { + t.Fatalf("legacy step = %q %q", legacy.Status, legacy.Detail) + } + if shell := readShell(t, env); shell["bar"] != nil { + t.Errorf("the per-user omarchy tree should supply the defaults the layout is compared to: %v", shell) } } -func TestBarStatusSilentWhenUnauthenticatedOrOffline(t *testing.T) { +func TestPollIgnoresRepositoryLocalConfig(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".hey", "config.json"), []byte(`{"base_url":"https://untrusted.example.com"}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) server := imboxServer(t, `[]`) defer server.Close() - out, err := runBarStatus(t, server.URL, false) - if err != nil || out != "" { - t.Errorf("logged out should print nothing and succeed, got %q, %v", out, err) + // The plugin runs the poll from the shell's working directory, wherever + // that is: an untrusted checkout must neither fail it at the trust gate + // nor point it at the checkout's server. + resp, err := runPoll(t, t.TempDir(), server.URL, true) + if err != nil || !resp.OK { + t.Errorf("the poll must answer from an untrusted checkout, got %+v, %v", resp, err) + } + if cfg.UntrustedLocalConfig() != nil || cfg.SourceOf("base_url") != config.SourceFlag { + t.Errorf("the poll must load global configuration only, got base_url from %v", cfg.SourceOf("base_url")) } - server.Close() - out, err = runBarStatus(t, server.URL, true) - if err != nil || out != "" { - t.Errorf("offline should print nothing and succeed, got %q, %v", out, err) + root := newRootCmd() + command, _, _ := root.Find([]string{"setup", "omarchy"}) + if !commandIgnoresLocalConfig(command) { + t.Error("setup omarchy edits fixed desktop paths only and must ignore a checkout's config too") + } + if boxes, _, _ := root.Find([]string{"boxes"}); commandIgnoresLocalConfig(boxes) { + t.Error("ordinary commands keep honouring repository-local config") } } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d4b2d2fc..023be735 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -74,14 +74,14 @@ func newRootCmd() *cobra.Command { } var err error - configDegraded = false + errConfigDegraded = nil if commandIgnoresLocalConfig(cmd) { - // These commands never fail on configuration: a malformed global - // file leaves them on the baseline defaults, where the bar poller - // simply finds no credentials and stays dark. + // These commands do not fail on configuration here: a malformed + // global file leaves them on the baseline defaults and records + // the error, so setup omarchy --remove still works and the poll + // can report it instead of answering for a guessed server. if cfg, err = config.LoadGlobal(); err != nil { - cfg, err = config.Defaults(), nil - configDegraded = true + cfg, errConfigDegraded, err = config.Defaults(), err, nil } } else { cfg, err = config.Load() @@ -206,8 +206,8 @@ func commandUsesAccountScope(cmd *cobra.Command) bool { return true } switch parts[1] { - // omarchy is exempt because its bar-status must never fail: it selects the - // configured account itself and treats a failed selection as a dark indicator. + // omarchy is exempt so its poll checks authentication before selecting the + // configured account itself: the plugin branches on the auth error envelope. case "accounts", "auth", "commands", "completion", "config", "doctor", "omarchy", "setup", "skill", "upgrade", "version": return false default: diff --git a/internal/tui/mail.go b/internal/tui/mail.go index c068cbb7..0ec45700 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -426,38 +426,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if msg.err != nil { return func() tea.Msg { return errMsg{msg.err} }, true } - if msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { - return nil, true - } - v.notice = msg.action - idx := v.postingIndex(msg.postingID) - if idx >= 0 { - switch msg.effect { - case postingActionNone: - case postingActionRemove: - v.postingList.postings = append(v.postingList.postings[:idx], v.postingList.postings[idx+1:]...) - if v.postingList.cursor > idx { - v.postingList.cursor-- - } - if v.postingList.cursor >= len(v.postingList.postings) && v.postingList.cursor > 0 { - v.postingList.cursor-- - } - v.postingList.ensureVisible() - case postingActionSeen: - v.postingList.postings[idx].Seen = true - v.postingList.resort() - case postingActionIgnore: - v.postingList.postings[idx].Muted = true - case postingActionStopIgnoring: - v.postingList.postings[idx].Muted = false - } - } - if v.activeRequestKind == mailRequestPostings { - if source := v.currentSource(); source != nil { - return v.requestPostings(*source), true - } - } - return nil, true + // The mutation landed whether or not this view still shows the box, and + // the bar plugin's unread set changed with it. + return tea.Batch(v.applyPostingAction(msg), omarchyRefresh()), true case folderActionDoneMsg: v.finishMutation() @@ -1254,6 +1225,44 @@ func (v *mailView) requestTopic(boxID, topicID int64, title string) tea.Cmd { return v.fetchTopic(ctx, requestID, boxID, topicID, title) } +// applyPostingAction reflects a completed posting mutation in the list and +// reloads it when a load was already in flight, so the reload cannot restore +// what the action removed. +func (v *mailView) applyPostingAction(msg postingActionDoneMsg) tea.Cmd { + if msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { + return nil + } + v.notice = msg.action + idx := v.postingIndex(msg.postingID) + if idx >= 0 { + switch msg.effect { + case postingActionNone: + case postingActionRemove: + v.postingList.postings = append(v.postingList.postings[:idx], v.postingList.postings[idx+1:]...) + if v.postingList.cursor > idx { + v.postingList.cursor-- + } + if v.postingList.cursor >= len(v.postingList.postings) && v.postingList.cursor > 0 { + v.postingList.cursor-- + } + v.postingList.ensureVisible() + case postingActionSeen: + v.postingList.postings[idx].Seen = true + v.postingList.resort() + case postingActionIgnore: + v.postingList.postings[idx].Muted = true + case postingActionStopIgnoring: + v.postingList.postings[idx].Muted = false + } + } + if v.activeRequestKind == mailRequestPostings { + if source := v.currentSource(); source != nil { + return v.requestPostings(*source) + } + } + return nil +} + func (v *mailView) postingIndex(postingID int64) int { for i := range v.postingList.postings { if v.postingList.postings[i].ID == postingID { diff --git a/internal/tui/omarchy_refresh.go b/internal/tui/omarchy_refresh.go new file mode 100644 index 00000000..5c613d0e --- /dev/null +++ b/internal/tui/omarchy_refresh.go @@ -0,0 +1,50 @@ +package tui + +import ( + "context" + "io" + "os/exec" + "time" + + tea "charm.land/bubbletea/v2" +) + +// The Omarchy bar plugin (37signals.hey) polls the Imbox every few minutes, so +// on its own it would show a thread as unread for minutes after the TUI marked +// it seen or archived it. After every posting mutation the TUI asks the plugin +// to refresh over the shell's IPC; the plugin guards against overlapping +// refreshes, so a burst of mutations costs a few no-op calls, not a fetch storm. + +const ( + omarchyBarPluginID = "37signals.hey" + // omarchy-shell itself gives up after two seconds; this only bounds a hung + // binary. + omarchyRefreshTimeout = 3 * time.Second +) + +// omarchyShellRunner runs a shell command and discards whatever it says: +// whether the refresh reached the plugin is nothing the TUI can act on. A +// package-level seam so tests can record the invocation. +var omarchyShellRunner = func(name string, args ...string) { + if _, err := exec.LookPath(name); err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), omarchyRefreshTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // G204: fixed omarchy command + cmd.Stdout, cmd.Stderr = io.Discard, io.Discard + _ = cmd.Run() +} + +// omarchyRefresh returns a command that nudges the bar plugin to refresh, or +// nil when this machine does not run Omarchy. -q keeps omarchy-shell quiet and +// successful even when the shell or the plugin is not running. +func omarchyRefresh() tea.Cmd { + if omarchyThemeDir(userHomeDir()) == "" { + return nil + } + return func() tea.Msg { + omarchyShellRunner("omarchy-shell", "-q", omarchyBarPluginID, "refresh") + return nil + } +} diff --git a/internal/tui/omarchy_refresh_test.go b/internal/tui/omarchy_refresh_test.go new file mode 100644 index 00000000..d01c6ac5 --- /dev/null +++ b/internal/tui/omarchy_refresh_test.go @@ -0,0 +1,103 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// fakeOmarchyHome points HOME at a directory with (or without) Omarchy's state +// dir and records what the refresh command would run. +func fakeOmarchyHome(t *testing.T, omarchy bool) *[]string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + if omarchy { + if err := os.MkdirAll(filepath.Join(home, ".local", "state", "omarchy"), 0o755); err != nil { + t.Fatal(err) + } + } + var ran []string + previous := omarchyShellRunner + omarchyShellRunner = func(name string, args ...string) { + ran = append(ran, strings.Join(append([]string{name}, args...), " ")) + } + t.Cleanup(func() { omarchyShellRunner = previous }) + return &ran +} + +func TestOmarchyRefreshNudgesTheBarPlugin(t *testing.T) { + ran := fakeOmarchyHome(t, true) + + cmd := omarchyRefresh() + if cmd == nil { + t.Fatal("under Omarchy the refresh command must exist") + } + if msg := cmd(); msg != nil { + t.Errorf("the refresh produces no message, got %T", msg) + } + if len(*ran) != 1 || (*ran)[0] != "omarchy-shell -q 37signals.hey refresh" { + t.Errorf("ran %v", *ran) + } +} + +func TestOmarchyRefreshIsANoOpOutsideOmarchy(t *testing.T) { + ran := fakeOmarchyHome(t, false) + + if cmd := omarchyRefresh(); cmd != nil { + t.Error("without Omarchy's state dir there is no shell to nudge") + } + if len(*ran) != 0 { + t.Errorf("nothing may run, ran %v", *ran) + } +} + +func TestMailViewPostingActionRefreshesTheBarPlugin(t *testing.T) { + ran := fakeOmarchyHome(t, true) + v := mailWithPostings() + + cmd, consumed := v.Update(postingActionDoneMsg{action: "Thread marked as seen", boxID: 1, postingID: 100, effect: postingActionSeen}) + if !consumed || cmd == nil { + t.Fatal("a completed action should batch the bar refresh") + } + runBatch(cmd) + if len(*ran) != 1 { + t.Errorf("the bar plugin should be refreshed once after a posting action, ran %v", *ran) + } + + // A failed action changed nothing, so there is nothing to refresh. + cmd, _ = v.Update(postingActionDoneMsg{boxID: 1, postingID: 100, err: os.ErrClosed}) + runBatch(cmd) + if len(*ran) != 1 { + t.Errorf("a failed action must not refresh, ran %v", *ran) + } +} + +func TestScreenerDecisionRefreshesTheBarPlugin(t *testing.T) { + ran := fakeOmarchyHome(t, true) + v := newScreenerView(testVC()) + + cmd, _ := v.Update(screenerDecisionDoneMsg{clearanceID: 1, name: "Maria Delgado", status: "approved"}) + runBatch(cmd) + cmd, _ = v.Update(screenerClearedMsg{}) + runBatch(cmd) + if len(*ran) != 2 { + t.Errorf("approving a sender and clearing The Screener both change the Imbox, ran %v", *ran) + } +} + +// runBatch executes a command and any batch it expands to, synchronously. +func runBatch(cmd tea.Cmd) { + if cmd == nil { + return + } + switch msg := cmd().(type) { + case tea.BatchMsg: + for _, inner := range msg { + runBatch(inner) + } + } +} diff --git a/internal/tui/screener.go b/internal/tui/screener.go index e257c13b..0de06a77 100644 --- a/internal/tui/screener.go +++ b/internal/tui/screener.go @@ -205,7 +205,9 @@ func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) { v.pendingCount = max(v.pendingCount-1, 0) v.history.loaded = false v.notice = msg.name + " " + screenedVerb(msg.status) - return nil, true + // An approved sender's mail lands in the Imbox; the bar plugin shows + // the Screener count too. Either way its view is stale now. + return omarchyRefresh(), true case screenerClearedMsg: if v.mutations > 0 { @@ -218,7 +220,7 @@ func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) { v.pending.setRows(nil, 1) v.pendingCount = 0 v.notice = "The Screener is clearing. Everyone waiting will be asked about again on their next email." - return nil, true + return omarchyRefresh(), true } return nil, false } diff --git a/tests/smoke/helpers_test.go b/tests/smoke/helpers_test.go index b2a55752..05be34ca 100644 --- a/tests/smoke/helpers_test.go +++ b/tests/smoke/helpers_test.go @@ -23,6 +23,7 @@ var ( binaryPath string baseURL string configDir string + stateDir string sessionCookie string smokeEmail string ) @@ -76,6 +77,13 @@ func TestMain(m *testing.M) { fmt.Fprintf(os.Stderr, "Failed to create temp config dir: %v\n", err) os.Exit(1) } + // And an isolated state directory: hey omarchy poll --notify keeps its + // toast fingerprints there. + stateDir, err = os.MkdirTemp("", "hey-smoke-state-*") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create temp state dir: %v\n", err) + os.Exit(1) + } // Launch headless Chrome browser and log in to obtain a session cookie. sessionCookie, err = browserLogin(baseURL, smokeEmail, password) if err != nil { @@ -91,6 +99,7 @@ func TestMain(m *testing.M) { code := m.Run() os.RemoveAll(configDir) + os.RemoveAll(stateDir) os.Exit(code) } @@ -176,6 +185,7 @@ func cliEnv() []string { env = append(env, "HEY_BASE_URL="+baseURL, "XDG_CONFIG_HOME="+configDir, + "XDG_STATE_HOME="+stateDir, "HEY_NO_KEYRING=1", "NO_COLOR=1", "TERM=dumb", diff --git a/tests/smoke/omarchy_test.go b/tests/smoke/omarchy_test.go new file mode 100644 index 00000000..3a103b03 --- /dev/null +++ b/tests/smoke/omarchy_test.go @@ -0,0 +1,71 @@ +package smoke_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// hey omarchy poll is what the Omarchy bar plugin runs: it must answer with the +// same box shape hey box imbox --json does, honour --limit, and with --notify +// keep its toast fingerprints in the state directory without ever failing on +// the toast itself (omarchy-notification-send is not on a smoke machine). + +func TestOmarchyPollMatchesBoxImbox(t *testing.T) { + poll := heyJSON(t, "omarchy", "poll", "--limit", "5") + box := heyJSON(t, "box", "imbox", "--limit", "5") + + var pollBox, imbox struct { + ID int64 `json:"id"` + Name string `json:"name"` + Postings []json.RawMessage `json:"postings"` + } + if err := json.Unmarshal(poll.Data, &pollBox); err != nil { + t.Fatalf("poll data: %v", err) + } + if err := json.Unmarshal(box.Data, &imbox); err != nil { + t.Fatalf("box data: %v", err) + } + if pollBox.ID != imbox.ID || pollBox.Name != imbox.Name { + t.Errorf("poll answers for %d %q, box imbox for %d %q", pollBox.ID, pollBox.Name, imbox.ID, imbox.Name) + } + if len(pollBox.Postings) > 5 { + t.Errorf("--limit 5 returned %d postings", len(pollBox.Postings)) + } + if len(pollBox.Postings) != len(imbox.Postings) { + t.Errorf("poll returned %d postings, box imbox %d", len(pollBox.Postings), len(imbox.Postings)) + } +} + +func TestOmarchyPollNotifySeedsState(t *testing.T) { + statePath := filepath.Join(stateDir, "hey-cli", "omarchy-poll.json") + _ = os.Remove(statePath) + + resp := heyJSON(t, "omarchy", "poll", "--limit", "5", "--notify") + if !resp.OK { + t.Fatal("poll --notify should succeed without a notification daemon") + } + state, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("the first --notify poll should seed %s: %v", statePath, err) + } + if !strings.Contains(string(state), `"identity"`) || !strings.Contains(string(state), `"seen"`) { + t.Errorf("unexpected state file: %s", state) + } + + // A poll without --notify forgets the seed, so turning toasts back on + // starts silent. + heyJSON(t, "omarchy", "poll", "--limit", "5") + if _, err := os.Stat(statePath); !os.IsNotExist(err) { + t.Error("a poll without --notify should remove the fingerprints") + } +} + +func TestOmarchyPollRejectsABadLimit(t *testing.T) { + _, stderr := heyFail(t, "omarchy", "poll", "--limit", "0") + if !strings.Contains(stderr, "--limit") { + t.Errorf("expected a usage error about --limit, got %q", stderr) + } +}