wherdr 1.3.1 → 1.3.3

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 (180) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{AgentView.C4YOwvZG.css → AgentView.DzFWBd2A.css} +1 -1
  3. package/.output/public/_nuxt/B4Yk2tB1.js +23 -0
  4. package/.output/public/_nuxt/BBw_0a4M.js +6 -0
  5. package/.output/public/_nuxt/{B6qBWHWB.js → BSy6OCIY.js} +1 -1
  6. package/.output/public/_nuxt/BX5PM2JQ.js +1 -0
  7. package/.output/public/_nuxt/BgPDnf2S.js +1 -0
  8. package/.output/public/_nuxt/BkOxQzbS.js +2 -0
  9. package/.output/public/_nuxt/{WLQjK8uJ.js → CKhgznq4.js} +45 -46
  10. package/.output/public/_nuxt/{C2T9fQ4f.js → CP8o925U.js} +1 -1
  11. package/.output/public/_nuxt/CkbmecwS.js +1 -0
  12. package/.output/public/_nuxt/Cr3SrGky.js +1 -0
  13. package/.output/public/_nuxt/DAg9fiVh.js +89 -0
  14. package/.output/public/_nuxt/DT8qZ0Wv.js +1 -0
  15. package/.output/public/_nuxt/DaaJIRB3.js +1 -0
  16. package/.output/public/_nuxt/DcJi16XA.js +1 -0
  17. package/.output/public/_nuxt/VpjCgSXu.js +36 -0
  18. package/.output/public/_nuxt/builds/latest.json +1 -1
  19. package/.output/public/_nuxt/builds/meta/c735b6c0-1bd0-47d7-a208-d310f0a06287.json +1 -0
  20. package/.output/public/_nuxt/dPxB2L94.js +89 -0
  21. package/.output/public/_nuxt/{entry.cKR16VqG.css → entry.CGWKmYU3.css} +1 -1
  22. package/.output/public/_nuxt/hePW80VL.js +1 -0
  23. package/.output/public/_nuxt/xYX85SPI.js +1 -0
  24. package/.output/public/sw.js +1 -1
  25. package/.output/server/chunks/_/branding.mjs +1 -1
  26. package/.output/server/chunks/_/filePaths.mjs +25 -0
  27. package/.output/server/chunks/_/files.mjs +204 -0
  28. package/.output/server/chunks/_/machineOrder.mjs +1 -1
  29. package/.output/server/chunks/_/onboarding.mjs +1 -1
  30. package/.output/server/chunks/_/package.mjs +97 -0
  31. package/.output/server/chunks/_/pluginPolicy.mjs +93 -0
  32. package/.output/server/chunks/_/plugins.mjs +4 -94
  33. package/.output/server/chunks/_/projectBoard.mjs +120 -29
  34. package/.output/server/chunks/_/projectDocs.mjs +106 -0
  35. package/.output/server/chunks/_/projectInbox.mjs +135 -0
  36. package/.output/server/chunks/_/projectThreads.mjs +46 -0
  37. package/.output/server/chunks/_/projectThreads2.mjs +61 -0
  38. package/.output/server/chunks/_/projectsActions.mjs +1 -1
  39. package/.output/server/chunks/_/projectsPlugin.mjs +1 -1
  40. package/.output/server/chunks/_/restart.mjs +2 -312
  41. package/.output/server/chunks/_/reveal.mjs +2 -24
  42. package/.output/server/chunks/_/selfupdate.mjs +83 -0
  43. package/.output/server/chunks/_/updates.mjs +91 -0
  44. package/.output/server/chunks/_/worktrees.mjs +1 -1
  45. package/.output/server/chunks/_/ws.mjs +1 -1
  46. package/.output/server/chunks/nitro/nitro.mjs +2660 -1061
  47. package/.output/server/chunks/raw/bump-homebrew.mjs +1 -1
  48. package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
  49. package/.output/server/chunks/raw/release-ready.mjs +5 -0
  50. package/.output/server/chunks/routes/api/_..._.mjs +6 -2
  51. package/.output/server/chunks/routes/api/agents.post.mjs +6 -2
  52. package/.output/server/chunks/routes/api/ask-tab.post.mjs +6 -2
  53. package/.output/server/chunks/routes/api/attach.post.mjs +6 -2
  54. package/.output/server/chunks/routes/api/auth/disable.post.mjs +6 -2
  55. package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +6 -2
  56. package/.output/server/chunks/routes/api/auth/lock.post.mjs +6 -2
  57. package/.output/server/chunks/routes/api/auth/login/options.post.mjs +6 -2
  58. package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +6 -2
  59. package/.output/server/chunks/routes/api/auth/max-session.post.mjs +6 -2
  60. package/.output/server/chunks/routes/api/auth/register/options.post.mjs +6 -2
  61. package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +6 -2
  62. package/.output/server/chunks/routes/api/auth/status.get.mjs +6 -2
  63. package/.output/server/chunks/routes/api/board.get.mjs +147 -0
  64. package/.output/server/chunks/routes/api/changes.get.mjs +7 -3
  65. package/.output/server/chunks/routes/api/chat/image.get.mjs +7 -3
  66. package/.output/server/chunks/routes/api/chat.get.mjs +7 -3
  67. package/.output/server/chunks/routes/api/choose.post.mjs +34 -4
  68. package/.output/server/chunks/routes/api/claude-statusline.post.mjs +7 -3
  69. package/.output/server/chunks/routes/api/close/status.get.mjs +7 -3
  70. package/.output/server/chunks/routes/api/close.post.mjs +6 -2
  71. package/.output/server/chunks/routes/api/codex-update.post.mjs +7 -3
  72. package/.output/server/chunks/routes/api/commands.get.mjs +7 -3
  73. package/.output/server/chunks/routes/api/config.get.mjs +8 -3
  74. package/.output/server/chunks/routes/api/dirs.get.mjs +7 -3
  75. package/.output/server/chunks/routes/api/dismiss-stopped.post.mjs +43 -0
  76. package/.output/server/chunks/routes/api/dismiss.post.mjs +7 -3
  77. package/.output/server/chunks/routes/api/effort.post.mjs +7 -3
  78. package/.output/server/chunks/routes/api/efforts.get.mjs +7 -3
  79. package/.output/server/chunks/routes/api/file.post.mjs +7 -3
  80. package/.output/server/chunks/routes/api/files/download.get.mjs +53 -0
  81. package/.output/server/chunks/routes/api/files/read.get.mjs +45 -0
  82. package/.output/server/chunks/routes/api/files.get.mjs +44 -0
  83. package/.output/server/chunks/routes/api/gitroot.get.mjs +7 -3
  84. package/.output/server/chunks/routes/api/health.get.mjs +36 -0
  85. package/.output/server/chunks/routes/api/index.get.mjs +6 -2
  86. package/.output/server/chunks/routes/api/input.post.mjs +18 -4
  87. package/.output/server/chunks/routes/api/interrupt.post.mjs +7 -3
  88. package/.output/server/chunks/routes/api/isgit.get.mjs +7 -3
  89. package/.output/server/chunks/routes/api/machine/awake.get.mjs +7 -3
  90. package/.output/server/chunks/routes/api/machine/awake.post.mjs +7 -3
  91. package/.output/server/chunks/routes/api/machine/order.get.mjs +6 -2
  92. package/.output/server/chunks/routes/api/machine/order.post.mjs +6 -2
  93. package/.output/server/chunks/routes/api/machine/rename.post.mjs +7 -3
  94. package/.output/server/chunks/routes/api/menu.post.mjs +7 -3
  95. package/.output/server/chunks/routes/api/model.post.mjs +7 -3
  96. package/.output/server/chunks/routes/api/models.get.mjs +7 -3
  97. package/.output/server/chunks/routes/api/nav.post.mjs +10 -5
  98. package/.output/server/chunks/routes/api/onboarding.get.mjs +6 -2
  99. package/.output/server/chunks/routes/api/onboarding.post.mjs +6 -2
  100. package/.output/server/chunks/routes/api/phone.get.mjs +6 -7
  101. package/.output/server/chunks/routes/api/phone.post.mjs +6 -7
  102. package/.output/server/chunks/routes/api/plugins/actions.get.mjs +7 -2
  103. package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +7 -2
  104. package/.output/server/chunks/routes/api/plugins/profiles.get.mjs +7 -2
  105. package/.output/server/chunks/routes/api/plugins/projects.get.mjs +7 -3
  106. package/.output/server/chunks/routes/api/plugins/projects.post.mjs +7 -3
  107. package/.output/server/chunks/routes/api/plugins/thread-limits.get.mjs +7 -3
  108. package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +7 -3
  109. package/.output/server/chunks/routes/api/project/inbox.get.mjs +43 -0
  110. package/.output/server/chunks/routes/api/project/inbox.post.mjs +43 -0
  111. package/.output/server/chunks/routes/api/project/memory.get.mjs +42 -0
  112. package/.output/server/chunks/routes/api/project/report.get.mjs +7 -3
  113. package/.output/server/chunks/routes/api/project/routines.get.mjs +43 -0
  114. package/.output/server/chunks/routes/api/project/settings.get.mjs +41 -0
  115. package/.output/server/chunks/routes/api/project/thread.get.mjs +44 -0
  116. package/.output/server/chunks/routes/api/project/thread.post.mjs +45 -0
  117. package/.output/server/chunks/routes/api/prompt.post.mjs +26 -4
  118. package/.output/server/chunks/routes/api/push/quiet.post.mjs +7 -3
  119. package/.output/server/chunks/routes/api/push/subscribe.post.mjs +7 -3
  120. package/.output/server/chunks/routes/api/push/test.post.mjs +7 -3
  121. package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +7 -3
  122. package/.output/server/chunks/routes/api/quotas.get.mjs +7 -3
  123. package/.output/server/chunks/routes/api/rename.post.mjs +6 -2
  124. package/.output/server/chunks/routes/api/requeue.post.mjs +7 -3
  125. package/.output/server/chunks/routes/api/restart.get.mjs +6 -2
  126. package/.output/server/chunks/routes/api/restart.post.mjs +6 -2
  127. package/.output/server/chunks/routes/api/reveal.post.mjs +7 -2
  128. package/.output/server/chunks/routes/api/screen-key.post.mjs +7 -3
  129. package/.output/server/chunks/routes/api/screen.get.mjs +10 -4
  130. package/.output/server/chunks/routes/api/search.get.mjs +7 -3
  131. package/.output/server/chunks/routes/api/seen.post.mjs +7 -3
  132. package/.output/server/chunks/routes/api/sessions.get.mjs +7 -3
  133. package/.output/server/chunks/routes/api/sessions.post.mjs +7 -3
  134. package/.output/server/chunks/routes/api/space.post.mjs +19 -15
  135. package/.output/server/chunks/routes/api/state.get.mjs +7 -3
  136. package/.output/server/chunks/routes/api/unqueue.post.mjs +7 -3
  137. package/.output/server/chunks/routes/api/update/status.get.mjs +38 -0
  138. package/.output/server/chunks/routes/api/update.get.mjs +12 -51
  139. package/.output/server/chunks/routes/api/update.post.mjs +41 -0
  140. package/.output/server/chunks/routes/api/upload.post.mjs +7 -3
  141. package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +6 -2
  142. package/.output/server/chunks/routes/api/worktrees.get.mjs +6 -2
  143. package/.output/server/chunks/routes/favicon.ico.get.mjs +6 -2
  144. package/.output/server/chunks/routes/icons/_name_.get.mjs +7 -3
  145. package/.output/server/chunks/routes/renderer.mjs +8 -4
  146. package/.output/server/chunks/routes/uploads/_...name_.get.mjs +7 -3
  147. package/.output/server/chunks/routes/ws/_..._.mjs +7 -3
  148. package/.output/server/chunks/routes/ws/events.mjs +7 -3
  149. package/.output/server/chunks/routes/ws/term.mjs +9 -5
  150. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  151. package/.output/server/index.mjs +7 -3
  152. package/.output/server/package.json +1 -1
  153. package/README.md +440 -46
  154. package/bin/install-docker-skill.sh +20 -0
  155. package/bin/lib/commands.mjs +47 -9
  156. package/bin/lib/core.mjs +18 -2
  157. package/bin/lib/install-skill.sh +100 -0
  158. package/bin/lib/skill.mjs +16 -0
  159. package/bin/lib/tailnet.d.mts +12 -3
  160. package/bin/lib/tailnet.mjs +103 -25
  161. package/bin/lib/updater.mjs +320 -0
  162. package/bin/lib/verify-release.mjs +20 -0
  163. package/package.json +4 -2
  164. package/skills/wherdr/SKILL.md +180 -0
  165. package/.output/public/_nuxt/BDNMzG2s.js +0 -1
  166. package/.output/public/_nuxt/BMIe8p_3.js +0 -2
  167. package/.output/public/_nuxt/BpQH8Ho1.js +0 -1
  168. package/.output/public/_nuxt/BrAeJIZR.js +0 -30
  169. package/.output/public/_nuxt/C12eXl5z.js +0 -1
  170. package/.output/public/_nuxt/CXG-cOdh.js +0 -1
  171. package/.output/public/_nuxt/CYxHnMNH.js +0 -83
  172. package/.output/public/_nuxt/CctS2Xn6.js +0 -65
  173. package/.output/public/_nuxt/CiYar6hg.js +0 -6
  174. package/.output/public/_nuxt/DLLQgP0d.js +0 -1
  175. package/.output/public/_nuxt/Dwh-l2rQ.js +0 -17
  176. package/.output/public/_nuxt/bLP7Jj-b.js +0 -1
  177. package/.output/public/_nuxt/builds/meta/6466b0ab-7935-460c-abdf-2ea632dae6d0.json +0 -1
  178. package/.output/public/_nuxt/whFLy_Ne.js +0 -23
  179. package/.output/server/chunks/_/phone.mjs +0 -343
  180. package/.output/server/chunks/routes/ws/mirror.mjs +0 -157
@@ -0,0 +1,20 @@
1
+ #!/bin/sh
2
+ # Run on the HOST after pulling an image; no container is started, no home mounted.
3
+ # Usage: sh bin/install-docker-skill.sh <image> [refresh|install]
4
+ extract_skill() (
5
+ set -eu
6
+ [ -n "${1:-}" ] || { echo 'usage: install-docker-skill.sh <image> [refresh|install]' >&2; exit 1; }
7
+ [ "${WHERDR_SKILL:-}" != off ] || { echo 'wherdr skill: disabled by WHERDR_SKILL=off'; exit 0; }
8
+ if [ "${2:-install}" != install ] && [ -e "${HOME:-}/.local/state/wherdr/skill-disabled" ]; then exit 0; fi
9
+ helper=$(CDPATH= cd -- "$(dirname "$0")" && pwd -P)/lib/install-skill.sh
10
+ tmp=$(mktemp -d)
11
+ container=
12
+ trap '[ -z "$container" ] || docker rm "$container" >/dev/null 2>&1; rm -rf "$tmp"' EXIT HUP INT TERM
13
+ container=$(docker create -- "$1")
14
+ mkdir -p "$tmp/skills/wherdr"
15
+ # Only data crosses the image boundary. The executed helper is local.
16
+ docker cp "$container:/app/skills/wherdr/SKILL.md" "$tmp/skills/wherdr/SKILL.md"
17
+ sh "$helper" "${2:-install}" "$tmp"
18
+ )
19
+ # Unlike startup, manual and plugin callers see failures and useful diagnostics.
20
+ extract_skill "$@"
@@ -11,7 +11,8 @@ import { BIN, CliError, SERVER, VERSION, bad, c, homebrewInstall, localUrl, ok,
11
11
  import { RUNTIME_HELP, findRuntime, isBun, runtimeOk, runtimeVersion, temporaryInstall } from './runtime.mjs'
12
12
  import { brewDoctor, brewRedirect, brewService, brewStatusRows } from './brew.mjs'
13
13
  import { controlService, installService, serviceFile, servicePlatform, serviceState, uninstallService } from './service.mjs'
14
- import { LINKS, inspect, reachable } from './tailnet.mjs'
14
+ import { refreshSkill } from './skill.mjs'
15
+ import { LINKS, inspect, publishCommand, reachable } from './tailnet.mjs'
15
16
 
16
17
  export const HELP = `${'wherdr'}: your Herdr agents from your phone and browser.
17
18
 
@@ -115,6 +116,7 @@ function needServer() {
115
116
 
116
117
  // ------------------------------------------------------------------- run
117
118
  export async function run(opts) {
119
+ refreshSkill(context(opts).env)
118
120
  needServer()
119
121
  const ctx = context(opts)
120
122
  if (!process.versions.bun && Number(process.versions.node.split('.')[0]) < 22) throw new CliError(`Node.js 22 or newer is required (found ${process.version}).`)
@@ -138,6 +140,7 @@ export async function run(opts) {
138
140
 
139
141
  // ----------------------------------------------------------------- start
140
142
  export async function start(opts) {
143
+ refreshSkill(context(opts).env)
141
144
  needServer()
142
145
  const ctx = context(opts)
143
146
  const brew = brewService()
@@ -289,16 +292,39 @@ export function openUrl(url) {
289
292
  return r.status === 0
290
293
  }
291
294
 
295
+ // Does the published address answer? 'host' means wherdr answers but does not
296
+ // know that name yet (`tailscale serve` run a moment ago): a local request to
297
+ // /api/phone makes it read Tailscale again and adopt the address
298
+ // (server/utils/phone.ts), then it is asked again. `reach` / `allow` are
299
+ // injectable for the tests.
300
+ export async function phoneReach(localUrl, phoneUrl, { reach = reachable, allow = url => fetch(`${url}/api/phone`).catch(() => {}), timeoutMs = 8000 } = {}) {
301
+ let r = await reach(phoneUrl, timeoutMs)
302
+ if (r === 'host') { await allow(localUrl); r = await reach(phoneUrl, timeoutMs) }
303
+ return r
304
+ }
305
+
306
+ // The address to open: the tailnet one when wherdr is already published there
307
+ // and it answers (passkeys are tied to the address they were created on),
308
+ // else the local one.
309
+ export async function openAddress(localUrl, port, { net = inspect, ...probes } = {}) {
310
+ const phone = (await net(port)).phone
311
+ if (!phone?.served) return localUrl
312
+ return await phoneReach(localUrl, phone.url, { timeoutMs: 5000, ...probes }) === 'ok' ? phone.url.replace(/\/$/, '') : localUrl
313
+ }
314
+
292
315
  export async function open(opts) {
293
316
  const ctx = context(opts)
294
317
  if (await probe(ctx.port, ctx.host) !== 'wherdr') warn(`wherdr does not answer on port ${ctx.port}: run \`wherdr start\` first.`)
295
- if (openUrl(ctx.url)) ok(`opened ${c.cyan(ctx.url)}`)
296
- else say(ctx.url)
318
+ const url = await openAddress(ctx.url, ctx.port)
319
+ if (openUrl(url)) ok(`opened ${c.cyan(url)}`)
320
+ else say(url)
297
321
  }
298
322
 
299
323
  // ----------------------------------------------------------------- phone
300
324
  // The phone setup lives in the app (Settings › Phone: publish on the tailnet,
301
- // live check, APP_URL, QR code). Here: the real state, the link to that page,
325
+ // live check, APP_URL, QR code). Here: the real state, the command that
326
+ // publishes wherdr when it is not (a server without a screen has no browser
327
+ // for that page; wherdr adopts the address by itself), the link to the page,
302
328
  // and the QR code only when the address answers.
303
329
  export const phoneSetupUrl = url => `${url}/#/settings?section=phone`
304
330
 
@@ -311,6 +337,13 @@ async function qr(text) {
311
337
  } catch { return null }
312
338
  }
313
339
 
340
+ // Why a published address is not usable yet, for `wherdr phone`.
341
+ export function phoneRefusal(reach, phone, running) {
342
+ if (reach !== 'host') return running ? 'does not answer yet (the HTTPS certificate can take a minute)' : 'wherdr is not running'
343
+ if (phone.funnel) return 'also open to the Internet by `tailscale funnel`, which wherdr never enables: turn the funnel off'
344
+ return 'wherdr refuses it: it could not read `tailscale serve status` itself (is `tailscale` on the PATH of the wherdr server?)'
345
+ }
346
+
314
347
  export async function phone(opts) {
315
348
  const ctx = context(opts)
316
349
  const setup = phoneSetupUrl(ctx.url)
@@ -323,17 +356,21 @@ export async function phone(opts) {
323
356
  let reach = null
324
357
  if (!net.installed) warn(`Tailscale is not installed: ${c.cyan(LINKS.download)}`)
325
358
  else if (!net.connected || !net.phone) warn('Tailscale is not connected on this machine.')
326
- else if (!net.phone.served) warn(`${c.bold('Not reachable from your phone yet')}: wherdr is not published on your tailnet.`)
327
- else {
328
- reach = await reachable(net.phone.url)
359
+ else if (!net.phone.served) {
360
+ warn(`${c.bold('Not reachable from your phone yet')}: wherdr is not published on your tailnet.`)
361
+ say(` Publish it (private, only your Tailscale devices): ${c.cyan(publishCommand(ctx.port))}`)
362
+ say(' wherdr enables that address by itself; run `wherdr phone` again for its QR code.')
363
+ } else {
364
+ reach = await phoneReach(ctx.url, net.phone.url)
329
365
  if (reach === 'ok') ok(`${c.cyan(net.phone.url)} answers`)
330
- else warn(`${c.bold('Not reachable from your phone yet')}: ${net.phone.url} is published but ${reach === 'host' ? 'refused (APP_URL)' : 'does not answer'}.`)
366
+ else warn(`${c.bold('Not reachable from your phone yet')}: ${net.phone.url} is published but ${phoneRefusal(reach, net.phone, local === 'wherdr')}.`)
331
367
  }
332
368
  if (reach === 'ok') {
333
369
  const code = await qr(net.phone.url)
334
370
  if (code) { say(); say(code.split('\n').map(l => ` ${l}`).join('\n')) }
335
371
  say(` ${code ? 'Scan it with the iPhone camera' : `Open ${c.cyan(net.phone.url)} on the iPhone`}, then Share → Add to Home Screen.`)
336
372
  say(' In the app: Settings → Enable notifications, then Security → Enable passkey lock.')
373
+ say(` Enable the lock on ${c.cyan(net.phone.url)}, not on localhost: a passkey is tied to its address.`)
337
374
  }
338
375
  say()
339
376
  say(` Set up your phone in the app, on this computer: ${c.cyan(setup)}`)
@@ -341,6 +378,7 @@ export async function phone(opts) {
341
378
 
342
379
  // --------------------------------------------------------------- service
343
380
  export async function service(opts) {
381
+ if (opts.sub === 'install') refreshSkill(context(opts).env)
344
382
  const ctx = context(opts)
345
383
  if (opts.sub === 'uninstall') {
346
384
  const file = uninstallService()
@@ -372,7 +410,7 @@ export async function service(opts) {
372
410
  if (ctx.env.HERDR_WEB_SESSION) args.push('--session', ctx.env.HERDR_WEB_SESSION)
373
411
  const PATH = [...new Set([path.dirname(runtime), path.join(os.homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'])].join(':')
374
412
  const env = { PATH, WHERDR_DIR: ctx.dir, WHERDR_RUNTIME: runtime }
375
- for (const k of ['APP_URL', 'HERDR_BIN', 'HOST_LABEL']) if (process.env[k]) env[k] = process.env[k]
413
+ for (const k of ['APP_URL', 'HERDR_BIN', 'HOST_LABEL', 'CLAUDE_CONFIG_DIR', 'WHERDR_SKILL']) if (ctx.env[k]) env[k] = ctx.env[k]
376
414
  const file = installService({ runtime, args, env, log: ctx.files.log })
377
415
  ok(`login service installed: ${file}`)
378
416
  say(c.dim(` ${runtime} ${args.join(' ')}`))
package/bin/lib/core.mjs CHANGED
@@ -17,6 +17,18 @@ export function homebrewInstall(file) {
17
17
  return /[\\/]Cellar[\\/]wherdr[\\/]/.test(file)
18
18
  }
19
19
 
20
+ // How this copy of wherdr was installed (shared/updates.ts InstallMode), from
21
+ // the path of its bin/wherdr.mjs: the Homebrew Cellar; the copy the Herdr
22
+ // plugin keeps in <wherdr dir>/app; `npm i -g` (<prefix>/lib/node_modules/wherdr,
23
+ // never a temporary npx folder); otherwise a temporary package (npx, bunx, pnpm dlx).
24
+ export function installKind(bin, dir) {
25
+ if (homebrewInstall(bin)) return 'brew'
26
+ const root = path.resolve(bin, '..', '..')
27
+ if (root === path.join(dir, 'app')) return 'plugin'
28
+ if (/[\\/]lib[\\/]node_modules[\\/]wherdr$/.test(root) && !/[\\/]_npx[\\/]/.test(root)) return 'npm-global'
29
+ return 'npm'
30
+ }
31
+
20
32
  export const COMMANDS = ['run', 'start', 'stop', 'restart', 'status', 'logs', 'open', 'phone', 'panel', 'service', 'doctor', 'help', 'version']
21
33
  const VALUE_OPTIONS = { '--port': 'port', '--host': 'host', '--data-dir': 'dataDir', '--session': 'session', '--lines': 'lines', '-n': 'lines' }
22
34
  const FLAGS = { '-h': 'help', '--help': 'help', '-v': 'version', '--version': 'version', '-f': 'follow', '--follow': 'follow' }
@@ -98,8 +110,12 @@ export function serverEnv(opts, env, home = os.homedir(), bin = BIN) {
98
110
  out.NODE_ENV = 'production'
99
111
  // Open WebSockets do not hold Ctrl+C for 30 s; clients reconnect on their own.
100
112
  out.NITRO_SHUTDOWN_TIMEOUT ??= '1500'
101
- // Update command offered in the app (shared/updates.ts).
102
- out.WHERDR_INSTALL = homebrewInstall(bin) ? 'brew' : 'npm'
113
+ // Update command, or one-tap update, offered in the app (shared/updates.ts):
114
+ // the package to replace and the folder of its state and log (server/utils/selfupdate.ts).
115
+ out.WHERDR_INSTALL = installKind(bin, dir)
116
+ out.WHERDR_ROOT = path.resolve(bin, '..', '..')
117
+ out.WHERDR_DIR = dir
118
+ if (out.CLAUDE_CONFIG_DIR) out.CLAUDE_CONFIG_DIR = path.resolve(home, expandHome(out.CLAUDE_CONFIG_DIR, home))
103
119
  // Nitro reads NITRO_PORT / NITRO_HOST before PORT / HOST.
104
120
  delete out.NITRO_PORT
105
121
  delete out.NITRO_HOST
@@ -0,0 +1,100 @@
1
+ #!/bin/sh
2
+ # Automatic startup is silent and best effort; explicit operations report errors.
3
+ # Usage: sh bin/lib/install-skill.sh [auto|refresh|install|uninstall] [package root]
4
+ install_skill() (
5
+ set -eu
6
+ [ -n "${HOME:-}" ] && [ -d "$HOME" ] || exit 0
7
+ root=${2:-$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd -P)}
8
+ mode=${1:-auto}
9
+ base=${WHERDR_DIR:-$HOME/wherdr}
10
+ case "$base" in /*) ;; *) base="$PWD/$base" ;; esac
11
+ dest="$base/skills/wherdr"
12
+ state="$HOME/.local/state/wherdr"
13
+ disabled="$state/skill-disabled"
14
+ claude=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
15
+ case "$claude" in /*) ;; *) claude="$HOME/$claude" ;; esac
16
+ dirs() {
17
+ [ ! -d "$claude" ] || printf '%s\n' "$claude/skills"
18
+ if [ -d "$HOME/.agents" ] || [ -d "$HOME/.codex" ] || [ -d "$HOME/.omp" ]; then
19
+ printf '%s\n' "$HOME/.agents/skills"
20
+ fi
21
+ }
22
+ # Opt-out applies even to an explicit install; uninstall is always available.
23
+ if [ "$mode" != uninstall ]; then
24
+ [ "${WHERDR_SKILL:-}" != off ] || { echo 'wherdr skill: disabled by WHERDR_SKILL=off'; exit 0; }
25
+ [ "$mode" = install ] || [ ! -e "$disabled" ] || exit 0
26
+ [ -n "$(dirs)" ] || { echo 'wherdr skill: no installed agent found'; exit 0; }
27
+ fi
28
+ if [ "$mode" = uninstall ]; then
29
+ mkdir -p "$state"
30
+ [ ! -L "$disabled" ] || { echo 'wherdr skill: opt-out marker is a symlink' >&2; exit 1; }
31
+ [ -f "$disabled" ] || : > "$disabled"
32
+ fi
33
+ # Never follow a foreign symlink at the managed copy or its metadata.
34
+ [ ! -L "$dest" ] && [ ! -L "$dest/SKILL.md" ] && [ ! -L "$dest/.wherdr-managed" ] || {
35
+ echo 'wherdr skill: foreign symlink left alone' >&2; exit 0;
36
+ }
37
+ owned=false
38
+ [ ! -f "$dest/.wherdr-managed" ] || owned=true
39
+ if [ -e "$dest" ] && [ "$owned" = false ]; then
40
+ # Adopt an older plugin copy only when an agent links to this exact path.
41
+ legacy=$(dirs | while IFS= read -r d; do
42
+ if [ -L "$d/wherdr" ] && [ "$(readlink "$d/wherdr")" = "$dest" ]; then echo 1; fi
43
+ done)
44
+ [ -z "$legacy" ] || owned=true
45
+ [ "$owned" = true ] || { echo 'wherdr skill: existing foreign copy left alone'; exit 0; }
46
+ fi
47
+ case "$mode" in
48
+ auto|refresh|install)
49
+ [ -f "$root/skills/wherdr/SKILL.md" ] || { echo 'wherdr skill: bundled SKILL.md is missing' >&2; exit 1; }
50
+ mkdir -p "$dest"
51
+ if ! cmp -s "$root/skills/wherdr/SKILL.md" "$dest/SKILL.md"; then
52
+ tmp=$(mktemp "$dest/.SKILL.XXXXXX")
53
+ trap 'rm -f "$tmp"' EXIT HUP INT TERM
54
+ cp "$root/skills/wherdr/SKILL.md" "$tmp"
55
+ chmod 644 "$tmp"
56
+ mv -f "$tmp" "$dest/SKILL.md"
57
+ fi
58
+ [ -f "$dest/.wherdr-managed" ] || : > "$dest/.wherdr-managed"
59
+ dirs | while IFS= read -r d; do
60
+ link="$d/wherdr"
61
+ record="$d/.wherdr-managed-target"
62
+ # The record survives removal of an old WHERDR_DIR. A path's shape
63
+ # alone never proves ownership of a dangling link.
64
+ if [ -L "$link" ] && [ ! -e "$link" ] && [ -f "$record" ] && [ ! -L "$record" ] \
65
+ && [ "$(cat "$record")" = "$(readlink "$link")" ]; then
66
+ rm "$link"
67
+ fi
68
+ if [ ! -e "$link" ] && [ ! -L "$link" ]; then
69
+ mkdir -p "$d"
70
+ ln -s "$dest" "$link"
71
+ fi
72
+ if [ -L "$link" ] && [ "$(readlink "$link")" = "$dest" ] && [ ! -L "$record" ]; then
73
+ if [ ! -f "$record" ] || [ "$(cat "$record")" != "$dest" ]; then printf '%s\n' "$dest" > "$record"; fi
74
+ fi
75
+ done
76
+ [ "$mode" != install ] || rm -f "$disabled"
77
+ echo 'wherdr skill: installed or already up to date'
78
+ ;;
79
+ uninstall)
80
+ dirs | while IFS= read -r d; do
81
+ if [ -L "$d/wherdr" ] && [ "$(readlink "$d/wherdr")" = "$dest" ]; then
82
+ rm "$d/wherdr"
83
+ [ -L "$d/.wherdr-managed-target" ] || rm -f "$d/.wherdr-managed-target"
84
+ fi
85
+ done
86
+ if [ "$owned" = true ]; then
87
+ rm -f "$dest/SKILL.md" "$dest/.wherdr-managed"
88
+ rmdir "$dest" "$(dirname "$dest")" 2>/dev/null || true
89
+ fi
90
+ echo 'wherdr skill: uninstalled; automatic installation disabled'
91
+ ;;
92
+ *) echo 'usage: install-skill.sh [auto|refresh|install|uninstall] [package root]' >&2; exit 1 ;;
93
+ esac
94
+ )
95
+ if [ "${1:-auto}" = auto ]; then
96
+ ( install_skill "$@" ) >/dev/null 2>&1
97
+ exit 0
98
+ else
99
+ install_skill "$@"
100
+ fi
@@ -0,0 +1,16 @@
1
+ // The shell helper is also used without Node by the Docker / Herdr installer.
2
+ import { spawnSync } from 'node:child_process'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export function refreshSkill(env = process.env) {
6
+ // One-shot handoff from the detached updater; never pass it to the server.
7
+ const refreshed = env.WHERDR_SKILL_REFRESHED === '1'
8
+ delete env.WHERDR_SKILL_REFRESHED
9
+ delete process.env.WHERDR_SKILL_REFRESHED
10
+ if (refreshed || env.WHERDR_SKILL === 'off') return
11
+ try {
12
+ spawnSync('sh', [fileURLToPath(new URL('./install-skill.sh', import.meta.url))], {
13
+ env, stdio: 'ignore', timeout: 5000,
14
+ })
15
+ } catch {} // Optional: a read-only home or missing shell must not prevent startup.
16
+ }
@@ -1,13 +1,18 @@
1
1
  // Types of tailnet.mjs, for the server (server/utils/phone.ts).
2
- export interface PhoneAddress { url: string, served: boolean, httpsPort: string }
2
+ // funnel: also published on the Internet by `tailscale funnel` (never adopted).
3
+ export interface PhoneAddress { url: string, served: boolean, httpsPort: string, funnel?: boolean }
3
4
  export interface Tailnet {
4
5
  installed: boolean
5
- bin?: string
6
+ bin?: string | null
7
+ // Tailscale's socket, when the state was read there (no CLI: Docker).
8
+ socket?: string | null
6
9
  name?: string | null
7
10
  connected?: boolean
8
11
  https?: boolean
9
12
  phone?: PhoneAddress | null
10
13
  taken?: boolean
14
+ // Addresses published for wherdr's port; null when they could not be read.
15
+ served?: string[] | null
11
16
  }
12
17
  export type Reach = 'ok' | 'host' | 'other' | 'unreachable'
13
18
  export type ReachCause = 'dns' | 'refused' | 'timeout' | 'cert' | 'tls' | 'network'
@@ -15,18 +20,22 @@ export interface Probe { reach: Reach, cause?: ReachCause, status?: number }
15
20
  export type ServeErrorCode = 'operator' | 'https' | 'offline'
16
21
 
17
22
  export const LINKS: { download: string, https: string, operator: string }
23
+ export const TAILSCALED_SOCKET: string
24
+ export function tailscaledSocket(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): string | null
18
25
  export function tailscaleBin(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): string | null
19
26
  export function tailnetStatus(statusJson: string): { name: string | null, connected: boolean, https: boolean }
20
27
  export function phoneAddress(name: string | null, port: string | number, serveJson: string): PhoneAddress | null
28
+ export function servedAddresses(name: string | null, port: string | number, serveJson: string): string[] | null
21
29
  export function servedPorts(serveJson: string): string[]
22
30
  export function publishCommand(port: string | number, httpsPort?: string | number): string
23
31
  export function publishArgs(port: string | number): string[]
24
32
  export function unpublishArgs(httpsPort: string): string[]
25
33
  export function tailnetUrl(text: unknown): string | null
26
34
  export function serveError(output: string): { code: ServeErrorCode, link: string } | null
27
- export function inspect(port: string | number, bin?: string | null): Promise<Tailnet>
35
+ export function inspect(port: string | number, bin?: string | null, socket?: string | null): Promise<Tailnet>
28
36
  export function tailnetDomain(resolvConf: string): string | null
29
37
  export function reachable(url: string, timeoutMs?: number): Promise<Reach>
38
+ export function healthReach(status: number, text: string): 'ok' | 'host' | null
30
39
  export function probe(url: string, timeoutMs?: number): Promise<Probe>
31
40
  export function failureCause(err: unknown): ReachCause
32
41
  export function runTailscale(bin: string, args: string[], timeoutMs?: number): Promise<{ code: number, output: string }>
@@ -7,6 +7,7 @@
7
7
  import { execFile } from 'node:child_process'
8
8
  import dns from 'node:dns'
9
9
  import { existsSync } from 'node:fs'
10
+ import http from 'node:http'
10
11
  import https from 'node:https'
11
12
  import path from 'node:path'
12
13
  import { promisify } from 'node:util'
@@ -26,6 +27,32 @@ export function tailscaleBin(env = process.env, exists = existsSync) {
26
27
  return dirs.find(p => exists(p)) || null
27
28
  }
28
29
 
30
+ // Tailscale's own socket on Linux. Its local API answers the same JSON as the
31
+ // CLI: a Docker container has no `tailscale` command, but docker-compose.yml
32
+ // mounts this folder, so wherdr reads the state there. Anyone may read; the
33
+ // API also accepts changes from Tailscale's operator, but wherdr only ever
34
+ // sends GET requests to it (localApi below).
35
+ export const TAILSCALED_SOCKET = '/var/run/tailscale/tailscaled.sock'
36
+ export function tailscaledSocket(env = process.env, exists = existsSync) {
37
+ const file = env.WHERDR_TAILSCALED_SOCKET || TAILSCALED_SOCKET
38
+ return exists(file) ? file : null
39
+ }
40
+
41
+ // GET on Tailscale's local API → the body, '' on any failure.
42
+ function localApi(socketPath, route, timeoutMs = 4000) {
43
+ const { promise, resolve } = Promise.withResolvers()
44
+ const req = http.get({ socketPath, path: `/localapi/v0/${route}`, headers: { host: 'local-tailscaled.sock' }, timeout: timeoutMs }, (res) => {
45
+ let text = ''
46
+ res.setEncoding('utf8')
47
+ res.on('data', (c) => { if (text.length < 1 << 20) text += c })
48
+ res.on('end', () => resolve(res.statusCode === 200 ? text : ''))
49
+ res.on('error', () => resolve(''))
50
+ })
51
+ req.on('timeout', () => req.destroy())
52
+ req.on('error', () => resolve(''))
53
+ return promise
54
+ }
55
+
29
56
  // `tailscale status --json` → { name, connected, https }: this machine's
30
57
  // tailnet name (machine.tailnet.ts.net) or null, whether Tailscale is up,
31
58
  // and whether the tailnet issues HTTPS certificates.
@@ -40,22 +67,51 @@ export function tailnetStatus(statusJson) {
40
67
  }
41
68
  }
42
69
 
70
+ // The HTTPS entries of a serve configuration that proxy to wherdr's local
71
+ // port on this machine's own name: [{ hostPort, httpsPort, funnel }], or null
72
+ // when the configuration could not be read (Tailscale stopped, not JSON).
73
+ // Background entries (`--bg`) and those of a `tailscale serve` still running
74
+ // in a terminal (Foreground) both count. `funnel`: also open to the Internet.
75
+ function wherdrEntries(name, port, serveJson) {
76
+ let config = null
77
+ try { config = JSON.parse(serveJson) } catch {}
78
+ if (!name || !config || typeof config !== 'object' || Array.isArray(config)) return null
79
+ const target = new RegExp(`^(https?://)?(127\\.0\\.0\\.1|localhost):${port}/?$`)
80
+ const out = []
81
+ for (const part of [config, ...Object.values(config.Foreground || {})]) {
82
+ for (const [hostPort, entry] of Object.entries(part?.Web || {})) {
83
+ const colon = hostPort.lastIndexOf(':')
84
+ if (hostPort.slice(0, colon).toLowerCase() !== name.toLowerCase()) continue
85
+ if (!Object.values(entry?.Handlers || {}).some(h => target.test(h?.Proxy || ''))) continue
86
+ out.push({ hostPort, httpsPort: hostPort.slice(colon + 1), funnel: Boolean(part.AllowFunnel?.[hostPort]) })
87
+ }
88
+ }
89
+ return out
90
+ }
91
+
92
+ const addressOf = (name, httpsPort) => `https://${name}${httpsPort === '443' ? '' : `:${httpsPort}`}/`
93
+
43
94
  // Phone address: the `tailscale serve` entry whose proxy targets wherdr's
44
95
  // local port (served on any HTTPS port), else the address publishing would
45
96
  // give (same port) with served: false. Null without a tailnet name.
46
97
  export function phoneAddress(name, port, serveJson) {
47
98
  if (!name) return null
48
- let web = {}
49
- try { web = JSON.parse(serveJson || '{}')?.Web || {} } catch {}
50
- const target = new RegExp(`^(https?://)?(127\\.0\\.0\\.1|localhost):${port}/?$`)
51
- for (const [hostPort, entry] of Object.entries(web)) {
52
- if (!Object.values(entry?.Handlers || {}).some(h => target.test(h?.Proxy || ''))) continue
53
- const servedPort = hostPort.slice(hostPort.lastIndexOf(':') + 1)
54
- return { url: `https://${name}${servedPort === '443' ? '' : `:${servedPort}`}/`, served: true, httpsPort: servedPort }
55
- }
99
+ const entries = wherdrEntries(name, port, serveJson) || []
100
+ const hit = entries.find(e => !e.funnel) || entries[0]
101
+ if (hit) return { url: addressOf(name, hit.httpsPort), served: true, httpsPort: hit.httpsPort, ...(hit.funnel ? { funnel: true } : {}) }
56
102
  return { url: `https://${name}:${port}/`, served: false, httpsPort: String(port) }
57
103
  }
58
104
 
105
+ // The addresses wherdr adopts by itself (server/utils/phone.ts): what
106
+ // `tailscale serve` publishes for its port on this machine's name, read from
107
+ // Tailscale, which only the owner of the machine configures. null: unknown
108
+ // (nothing is changed then); []: nothing is published. An address that
109
+ // `tailscale funnel` also opens to the Internet is never adopted.
110
+ export function servedAddresses(name, port, serveJson) {
111
+ const entries = wherdrEntries(name, port, serveJson)
112
+ return entries && entries.filter(e => !e.funnel).map(e => addressOf(name, e.httpsPort))
113
+ }
114
+
59
115
  // HTTPS ports already published by `tailscale serve` (for anything).
60
116
  export function servedPorts(serveJson) {
61
117
  try {
@@ -92,16 +148,18 @@ export function serveError(output) {
92
148
  return null
93
149
  }
94
150
 
95
- // Tailscale's state for wherdr's port: { installed, bin, connected, https, name, phone, taken }.
96
- export async function inspect(port, bin = tailscaleBin()) {
97
- if (!bin) return { installed: false }
151
+ // Tailscale's state for wherdr's port: { installed, bin, socket, connected,
152
+ // https, name, phone, taken, served }. Read with the CLI, or without one
153
+ // (Docker) from Tailscale's socket.
154
+ export async function inspect(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
155
+ if (!bin && !socket) return { installed: false }
98
156
  const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
99
- const status = tailnetStatus(await run(['status', '--json']))
100
- const serve = status.name ? await run(['serve', 'status', '--json']) : ''
157
+ const status = tailnetStatus(bin ? await run(['status', '--json']) : await localApi(socket, 'status?peers=false'))
158
+ const serve = !status.name ? '' : bin ? await run(['serve', 'status', '--json']) : await localApi(socket, 'serve-config')
101
159
  const phone = phoneAddress(status.name, port, serve)
102
160
  // Publishing on wherdr's port would replace whatever else is served there.
103
161
  const taken = Boolean(phone && !phone.served && servedPorts(serve).includes(String(port)))
104
- return { installed: true, bin, ...status, phone, taken }
162
+ return { installed: true, bin, socket, ...status, phone, taken, served: servedAddresses(status.name, port, serve) }
105
163
  }
106
164
 
107
165
  // The tailnet's DNS suffix (tailnet.ts.net) from /etc/resolv.conf's search
@@ -152,29 +210,49 @@ export function failureCause(err) {
152
210
  return 'network'
153
211
  }
154
212
 
155
- // reachable() with the details: { reach, cause?, status? }: `cause` when
156
- // unreachable (failureCause), `status` the HTTP code when something answered.
157
- export function probe(url, timeoutMs = 8000) {
213
+ // GET of `route` on the address → { status, text }, or { cause } when it failed.
214
+ function fetchText(url, route, timeoutMs) {
158
215
  const { promise, resolve } = Promise.withResolvers()
159
216
  let u
160
- try { u = new URL('manifest.webmanifest', url) } catch { return Promise.resolve({ reach: 'unreachable', cause: 'network' }) }
217
+ try { u = new URL(route, url) } catch { return Promise.resolve({ cause: 'network' }) }
161
218
  // Accept JSON: a refused host then answers { code: 'host' }, not the HTML page.
162
219
  const req = https.get(u, { lookup, timeout: timeoutMs, headers: { accept: 'application/json' } }, (res) => {
163
220
  let text = ''
164
221
  res.setEncoding('utf8')
165
222
  res.on('data', (c) => { if (text.length < 65536) text += c })
166
- res.on('end', () => {
167
- const status = res.statusCode || 0
168
- if (status >= 200 && status < 300) resolve({ reach: text.includes('wherdr') ? 'ok' : 'other', status })
169
- else resolve({ reach: status === 403 && /"code"\s*:\s*"host"/.test(text) ? 'host' : 'other', status })
170
- })
171
- res.on('error', e => resolve({ reach: 'unreachable', cause: failureCause(e) }))
223
+ res.on('end', () => resolve({ status: res.statusCode || 0, text }))
224
+ res.on('error', e => resolve({ cause: failureCause(e) }))
172
225
  })
173
226
  req.on('timeout', () => req.destroy(new Error('timeout')))
174
- req.on('error', e => resolve({ reach: 'unreachable', cause: failureCause(e) }))
227
+ req.on('error', e => resolve({ cause: failureCause(e) }))
175
228
  return promise
176
229
  }
177
230
 
231
+ // What an answer of /api/health means: 'ok' (wherdr, and it accepts this
232
+ // address), 'host' (wherdr's host check refused it), or null: not a wherdr
233
+ // that has this route (versions up to 1.3.1, or another program).
234
+ export function healthReach(status, text) {
235
+ if (status >= 200 && status < 300 && /"name"\s*:\s*"wherdr"/.test(text)) return 'ok'
236
+ if (status === 403 && /"code"\s*:\s*"host"/.test(text)) return 'host'
237
+ return null
238
+ }
239
+
240
+ // reachable() with the details: { reach, cause?, status? }: `cause` when
241
+ // unreachable (failureCause), `status` the HTTP code when something answered.
242
+ // /api/health is asked first: it goes through wherdr's host check, which the
243
+ // static manifest does not (it answers for any host name). The manifest only
244
+ // recognizes an older wherdr without that route.
245
+ export async function probe(url, timeoutMs = 8000) {
246
+ const health = await fetchText(url, 'api/health', timeoutMs)
247
+ if (health.cause) return { reach: 'unreachable', cause: health.cause }
248
+ const reach = healthReach(health.status, health.text)
249
+ if (reach) return { reach, status: health.status }
250
+ const manifest = await fetchText(url, 'manifest.webmanifest', timeoutMs)
251
+ if (manifest.cause) return { reach: 'unreachable', cause: manifest.cause }
252
+ const ok = manifest.status >= 200 && manifest.status < 300 && manifest.text.includes('wherdr')
253
+ return { reach: ok ? 'ok' : 'other', status: ok ? manifest.status : health.status }
254
+ }
255
+
178
256
  // Runs `tailscale <args>` without a terminal → { code, output }. On a tailnet
179
257
  // without HTTPS, `tailscale serve` prints a link and waits: the timeout ends it.
180
258
  export function runTailscale(bin, args, timeoutMs = 30_000) {