wherdr 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{BB3uOVbO.js → 8ksjcuDu.js} +51 -41
  3. package/.output/public/_nuxt/{AgentView.DE2GDt4O.css → AgentView.C4YOwvZG.css} +1 -1
  4. package/.output/public/_nuxt/B7QP1TFs.js +23 -0
  5. package/.output/public/_nuxt/{Bta8at9w.js → BOpBIziZ.js} +2 -2
  6. package/.output/public/_nuxt/BWXdFfeg.js +17 -0
  7. package/.output/public/_nuxt/COFCNFpG.js +83 -0
  8. package/.output/public/_nuxt/{C3BWhslt.js → Cd8j9O5w.js} +1 -1
  9. package/.output/public/_nuxt/CkZ8t6uB.js +1 -0
  10. package/.output/public/_nuxt/{OAXEskzA.js → D4Yz4rat.js} +4 -4
  11. package/.output/public/_nuxt/{C3P2P5Mv.js → D7sxNXx1.js} +1 -1
  12. package/.output/public/_nuxt/DViwOclv.js +1 -0
  13. package/.output/public/_nuxt/DoD7Qb5P.js +30 -0
  14. package/.output/public/_nuxt/DyBZDSCV.js +2 -0
  15. package/.output/public/_nuxt/builds/latest.json +1 -1
  16. package/.output/public/_nuxt/builds/meta/7afbf062-5369-4002-b20d-7a582ff6e0f1.json +1 -0
  17. package/.output/public/_nuxt/{entry.SHBiCXBL.css → entry.Crt1r4l3.css} +1 -1
  18. package/.output/public/_nuxt/hePW80VL.js +1 -0
  19. package/.output/public/sw.js +1 -1
  20. package/.output/server/chunks/_/branding.mjs +1 -1
  21. package/.output/server/chunks/_/package.mjs +96 -0
  22. package/.output/server/chunks/_/plugins.mjs +11 -3
  23. package/.output/server/chunks/_/projectBoard.mjs +1 -1
  24. package/.output/server/chunks/_/projectsPlugin.mjs +1 -1
  25. package/.output/server/chunks/_/selfupdate.mjs +82 -0
  26. package/.output/server/chunks/_/updates.mjs +50 -0
  27. package/.output/server/chunks/_/worktrees.mjs +1 -1
  28. package/.output/server/chunks/_/ws.mjs +1 -1
  29. package/.output/server/chunks/nitro/nitro.mjs +1728 -917
  30. package/.output/server/chunks/raw/bump-homebrew.mjs +7 -0
  31. package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
  32. package/.output/server/chunks/routes/api/_..._.mjs +5 -1
  33. package/.output/server/chunks/routes/api/agents.post.mjs +5 -1
  34. package/.output/server/chunks/routes/api/ask-tab.post.mjs +5 -1
  35. package/.output/server/chunks/routes/api/attach.post.mjs +5 -1
  36. package/.output/server/chunks/routes/api/auth/disable.post.mjs +5 -1
  37. package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +5 -1
  38. package/.output/server/chunks/routes/api/auth/lock.post.mjs +5 -1
  39. package/.output/server/chunks/routes/api/auth/login/options.post.mjs +5 -1
  40. package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +5 -1
  41. package/.output/server/chunks/routes/api/auth/max-session.post.mjs +5 -1
  42. package/.output/server/chunks/routes/api/auth/register/options.post.mjs +5 -1
  43. package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +5 -1
  44. package/.output/server/chunks/routes/api/auth/status.get.mjs +5 -1
  45. package/.output/server/chunks/routes/api/changes.get.mjs +5 -1
  46. package/.output/server/chunks/routes/api/chat/image.get.mjs +5 -1
  47. package/.output/server/chunks/routes/api/chat.get.mjs +5 -1
  48. package/.output/server/chunks/routes/api/choose.post.mjs +5 -1
  49. package/.output/server/chunks/routes/api/claude-statusline.post.mjs +5 -1
  50. package/.output/server/chunks/routes/api/close/status.get.mjs +5 -1
  51. package/.output/server/chunks/routes/api/close.post.mjs +5 -1
  52. package/.output/server/chunks/routes/api/codex-update.post.mjs +5 -1
  53. package/.output/server/chunks/routes/api/commands.get.mjs +5 -1
  54. package/.output/server/chunks/routes/api/config.get.mjs +5 -1
  55. package/.output/server/chunks/routes/api/dirs.get.mjs +5 -1
  56. package/.output/server/chunks/routes/api/dismiss.post.mjs +5 -1
  57. package/.output/server/chunks/routes/api/effort.post.mjs +5 -1
  58. package/.output/server/chunks/routes/api/efforts.get.mjs +5 -1
  59. package/.output/server/chunks/routes/api/file.post.mjs +5 -1
  60. package/.output/server/chunks/routes/api/gitroot.get.mjs +5 -1
  61. package/.output/server/chunks/routes/api/health.get.mjs +36 -0
  62. package/.output/server/chunks/routes/api/index.get.mjs +5 -1
  63. package/.output/server/chunks/routes/api/input.post.mjs +5 -1
  64. package/.output/server/chunks/routes/api/interrupt.post.mjs +5 -1
  65. package/.output/server/chunks/routes/api/isgit.get.mjs +5 -1
  66. package/.output/server/chunks/routes/api/machine/awake.get.mjs +5 -1
  67. package/.output/server/chunks/routes/api/machine/awake.post.mjs +5 -1
  68. package/.output/server/chunks/routes/api/machine/order.get.mjs +5 -1
  69. package/.output/server/chunks/routes/api/machine/order.post.mjs +5 -1
  70. package/.output/server/chunks/routes/api/machine/rename.post.mjs +5 -1
  71. package/.output/server/chunks/routes/api/menu.post.mjs +5 -1
  72. package/.output/server/chunks/routes/api/model.post.mjs +5 -1
  73. package/.output/server/chunks/routes/api/models.get.mjs +5 -1
  74. package/.output/server/chunks/routes/api/nav.post.mjs +5 -1
  75. package/.output/server/chunks/routes/api/onboarding.get.mjs +5 -1
  76. package/.output/server/chunks/routes/api/onboarding.post.mjs +5 -1
  77. package/.output/server/chunks/routes/api/phone.get.mjs +5 -6
  78. package/.output/server/chunks/routes/api/phone.post.mjs +5 -6
  79. package/.output/server/chunks/routes/api/plugins/actions.get.mjs +5 -1
  80. package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +5 -1
  81. package/.output/server/chunks/routes/api/plugins/profiles.get.mjs +5 -1
  82. package/.output/server/chunks/routes/api/plugins/projects.get.mjs +5 -1
  83. package/.output/server/chunks/routes/api/plugins/projects.post.mjs +5 -1
  84. package/.output/server/chunks/routes/api/plugins/thread-limits.get.mjs +6 -2
  85. package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +6 -2
  86. package/.output/server/chunks/routes/api/project/report.get.mjs +5 -1
  87. package/.output/server/chunks/routes/api/prompt.post.mjs +6 -2
  88. package/.output/server/chunks/routes/api/push/quiet.post.mjs +6 -2
  89. package/.output/server/chunks/routes/api/push/subscribe.post.mjs +6 -2
  90. package/.output/server/chunks/routes/api/push/test.post.mjs +6 -2
  91. package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +6 -2
  92. package/.output/server/chunks/routes/api/quotas.get.mjs +6 -2
  93. package/.output/server/chunks/routes/api/rename.post.mjs +5 -1
  94. package/.output/server/chunks/routes/api/requeue.post.mjs +6 -2
  95. package/.output/server/chunks/routes/api/restart.get.mjs +5 -1
  96. package/.output/server/chunks/routes/api/restart.post.mjs +5 -1
  97. package/.output/server/chunks/routes/api/reveal.post.mjs +5 -1
  98. package/.output/server/chunks/routes/api/screen-key.post.mjs +6 -2
  99. package/.output/server/chunks/routes/api/screen.get.mjs +12 -6
  100. package/.output/server/chunks/routes/api/search.get.mjs +6 -2
  101. package/.output/server/chunks/routes/api/seen.post.mjs +6 -2
  102. package/.output/server/chunks/routes/api/sessions.get.mjs +6 -2
  103. package/.output/server/chunks/routes/api/sessions.post.mjs +6 -2
  104. package/.output/server/chunks/routes/api/space.post.mjs +6 -2
  105. package/.output/server/chunks/routes/api/state.get.mjs +6 -2
  106. package/.output/server/chunks/routes/api/unqueue.post.mjs +6 -2
  107. package/.output/server/chunks/routes/api/update/status.get.mjs +38 -0
  108. package/.output/server/chunks/routes/api/update.get.mjs +11 -50
  109. package/.output/server/chunks/routes/api/update.post.mjs +41 -0
  110. package/.output/server/chunks/routes/api/upload.post.mjs +5 -1
  111. package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +5 -1
  112. package/.output/server/chunks/routes/api/worktrees.get.mjs +5 -1
  113. package/.output/server/chunks/routes/favicon.ico.get.mjs +5 -1
  114. package/.output/server/chunks/routes/icons/_name_.get.mjs +6 -2
  115. package/.output/server/chunks/routes/renderer.mjs +7 -3
  116. package/.output/server/chunks/routes/uploads/_...name_.get.mjs +6 -2
  117. package/.output/server/chunks/routes/ws/_..._.mjs +6 -2
  118. package/.output/server/chunks/routes/ws/events.mjs +6 -2
  119. package/.output/server/chunks/routes/ws/mirror.mjs +6 -2
  120. package/.output/server/chunks/routes/ws/term.mjs +8 -4
  121. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  122. package/.output/server/index.mjs +6 -2
  123. package/.output/server/package.json +1 -1
  124. package/README.md +216 -45
  125. package/bin/lib/brew.mjs +86 -0
  126. package/bin/lib/commands.mjs +74 -16
  127. package/bin/lib/core.mjs +23 -3
  128. package/bin/lib/tailnet.d.mts +12 -3
  129. package/bin/lib/tailnet.mjs +103 -25
  130. package/bin/lib/updater.mjs +267 -0
  131. package/package.json +2 -1
  132. package/.output/public/_nuxt/01adBkx4.js +0 -1
  133. package/.output/public/_nuxt/BJsf0WRn.js +0 -2
  134. package/.output/public/_nuxt/BpQH8Ho1.js +0 -1
  135. package/.output/public/_nuxt/Cc2bsOcu.js +0 -83
  136. package/.output/public/_nuxt/DKmoLFfn.js +0 -30
  137. package/.output/public/_nuxt/DLLQgP0d.js +0 -1
  138. package/.output/public/_nuxt/DcCZr0Ya.js +0 -17
  139. package/.output/public/_nuxt/DsD2R-37.js +0 -23
  140. package/.output/public/_nuxt/builds/meta/ff11fc8b-1e70-4261-a76e-a52155efb196.json +0 -1
  141. package/.output/server/chunks/_/phone.mjs +0 -343
package/README.md CHANGED
@@ -9,18 +9,28 @@ computer and on your phone.</b></p>
9
9
 
10
10
  <p align="center"><a href="https://wherdr.dev"><b>wherdr.dev</b></a> · <code>curl -fsSL https://wherdr.dev/install | sh</code> · <code>herdr plugin install afloury/wherdr</code></p>
11
11
 
12
+ <p align="center"><a href="https://wherdr.dev/demo/"><b>Try it in your browser — no install</b></a>: the real app on scripted agents, nothing runs.</p>
13
+
12
14
  <p align="center"><a href="https://github.com/afloury/wherdr/actions/workflows/ci.yml"><img src="https://github.com/afloury/wherdr/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a></p>
13
15
 
14
16
  ## Quick start
15
17
 
16
- With [Herdr](https://herdr.dev) running and Node.js 22 (or Bun), on macOS or Linux:
18
+ On the computer that runs [Herdr](https://herdr.dev) (≥ 0.9.1), macOS or Linux, one command:
17
19
 
18
20
  ```sh
19
- npx wherdr # or: bunx wherdr, pnpm dlx wherdr — then open http://localhost:7683
21
+ curl -fsSL https://wherdr.dev/install | sh
20
22
  ```
21
23
 
22
- To keep it: `npm install -g wherdr`, then `wherdr service install` starts it at every login, and
23
- `wherdr phone` walks you through phone access. See [the `wherdr` command](#the-wherdr-command).
24
+ It installs the wherdr [Herdr plugin](#install-as-a-herdr-plugin), starts wherdr on
25
+ `http://localhost:7683` and opens its setup guide in your browser; then **prefix+i** in Herdr opens
26
+ the wherdr panel. Without Herdr, it says where to get it; on a Mac without Node.js 22, it offers
27
+ `brew install node`; on Linux, Docker works too. On a server without a screen (or over SSH), it
28
+ prints the address and the phone steps instead. See [The one command](#the-one-command).
29
+
30
+ Other ways, after it: `herdr plugin install afloury/wherdr` (the same plugin, without the
31
+ checks), `npx wherdr` (or `bunx wherdr`, `pnpm dlx wherdr`; see
32
+ [the `wherdr` command](#the-wherdr-command)), `brew install afloury/tap/wherdr`, or
33
+ [Docker by hand](#recommended-always-on-server--tailscale).
24
34
 
25
35
  At first launch, wherdr opens a **setup guide**: the Herdr agents it found, your phone
26
36
  (Tailscale), then the passkey lock and notifications. Skip it any time; finishing or skipping is
@@ -28,6 +38,13 @@ saved on the server (`data/onboarding.json`), so your other devices do not show
28
38
  it from Settings › About › **Setup guide**. On a phone that already opens wherdr through its
29
39
  tailnet address, the phone step becomes Add to Home Screen.
30
40
 
41
+ **Passkeys are tied to the address they are created on.** Once wherdr's tailnet address is
42
+ published and answers, the guide opened on `localhost` shows **Continue on
43
+ <machine>.<tailnet>.ts.net**, which reopens it at the same step on that address, and its Security
44
+ step (like Settings › Security) sends the passkey lock there. Nothing redirects on its own:
45
+ `localhost` stays valid on the computer. The installer, the Herdr plugin and `wherdr open` open
46
+ that address too when it is already published.
47
+
31
48
  wherdr is a small self-hosted web app that talks to the Herdr server on your machine. **On a
32
49
  computer**, it can replace the Herdr terminal client day to day: your spaces, tabs and split panes
33
50
  side by side, live conversations and real terminals, drag-and-drop layout, keyboard shortcuts
@@ -61,21 +78,40 @@ it on `localhost`, or from your other devices over Tailscale.
61
78
  *Unofficial project. Not affiliated with or endorsed by Herdr, Anthropic or OpenAI. Claude,
62
79
  Claude Code, Codex and other product names are trademarks of their respective owners.*
63
80
 
81
+ ## Why wherdr
82
+
83
+ - **Herdr's splits, live.** A tab with several panes is drawn in Herdr's real layout, every pane
84
+ live; drag a pane to move it or a divider to resize, and the change goes to Herdr itself.
85
+ - **A passkey lock.** Face ID, Touch ID, Windows Hello or Android unlock the app; once a passkey
86
+ exists, nothing about your agents is served to a locked session.
87
+ - **The agent's diff.** Git status and diff of each agent's working folder, one click away from
88
+ its conversation.
89
+ - **The herdr-projects board.** A coordinator and its threads grouped under their project, with
90
+ the project's task lists as a live board and one-click replies.
91
+ - **Keyboard and global search.** One shortcut searches every agent and conversation; next
92
+ agent, new tab, pane swaps and `1`–`9` answers never need the mouse.
93
+ - **Real time, and private.** The real terminal over a WebSocket, a push when an agent needs
94
+ you, and your phone through Tailscale: nothing is on the Internet.
95
+
96
+ Details in [Features](#features) and [Security](#security).
97
+
64
98
  ## Contents
65
99
 
66
100
  - [Quick start](#quick-start)
101
+ - [Why wherdr](#why-wherdr)
67
102
  - [Features](#features)
68
103
  - [How it works](#how-it-works)
69
104
  - [Requirements](#requirements)
70
- - [Installation](#installation): [which setup?](#which-setup) ·
105
+ - [Installation](#installation): [the one command](#the-one-command) ·
106
+ [which setup?](#which-setup) ·
71
107
  [always-on server + Tailscale](#recommended-always-on-server--tailscale) ·
72
108
  [other private networks](#other-private-networks) ·
73
109
  [the `wherdr` command](#the-wherdr-command) ·
74
- [one computer, no Docker](#simple-one-computer-no-docker) ·
110
+ [from source](#from-source-no-docker) ·
75
111
  [as a Herdr plugin](#install-as-a-herdr-plugin) ·
76
112
  [Docker on a Mac: Reveal/Open](#wherdr-in-docker-on-a-mac-reveal-in-finder-open-and-modalto)
77
113
  - [Updating](#updating)
78
- - [Install the app on your phone](#install-the-app-on-your-phone)
114
+ - [Install it as an app](#install-it-as-an-app)
79
115
  - [Configuration](#configuration)
80
116
  - [Several machines (SSH)](#several-machines-ssh)
81
117
  - [Claude and Codex quotas](#claude-and-codex-quotas)
@@ -99,7 +135,10 @@ Claude Code, Codex and other product names are trademarks of their respective ow
99
135
  A command the agent proposes (shell code block, or inline `! command`) can be copied without
100
136
  its prompt, or run in the agent's shell mode (`!`, Claude Code, Codex and omp) after you
101
137
  confirm the full command.
102
- - **Terminal view**: the real terminal (xterm.js, WebGL or DOM renderer) with take-over. On a
138
+ - **Terminal view**: the real terminal (xterm.js, WebGL or DOM renderer) with take-over. Opening
139
+ it resizes the real pane to your screen; closing it gives the pane the size it had before
140
+ (after 30 seconds when the connection just dropped, e.g. a locked phone), unless a Herdr
141
+ client attached meanwhile laid it out its own way. On a
103
142
  computer, drag over the text on screen to select it: releasing the button copies it, as do
104
143
  `⌘C` / `Ctrl+Shift+C`. The history is browsed with the wheel, not by dragging past the edge.
105
144
  Links (OSC 8 hyperlinks and plain `http(s)://` URLs, even wrapped over several rows) open in a
@@ -110,7 +149,9 @@ Claude Code, Codex and other product names are trademarks of their respective ow
110
149
  - **Composer**: send messages while the agent works (queued, cancellable), attach or paste
111
150
  photos and the files the agent can read (Claude Code: PDFs, notebooks, text and code of any
112
151
  extension; Codex: notebooks, text and code), run slash commands (`/compact`, `/clear`, `/context`, `/usage`…) and see the output
113
- of local commands. Stop button while the agent works.
152
+ of local commands. Stop button while the agent works. A long pasted text (log, output) becomes
153
+ a "Pasted text · N lines" card, sent to the agent as it is and shown the same way in the
154
+ conversation, where it opens in full with Copy; long typed messages fold with Show more.
114
155
  - **Codex updates and limits**: in a Codex conversation, a new Codex version shows above the
115
156
  field with an **Update** button (runs Codex's official update command on that machine, after
116
157
  confirmation), then **Restart to update** on the same conversation. When wherdr cannot run it
@@ -211,16 +252,64 @@ Web Push, lock) runs in Nitro.
211
252
 
212
253
  ## Installation
213
254
 
255
+ ### The one command
256
+
257
+ On the computer that runs Herdr, as your user, on macOS or Linux:
258
+
259
+ ```sh
260
+ curl -fsSL https://wherdr.dev/install | sh
261
+ ```
262
+
263
+ The script ([read it first](https://wherdr.dev/install), source in
264
+ [`website/public/install`](website/public/install)) never uses sudo. It:
265
+
266
+ 1. checks **Herdr ≥ 0.9.1**; without it, it stops with the link to [herdr.dev](https://herdr.dev);
267
+ 2. leaves alone a wherdr that **already answers** on the port: a Herdr plugin install is offered
268
+ its update, a Docker install shows its update command, anything else is left as it is. A
269
+ `wherdr` container managed from another folder is never replaced or stopped;
270
+ 3. checks what wherdr runs on: Docker Compose v2 on Linux when your user can use it, otherwise
271
+ **Node.js 22** or Bun (always on macOS). On a Mac without them, it offers
272
+ `brew install node` when Homebrew is there; elsewhere it stops with the install links;
273
+ 4. runs `herdr plugin install afloury/wherdr --yes`: the [Herdr plugin](#install-as-a-herdr-plugin)
274
+ installs wherdr in `~/wherdr`, starts it on `http://localhost:7683`, starts it again with Herdr,
275
+ opens the setup guide in your browser (first install) and binds **prefix+i** to its panel;
276
+ 5. on a computer **without a screen** (no `DISPLAY` on Linux, or an SSH session): opens nothing,
277
+ prints the local address, an `ssh -L` tunnel to reach it from your computer, and the phone
278
+ steps (`wherdr phone`: Tailscale state, the address, the QR code once it answers). With
279
+ Docker, it offers to run `tailscale serve` for you (asked first), and wherdr enables the
280
+ published address by itself: no browser is needed on the server. Run the command again at
281
+ any time to check the phone address.
282
+
283
+ Settings, as environment variables (`curl -fsSL https://wherdr.dev/install | WHERDR_PORT=7684 sh`):
284
+
285
+ | Variable | Default | Meaning |
286
+ | --- | --- | --- |
287
+ | `WHERDR_DIR` | `~/wherdr` | Install folder (plugin settings, data, log). |
288
+ | `WHERDR_PORT` | `7683` | Local port. |
289
+ | `WHERDR_MODE` | — | `native`: Node.js even when Docker is there. `docker`: no plugin, Docker Compose by hand in `WHERDR_DIR` (Linux; see below). |
290
+ | `WHERDR_YES` | — | `1` answers yes to every question (non-interactive use). |
291
+ | `WHERDR_NO_BROWSER` | — | `1` never opens a browser. |
292
+ | `WHERDR_REF` | latest | Git tag or branch of the plugin (or of `docker-compose.yml` with `WHERDR_MODE=docker`). |
293
+ | `WHERDR_PLUGIN` | `afloury/wherdr` | A local checkout to link and build instead (development, tests). |
294
+
295
+ **Docker by hand, with the script:** `curl -fsSL https://wherdr.dev/install | WHERDR_MODE=docker sh`
296
+ writes `docker-compose.yml` and `.env` to `~/wherdr`, creates the bind-mount folders, pulls the
297
+ published image and starts it, without the plugin; it asks before replacing a file it did not
298
+ create. The same steps by hand are in the next section.
299
+
300
+ Update: run the command again (it offers the update), or **U** in the wherdr panel.
301
+
214
302
  ### Which setup?
215
303
 
216
- | | **Always-on server + Tailscale** (recommended) | **One computer, no Docker** |
304
+ | | **Always-on server + Tailscale** (recommended) | **One computer** |
217
305
  | --- | --- | --- |
218
- | Runs on | A machine that never sleeps: Raspberry Pi, mini-PC, home server, private VPS (Linux, Docker) | The computer you work on (macOS or Linux, Node.js 22) |
306
+ | Runs on | A machine that never sleeps: Raspberry Pi, mini-PC, home server, private VPS (Linux) | The computer you work on (macOS or Linux) |
219
307
  | Reach it from | Your phone and every computer on your tailnet | That computer only (`http://localhost:7683`) |
220
308
  | Installable app, push notifications, passkeys | Yes (private HTTPS from `tailscale serve`) | Desktop browser only; no phone access |
221
309
  | Other machines (a Mac, a laptop…) | Added in Herdr over SSH, shown in wherdr | Also possible, but they are only reachable while this computer is awake |
222
310
 
223
- Both setups use the same app. You can start with the second one and move to the first later.
311
+ Both use [the one command](#the-one-command) and the same app. You can start with the second one
312
+ and move to the first later.
224
313
 
225
314
  ### Recommended: always-on server + Tailscale
226
315
 
@@ -231,7 +320,9 @@ wherdr stays on your private network: it is **never published on the Internet**.
231
320
  (`herdr` or `herdr server`), with the integrations of the agents you use
232
321
  (`herdr integration install claude|codex`).
233
322
 
234
- **2. wherdr with Docker** (Linux, Compose v2):
323
+ **2. wherdr.** Run [the one command](#the-one-command) on the server (over SSH, it prints the
324
+ address and the phone steps instead of opening a browser), then go to step 3. Or set up Docker
325
+ Compose by hand (Linux, Compose v2), as follows:
235
326
 
236
327
  ```sh
237
328
  git clone https://github.com/afloury/wherdr.git
@@ -246,7 +337,7 @@ Edit `.env` (every value has a default; `HOST_HOME` defaults to your `$HOME`):
246
337
  | `HOST_HOME` | `/home/alice` | Your home folder on the server (default: `$HOME`). Mounted at the same path, read-only. |
247
338
  | `PUID` / `PGID` | `1000` | Your user and group IDs (`id -u`, `id -g`). |
248
339
  | `PORT` | `7683` | Listening port, published on `127.0.0.1` only. |
249
- | `APP_URL` | `https://server.example.ts.net:7683/` | Private HTTPS address of the app (step 3; also used as the Web Push contact and an allowed host). Use `http://localhost:7683/` for local testing. |
340
+ | `APP_URL` | `http://localhost:7683/` | Leave it with Tailscale: wherdr adopts the address of step 3 by itself. Set a private HTTPS address only for [another private network](#other-private-networks) (Web Push contact and allowed host; it then always wins). |
250
341
  | `HOST_LABEL` | `server` | Name of this machine in the app. |
251
342
  | `TZ` | `Europe/Paris` | Time zone for timestamps and logs. |
252
343
  | `WHERDR_UPDATE_CHECK` | `off` | Disable the daily check for a new release (default `on`). |
@@ -284,11 +375,38 @@ tailscale serve --bg --https=7683 http://127.0.0.1:7683
284
375
  ```
285
376
 
286
377
  Tailscale Serve listens on HTTPS port 7683 of your tailnet name, with a valid certificate, and
287
- forwards to wherdr's local HTTP port. Only devices of your tailnet can reach it. Then open
288
- **Settings › Phone** in wherdr on the server (`http://localhost:7683/#/settings?section=phone`),
289
- paste that address: wherdr checks it answers, sets it as `APP_URL` (saved in `data/app-url.json`;
290
- an HTTPS `APP_URL` in `.env` wins) and shows its QR code. Without Docker, the same page publishes
291
- wherdr itself: one button, asked first.
378
+ forwards to wherdr's local HTTP port. Only devices of your tailnet can reach it.
379
+
380
+ **wherdr enables that address by itself**, on a server without a screen too: at startup, every
381
+ 30 seconds, and when an address it does not know yet is opened, it reads what `tailscale serve`
382
+ publishes on this machine and adopts the HTTPS address that points to its own port (allowed
383
+ host and `APP_URL`, saved in `data/app-url.json`; an HTTPS `APP_URL` in `.env` wins). An address
384
+ removed from `tailscale serve` is refused again. Nothing a request says can enable an address:
385
+ only Tailscale's own state on the machine does, and an address that `tailscale funnel` opens to
386
+ the Internet is never adopted. Without Docker, wherdr runs the `tailscale` command; in Docker
387
+ it reads Tailscale's socket, mounted by `docker-compose.yml` (`/var/run/tailscale`). After
388
+ Tailscale itself restarts (an update), restart the container.
389
+
390
+ What that mount gives: Tailscale's local API, with the rights of the host user the container
391
+ runs as. Anyone may read it; if that user is Tailscale's operator
392
+ (`tailscale set --operator=$USER`), the API also accepts changes from it (serve, funnel…), and
393
+ `:ro` on the folder does not prevent that. wherdr itself only sends `GET` requests. This is not
394
+ a new privilege: the container already drives Herdr, which is a shell of that same user. To do
395
+ without it, remove the line from `docker-compose.yml`: wherdr then no longer finds the address
396
+ by itself, and the one command (or the plugin's phone step) run after `tailscale serve` hands
397
+ it over.
398
+
399
+ **Settings › Phone** on the server (`http://localhost:7683/#/settings?section=phone`) shows the
400
+ state and the QR code; without Docker it also publishes wherdr itself: one button, asked first.
401
+ From a terminal, `wherdr phone` (npm, Homebrew) or the [one command](#the-one-command) run again
402
+ (Docker, Herdr plugin) checks the same thing, and
403
+ `curl -fsS http://127.0.0.1:7683/api/phone` prints the state as JSON. A container started from
404
+ an older `docker-compose.yml` has no Tailscale socket: paste the address in Settings › Phone,
405
+ or run the one command again, which sends it.
406
+
407
+ If the phone shows **"This address isn't enabled yet"** (403), the address it opened is not one
408
+ `tailscale serve` publishes for wherdr's port on that machine: the page gives the command to run
409
+ there.
292
410
 
293
411
  **Why HTTPS matters.** Browsers only allow three things on `https://` (or on `localhost` itself):
294
412
 
@@ -296,7 +414,7 @@ wherdr itself: one button, asked first.
296
414
  - **push notifications** (on iOS, only in the installed app);
297
415
  - **passkeys** for the lock screen (Face ID, Touch ID, Windows Hello, Android).
298
416
 
299
- **4. On the phone:** open the address, [install the app](#install-the-app-on-your-phone), then
417
+ **4. On the phone:** open the address, [install the app](#install-it-as-an-app), then
300
418
  Settings → **Enable notifications**. **On the computer:** use the same address in a browser; the
301
419
  desktop layout can replace the Herdr terminal client for daily work.
302
420
 
@@ -328,8 +446,8 @@ works with any private network that gives it an **HTTPS address with a valid cer
328
446
  - **`APP_URL` set to that address** (`.env` with Docker, or the environment of `wherdr` /
329
447
  `npm start`), then restart wherdr. Its hostname is allowed automatically and it enables Web
330
448
  Push. Add `HERDR_WEB_ALLOWED_HOSTS` only if you also open wherdr under another name. The
331
- one-click address check in **Settings › Phone** only knows `*.ts.net` addresses; an HTTPS
332
- `APP_URL` in the environment always wins.
449
+ automatic adoption and the address check in **Settings › Phone** only know the `*.ts.net`
450
+ addresses of `tailscale serve`; an HTTPS `APP_URL` in the environment always wins.
333
451
  - **Never exposed without authentication.** wherdr keeps listening on `127.0.0.1`; only the
334
452
  proxy listens on the private network. Turn on the passkey lock as soon as another device can
335
453
  reach it.
@@ -408,8 +526,8 @@ wherdr phone # phone state, the app's phone setup page, QR code o
408
526
  | `wherdr start` · `stop` · `restart` | Background server, pid and log in `~/wherdr`. `start` says *started* only once the port answers; otherwise it prints the end of the log. With a login service installed, they drive the service. |
409
527
  | `wherdr status` | Address, process, service, data folder and log. |
410
528
  | `wherdr logs` (`-f` to follow, `-n 100`) | The log, `~/wherdr/wherdr.log`. |
411
- | `wherdr open` | Opens wherdr in the browser. |
412
- | `wherdr phone` | Whether your phone can reach wherdr (Tailscale, `tailscale serve`, the address answering), the link to **Settings › Phone** where you publish it in one click, and the QR code once the address answers. |
529
+ | `wherdr open` | Opens wherdr in the browser: its tailnet address when published and answering, else `localhost`. |
530
+ | `wherdr phone` | Whether your phone can reach wherdr (Tailscale, `tailscale serve`, the address answering), the `tailscale serve` command to publish it when it is not (wherdr then enables the address by itself), the link to **Settings › Phone**, and the QR code once the address answers. |
413
531
  | `wherdr service install` · `uninstall` | Start at login: a LaunchAgent on macOS (`~/Library/LaunchAgents/dev.wherdr.plist`), a `systemd --user` unit on Linux (`loginctl enable-linger` keeps it running while you are logged out). |
414
532
  | `wherdr doctor` | Checks Node/Bun, Herdr and its socket, the port, the service, the data folder and `APP_URL`. |
415
533
 
@@ -421,6 +539,18 @@ plugin and the install script use), `--session` (a named Herdr session). Every
421
539
  environment, then that file. `start` and `service install` remember the port and folders for
422
540
  the other commands.
423
541
 
542
+ **Homebrew:** `brew install afloury/tap/wherdr` installs the same package; the first install also
543
+ brings Homebrew's Node.js (heavier than the Herdr plugin when Node.js 22 is already there). Then:
544
+
545
+ 1. `brew services start wherdr`: runs `wherdr run` now and at every login, log in
546
+ `$(brew --prefix)/var/log/wherdr.log` (`wherdr logs` reads it).
547
+ 2. `wherdr open`: the setup guide in your browser.
548
+ 3. Its Phone step (or `wherdr phone`).
549
+
550
+ On a Homebrew install, `wherdr start`, `stop`, `restart` and `service install` point to
551
+ `brew services` (the installed path changes at each upgrade); `wherdr status` and `wherdr doctor`
552
+ show the Homebrew service. Update: `brew upgrade wherdr && brew services restart wherdr`.
553
+
424
554
  The login service records the **absolute path** of the runtime (n, nvm, fnm, Volta or Homebrew
425
555
  node), since launchd and systemd do not see your shell's `PATH`. It refuses to point at a
426
556
  temporary `npx` / `pnpm dlx` / `bunx` folder: install the package globally first. To update:
@@ -430,10 +560,11 @@ Bun runs wherdr too (WebSockets, push, passkeys and Herdr's socket work); when N
430
560
  also installed, `bunx wherdr` hands the server to Node, the reference runtime. Set
431
561
  `WHERDR_RUNTIME=bun` to keep Bun.
432
562
 
433
- ### Simple: one computer, no Docker
563
+ ### From source, no Docker
434
564
 
435
- The quickest way to try it, and the way to run wherdr **on macOS**: Docker Desktop cannot reach
436
- Herdr's Unix socket on the host. With Herdr running, Git and Node.js 22 on the same machine:
565
+ For development, or to run a checkout of your own (Docker Desktop cannot reach Herdr's Unix socket
566
+ on macOS, so wherdr always runs natively there). With Herdr running, Git and Node.js 22 on the
567
+ same machine:
437
568
 
438
569
  ```sh
439
570
  git clone https://github.com/afloury/wherdr.git
@@ -460,12 +591,14 @@ and logs a warning.
460
591
 
461
592
  ### Install as a Herdr plugin
462
593
 
463
- Herdr can also install wherdr itself, on Linux and macOS, with Herdr ≥ 0.9.1. In three lines:
594
+ This is what [the one command](#the-one-command) installs; you can also run it yourself, on Linux
595
+ and macOS, with Herdr ≥ 0.9.1 (no checks for Herdr or Node.js first). In three lines:
464
596
 
465
597
  1. Run `herdr plugin install afloury/wherdr`: it installs wherdr, starts it
466
598
  (`✓ wherdr is running → http://localhost:7683`) and, at the first install on a computer with a
467
599
  screen, opens the setup guide in your browser (`open` on macOS, `xdg-open` on Linux with a
468
- display; a headless server just prints `http://localhost:7683/#/setup`). Updates open nothing.
600
+ display; a headless server, or `WHERDR_NO_BROWSER=1` in the environment of the install, just
601
+ prints `http://localhost:7683/#/setup`). Updates open nothing.
469
602
  2. In Herdr, press **prefix+i** (`ctrl+b` then `i` with the default prefix): the **wherdr** panel,
470
603
  with the state, **O** Open wherdr and **P** Set up my phone. The install prints the key it
471
604
  chose (see [Panel key](#panel-key)).
@@ -479,7 +612,8 @@ the install still succeeds and says why; press **S** in the panel once it is fix
479
612
  setup by itself:
480
613
 
481
614
  - **Docker** on Linux when Docker Compose v2 works for your user: the published image, from
482
- `~/wherdr` (`docker-compose.yml`, `.env`), exactly like the [one-line installer](https://wherdr.dev/install).
615
+ `~/wherdr` (`docker-compose.yml`, `.env`), the same files as `WHERDR_MODE=docker` of
616
+ [the one command](#the-one-command).
483
617
  - **Node.js 22** everywhere else, and always on macOS (Docker Desktop cannot reach Herdr's
484
618
  socket): the plugin downloads the prebuilt npm package of its version (or builds wherdr in its
485
619
  own folder when that fails), copies it to `~/wherdr/app` and runs it detached from there, with
@@ -522,7 +656,7 @@ Without a key: `herdr plugin action invoke panel --plugin afloury.wherdr`. The p
522
656
  | Action | What it does |
523
657
  |---|---|
524
658
  | `panel` | The **wherdr** panel (a popup) |
525
- | `open` | Opens wherdr in the browser |
659
+ | `open` | Opens wherdr in the browser (its tailnet address when published and answering) |
526
660
 
527
661
  Up to 1.2.0 the panel action was called `wherdr`: change a key bound to `afloury.wherdr.wherdr` to
528
662
  `afloury.wherdr.panel` (Herdr has no action aliases; the install warns about such a key and
@@ -604,12 +738,32 @@ home screen and in Settings › About, with the release notes and the exact comm
604
738
  Hide it until the next version with ×. Set `WHERDR_UPDATE_CHECK=off` to disable the check. The
605
739
  installed version is shown in Settings › About.
606
740
 
741
+ **Update in one tap.** When wherdr runs from the Herdr plugin (Node.js mode), a global npm install
742
+ or Homebrew, the banner has an **Update** button instead of the command, for a session opened on
743
+ this computer (`localhost`) or unlocked with a passkey. wherdr installs the new version in a
744
+ process of its own, restarts, and checks that `/api/health` reports the new version within 60
745
+ seconds; the app shows the progress and reloads on the new version. If the new version does not
746
+ answer, wherdr goes back to the previous one by itself (the plugin keeps the previous copy in
747
+ `~/wherdr/app.prev`, npm reinstalls the previous version, Homebrew points back to the previous keg)
748
+ and the app says so. Every step is logged in `~/wherdr/update.log`.
749
+
750
+ | Setup | What Update does |
751
+ | --- | --- |
752
+ | Herdr plugin, Node.js | Unpacks the npm package of the new version into `~/wherdr/app`, keeps the old copy, restarts |
753
+ | `npm install -g wherdr` | `npm install -g wherdr@X.Y.Z`, then restarts (login service included) |
754
+ | Homebrew | `brew upgrade wherdr`, then `brew services restart wherdr` |
755
+
756
+ Docker shows the command instead: replacing its own container would need the Docker socket (root
757
+ on the host) inside it. `npx wherdr` runs from a temporary folder: run `npx wherdr@latest` again.
758
+
607
759
  | Setup | Update command (in the wherdr folder) |
608
760
  | --- | --- |
609
761
  | Docker, published image (default) | `docker compose pull && docker compose up -d` |
610
762
  | Docker, built from source | `git pull && docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` |
611
- | No Docker | `git pull && npm ci && npm run build`, then restart wherdr |
763
+ | From source | `git pull && npm ci && npm run build`, then restart wherdr |
612
764
  | `wherdr` command (npm) | `npm install -g wherdr@latest && wherdr restart` (npx: stop it, then `npx wherdr@latest`) |
765
+ | `wherdr` command (Homebrew) | `brew upgrade wherdr`, then `brew services restart wherdr` |
766
+ | The one command | Run `curl -fsSL https://wherdr.dev/install \| sh` again: it offers the update |
613
767
  | Herdr plugin, Docker | **U** in the **wherdr** panel (pulls the image, restarts the container) |
614
768
  | Herdr plugin, Node.js | **U** in the **wherdr** panel, or `herdr plugin install afloury/wherdr --yes` (wherdr restarts on the new version) |
615
769
 
@@ -632,16 +786,23 @@ docker run -d --name watchtower --restart unless-stopped \
632
786
 
633
787
  Pin a version instead of `latest` with `WHERDR_IMAGE=ghcr.io/afloury/wherdr:1.2.0` in `.env`.
634
788
 
635
- ## Install the app on your phone
789
+ ## Install it as an app
636
790
 
637
- The app must be served over HTTPS (see [Requirements](#requirements)).
791
+ The app must be served over HTTPS (see [Requirements](#requirements)). Installed, wherdr opens
792
+ in its own window, without the browser's bars.
638
793
 
639
794
  - **iPhone / iPad** (iOS 16.4 or later): open the address in **Safari** → Share →
640
795
  **Add to Home Screen**. Open wherdr from the home screen, then Settings → **Enable
641
796
  notifications**. On iOS, push notifications only work in the installed app.
642
797
  - **Android**: open the address in **Chrome** → menu → **Install app** (or Add to Home
643
798
  screen), then Settings → **Enable notifications**.
644
- - **Computer**: just use the address in a browser; Chrome and Edge can also install it.
799
+ - **Mac, Safari** (macOS 14 Sonoma or later): File → **Add to Dock**.
800
+ - **Chrome, Edge, Brave** (macOS, Windows, Linux): the install icon at the right of the address
801
+ bar, or menu → **Cast, save and share** → **Install page as app** (Edge: Apps → **Install
802
+ this site as an app**).
803
+ - **Arc** cannot install web apps
804
+ ([Arc Help Center](https://resources.arc.net/hc/en-us/articles/25678978728983-Does-Arc-for-Desktop-Support-Progressive-Web-Apps)):
805
+ open the address in Safari or Chrome for that, or keep wherdr as an Arc favorite.
645
806
 
646
807
  Then Settings → Security → **Enable passkey lock** on each device.
647
808
 
@@ -653,8 +814,9 @@ All settings are environment variables (`.env` with Docker).
653
814
  | --- | --- | --- |
654
815
  | `PORT` | `7683` | Listening port. |
655
816
  | `HOST` | `127.0.0.1` (`npm start`) | Listening address without Docker. Keep it on loopback. |
656
- | `HOST_LABEL` | server hostname (`wherdr` in Docker) | Name of this machine in the app (can be renamed in the app). |
657
- | `APP_URL` | *(empty)* | App address and allowed host; a valid HTTPS URL enables Web Push. HTTP, empty or invalid values disable Web Push with a warning. |
817
+ | `HOST_LABEL` | server hostname | Name of this machine in the app (can be renamed in the app). In Docker the installer writes the host's short name; left empty, wherdr shows the name Tailscale gives the machine, else `wherdr`. |
818
+ | `APP_URL` | *(empty)* | App address and allowed host; a valid HTTPS URL enables Web Push. HTTP, empty or invalid values disable Web Push with a warning. With Tailscale, leave it empty: wherdr adopts the address `tailscale serve` publishes. |
819
+ | `TAILNET_REFRESH_MS` | `30000` | How often wherdr reads again what `tailscale serve` publishes for its port. |
658
820
  | `HERDR_WEB_ALLOWED_HOSTS` | *(empty)* | Additional hostnames or IP addresses allowed to open the app, comma-separated. |
659
821
  | `PASSKEY_USER` | `wherdr` | User name stored with the passkeys. |
660
822
  | `DATA_DIR` | `./data` | VAPID keys, push subscriptions, passkeys, recent folders. |
@@ -764,7 +926,7 @@ worktree. It is optional; with it installed, wherdr adds:
764
926
  | **To test** | You confirm the work | Confirm, Problem, Question |
765
927
  | **To decide** | Only you can unblock it | Question, Answer |
766
928
  | **To review** | Pull requests for you | Reviewed, Comment |
767
- | **In progress** | What threads are really doing | (live threads) |
929
+ | **In progress** | What threads are really doing | (live threads) Add info |
768
930
  | **In queue** | Decided: the coordinator launches the first one as soon as a thread slot frees | Move up / down, Launch now, Clarify, Remove from queue |
769
931
  | **Blocked** | Waiting for someone or something external | Unblock, Clarify |
770
932
  | **To do** | To do soon, in your priority order; never launched on its own | Move up / down, Queue it, Launch now, Clarify, Back to backlog |
@@ -775,9 +937,11 @@ worktree. It is optional; with it installed, wherdr adds:
775
937
  project may only need Backlog, In progress and Done); any other `##` list shows in a neutral
776
938
  style after Backlog. French titles and common synonyms are recognized (À tester, En file,
777
939
  À faire, Plus tard…). Every button sends a ready-made message to the coordinator, which edits
778
- `TASKS.md` (wherdr never writes it). The In queue header shows the thread slots in use
779
- (`max_parallel_threads` in `PROJECT.md`) and the next task, plus the machine's global limit
780
- when one is set (`2 of 2 thread slots in use on <machine> (all projects)`).
940
+ `TASKS.md` (wherdr never writes it). **Add info**, on a working thread, opens a text field:
941
+ what you type goes to the coordinator, which passes it on to that thread as is. The In queue
942
+ header shows the thread slots in use (`max_parallel_threads` in `PROJECT.md`) and the next
943
+ task, plus the machine's global limit when one is set
944
+ (`2 of 2 thread slots in use on <machine> (all projects)`).
781
945
  - **Max threads per machine**, across all projects: herdr-projects only limits threads per
782
946
  project, so three coordinators could still start a dozen threads on one small machine. Set a
783
947
  limit per machine in **Settings → Plugins → herdr-projects** (empty `—` = no global limit;
@@ -850,7 +1014,10 @@ Actions declared by the plugins installed on a machine (`herdr plugin install|li
850
1014
  the menus: workspace / tab / pane actions in the agent menu, global actions in the machine menu.
851
1015
  wherdr always passes the agent's pane explicitly. It asks for confirmation before running an
852
1016
  action, except for actions that only show or check something (list, show, status, doctor…).
853
- Actions that open a plugin panel open it in the attached Herdr terminal client, not on the phone.
1017
+ Actions that open a plugin pane or popup open it in the attached Herdr terminal client, not on the
1018
+ phone: the menu marks them **Opens in Herdr**. wherdr's own plugin actions (its panel, open in the
1019
+ browser) are not listed. With a single machine, the machine's actions are in the **…** menu of
1020
+ its session row, under the agent list header.
854
1021
 
855
1022
  **herdr-projects** actions that ask for input (New project, continue this workspace as a
856
1023
  project, open, pause, resume) are run by wherdr itself as `herdr-projects` commands, because
@@ -885,7 +1052,9 @@ container talks to the Herdr server running on the host.
885
1052
  (port forwarding, public reverse proxy, ngrok…); the only exception is a Cloudflare Tunnel
886
1053
  behind Cloudflare Access, with its risks.
887
1054
  - Requests are accepted only for `localhost`, loopback addresses, the runtime's hostname, the
888
- hostname in `APP_URL`, and the runtime's network interface addresses (those of the container
1055
+ hostname in `APP_URL`, the tailnet name `tailscale serve` publishes for wherdr's own port
1056
+ (read from Tailscale on the machine, never from a request, and only while it is published),
1057
+ and the runtime's network interface addresses (those of the container
889
1058
  with Docker). Add other private names or IP addresses to `HERDR_WEB_ALLOWED_HOSTS`
890
1059
  (comma-separated) before using them. An unlisted name returns HTTP 403 (`host` / "Host not
891
1060
  allowed"); add it to `HERDR_WEB_ALLOWED_HOSTS`, then restart wherdr. This host check also
@@ -933,8 +1102,10 @@ See [SECURITY.md](SECURITY.md) to report a vulnerability.
933
1102
  (`herdr integration install claude|codex|omp`) and start a new agent.
934
1103
  - **Passkeys or notifications unavailable**: the page must be served over HTTPS (or
935
1104
  `localhost`). On iPhone, notifications need the app installed on the home screen.
936
- - **HTTP 403 when opening the app through another name**: add that private hostname or IP
937
- address to `HERDR_WEB_ALLOWED_HOSTS`, or use the hostname in `APP_URL`, then restart wherdr.
1105
+ - **HTTP 403 "This address isn't enabled yet"**: for a tailnet address, run the command the
1106
+ page gives on the machine: it tells whether `tailscale serve` publishes wherdr's port there.
1107
+ For another private name, add the hostname or IP address to `HERDR_WEB_ALLOWED_HOSTS`, or use
1108
+ the hostname in `APP_URL`, then restart wherdr.
938
1109
  - **Remote machine offline**: `docker compose logs | grep machine` shows its state. Test SSH as
939
1110
  wherdr does, from the container:
940
1111
  `docker compose exec herdr-web ssh -o BatchMode=yes laptop '~/.local/bin/herdr status server'`.
@@ -0,0 +1,86 @@
1
+ // Homebrew install (the project's tap): the formula's `brew services` entry runs
2
+ // `wherdr run` at login (launchd label sh.brew.wherdr, homebrew.mxcl.wherdr before
3
+ // Homebrew 5; systemd unit of the same name on Linux), with its log in
4
+ // <prefix>/var/log/wherdr.log. The wherdr commands describe and point to it
5
+ // instead of their own pid file and login service.
6
+ import { spawnSync } from 'node:child_process'
7
+ import { existsSync } from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { BIN, homebrewInstall } from './core.mjs'
11
+
12
+ export const BREW_LABELS = ['sh.brew.wherdr', 'homebrew.mxcl.wherdr']
13
+
14
+ // <prefix>/Cellar/wherdr/<version>/libexec/… → the prefix, its brew and the service log.
15
+ export function brewPaths(bin = BIN) {
16
+ if (!homebrewInstall(bin)) return null
17
+ const prefix = bin.slice(0, bin.search(/[\\/]Cellar[\\/]wherdr[\\/]/))
18
+ return { prefix, brew: path.join(prefix, 'bin', 'brew'), log: path.join(prefix, 'var', 'log', 'wherdr.log') }
19
+ }
20
+
21
+ // Output of `brew services info wherdr --json` → { loaded, running }; null if unreadable.
22
+ export function parseServiceInfo(text) {
23
+ try {
24
+ const info = [].concat(JSON.parse(text)).find(s => s?.name === 'wherdr')
25
+ return info ? { loaded: !!info.loaded, running: !!info.running } : null
26
+ } catch { return null }
27
+ }
28
+
29
+ // `launchctl print gui/<uid>/<label>` → running when it reports "state = running".
30
+ export function parseLaunchctl(text) {
31
+ return /^\s*state = running\s*$/m.test(text)
32
+ }
33
+
34
+ const run = (cmd, args, env) => spawnSync(cmd, args, { encoding: 'utf8', timeout: 20_000, env: { ...process.env, ...env } })
35
+
36
+ // Without a usable brew: the LaunchAgent written by `brew services start`, and launchd's state.
37
+ function launchdState(home) {
38
+ for (const label of BREW_LABELS) {
39
+ if (!existsSync(path.join(home, 'Library', 'LaunchAgents', `${label}.plist`))) continue
40
+ const r = run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`])
41
+ return { loaded: r.status === 0, running: r.status === 0 && parseLaunchctl(r.stdout) }
42
+ }
43
+ return { loaded: false, running: false }
44
+ }
45
+
46
+ // null when this wherdr does not come from Homebrew; otherwise { prefix, brew, log, loaded, running }.
47
+ export function brewService(bin = BIN, home = os.homedir()) {
48
+ const paths = brewPaths(bin)
49
+ if (!paths) return null
50
+ const r = run(paths.brew, ['services', 'info', 'wherdr', '--json'], { HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_ENV_HINTS: '1' })
51
+ const state = (r.status === 0 && parseServiceInfo(r.stdout))
52
+ || (process.platform === 'darwin' ? launchdState(home) : { loaded: false, running: false })
53
+ return { ...paths, ...state }
54
+ }
55
+
56
+ // ------------------------------------------------- what the commands say
57
+
58
+ // `wherdr start|stop|restart|service install` on a Homebrew install: the message
59
+ // pointing to brew services, or null to go on (start while the service runs).
60
+ export function brewRedirect(command, brew) {
61
+ if (!brew) return null
62
+ if (command === 'start') return brew.running ? null : 'this wherdr was installed with Homebrew: start it (now and at every login) with `brew services start wherdr`.'
63
+ if (command === 'stop') return brew.running ? 'wherdr runs as a Homebrew service (launchd / systemd restart it): stop it with `brew services stop wherdr`.' : null
64
+ if (command === 'restart') return 'this wherdr was installed with Homebrew: restart it with `brew services restart wherdr`.'
65
+ if (command === 'service') return 'this wherdr was installed with Homebrew: start it at login with `brew services start wherdr` (stop: `brew services stop wherdr`).'
66
+ return null
67
+ }
68
+
69
+ // Rows of `wherdr status` for a Homebrew install.
70
+ export function brewStatusRows(brew) {
71
+ return {
72
+ process: brew.running ? 'Homebrew service (brew services)' : null,
73
+ service: brew.running ? 'Homebrew (brew services) · running'
74
+ : brew.loaded ? 'Homebrew (brew services) · started but not running: wherdr logs'
75
+ : 'Homebrew (brew services) · not started: brew services start wherdr',
76
+ log: brew.log,
77
+ hint: '\n Start it: brew services start wherdr (now and at every login), then wherdr open.',
78
+ }
79
+ }
80
+
81
+ // `wherdr doctor` line for a Homebrew install: [level, text], level ok | warn | fail.
82
+ export function brewDoctor(brew) {
83
+ if (brew.running) return ['ok', `Login service: Homebrew (brew services), running · log ${brew.log}`]
84
+ if (brew.loaded) return ['fail', `Login service: Homebrew (brew services), started but not running. See wherdr logs (${brew.log}).`]
85
+ return ['warn', 'Login service: Homebrew (brew services), not started · brew services start wherdr runs wherdr now and at every login.']
86
+ }