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.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{AgentView.C4YOwvZG.css → AgentView.DzFWBd2A.css} +1 -1
- package/.output/public/_nuxt/B4Yk2tB1.js +23 -0
- package/.output/public/_nuxt/BBw_0a4M.js +6 -0
- package/.output/public/_nuxt/{B6qBWHWB.js → BSy6OCIY.js} +1 -1
- package/.output/public/_nuxt/BX5PM2JQ.js +1 -0
- package/.output/public/_nuxt/BgPDnf2S.js +1 -0
- package/.output/public/_nuxt/BkOxQzbS.js +2 -0
- package/.output/public/_nuxt/{WLQjK8uJ.js → CKhgznq4.js} +45 -46
- package/.output/public/_nuxt/{C2T9fQ4f.js → CP8o925U.js} +1 -1
- package/.output/public/_nuxt/CkbmecwS.js +1 -0
- package/.output/public/_nuxt/Cr3SrGky.js +1 -0
- package/.output/public/_nuxt/DAg9fiVh.js +89 -0
- package/.output/public/_nuxt/DT8qZ0Wv.js +1 -0
- package/.output/public/_nuxt/DaaJIRB3.js +1 -0
- package/.output/public/_nuxt/DcJi16XA.js +1 -0
- package/.output/public/_nuxt/VpjCgSXu.js +36 -0
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/c735b6c0-1bd0-47d7-a208-d310f0a06287.json +1 -0
- package/.output/public/_nuxt/dPxB2L94.js +89 -0
- package/.output/public/_nuxt/{entry.cKR16VqG.css → entry.CGWKmYU3.css} +1 -1
- package/.output/public/_nuxt/hePW80VL.js +1 -0
- package/.output/public/_nuxt/xYX85SPI.js +1 -0
- package/.output/public/sw.js +1 -1
- package/.output/server/chunks/_/branding.mjs +1 -1
- package/.output/server/chunks/_/filePaths.mjs +25 -0
- package/.output/server/chunks/_/files.mjs +204 -0
- package/.output/server/chunks/_/machineOrder.mjs +1 -1
- package/.output/server/chunks/_/onboarding.mjs +1 -1
- package/.output/server/chunks/_/package.mjs +97 -0
- package/.output/server/chunks/_/pluginPolicy.mjs +93 -0
- package/.output/server/chunks/_/plugins.mjs +4 -94
- package/.output/server/chunks/_/projectBoard.mjs +120 -29
- package/.output/server/chunks/_/projectDocs.mjs +106 -0
- package/.output/server/chunks/_/projectInbox.mjs +135 -0
- package/.output/server/chunks/_/projectThreads.mjs +46 -0
- package/.output/server/chunks/_/projectThreads2.mjs +61 -0
- package/.output/server/chunks/_/projectsActions.mjs +1 -1
- package/.output/server/chunks/_/projectsPlugin.mjs +1 -1
- package/.output/server/chunks/_/restart.mjs +2 -312
- package/.output/server/chunks/_/reveal.mjs +2 -24
- package/.output/server/chunks/_/selfupdate.mjs +83 -0
- package/.output/server/chunks/_/updates.mjs +91 -0
- package/.output/server/chunks/_/worktrees.mjs +1 -1
- package/.output/server/chunks/_/ws.mjs +1 -1
- package/.output/server/chunks/nitro/nitro.mjs +2660 -1061
- package/.output/server/chunks/raw/bump-homebrew.mjs +1 -1
- package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
- package/.output/server/chunks/raw/release-ready.mjs +5 -0
- package/.output/server/chunks/routes/api/_..._.mjs +6 -2
- package/.output/server/chunks/routes/api/agents.post.mjs +6 -2
- package/.output/server/chunks/routes/api/ask-tab.post.mjs +6 -2
- package/.output/server/chunks/routes/api/attach.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/disable.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/lock.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/login/options.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/max-session.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/register/options.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +6 -2
- package/.output/server/chunks/routes/api/auth/status.get.mjs +6 -2
- package/.output/server/chunks/routes/api/board.get.mjs +147 -0
- package/.output/server/chunks/routes/api/changes.get.mjs +7 -3
- package/.output/server/chunks/routes/api/chat/image.get.mjs +7 -3
- package/.output/server/chunks/routes/api/chat.get.mjs +7 -3
- package/.output/server/chunks/routes/api/choose.post.mjs +34 -4
- package/.output/server/chunks/routes/api/claude-statusline.post.mjs +7 -3
- package/.output/server/chunks/routes/api/close/status.get.mjs +7 -3
- package/.output/server/chunks/routes/api/close.post.mjs +6 -2
- package/.output/server/chunks/routes/api/codex-update.post.mjs +7 -3
- package/.output/server/chunks/routes/api/commands.get.mjs +7 -3
- package/.output/server/chunks/routes/api/config.get.mjs +8 -3
- package/.output/server/chunks/routes/api/dirs.get.mjs +7 -3
- package/.output/server/chunks/routes/api/dismiss-stopped.post.mjs +43 -0
- package/.output/server/chunks/routes/api/dismiss.post.mjs +7 -3
- package/.output/server/chunks/routes/api/effort.post.mjs +7 -3
- package/.output/server/chunks/routes/api/efforts.get.mjs +7 -3
- package/.output/server/chunks/routes/api/file.post.mjs +7 -3
- package/.output/server/chunks/routes/api/files/download.get.mjs +53 -0
- package/.output/server/chunks/routes/api/files/read.get.mjs +45 -0
- package/.output/server/chunks/routes/api/files.get.mjs +44 -0
- package/.output/server/chunks/routes/api/gitroot.get.mjs +7 -3
- package/.output/server/chunks/routes/api/health.get.mjs +36 -0
- package/.output/server/chunks/routes/api/index.get.mjs +6 -2
- package/.output/server/chunks/routes/api/input.post.mjs +18 -4
- package/.output/server/chunks/routes/api/interrupt.post.mjs +7 -3
- package/.output/server/chunks/routes/api/isgit.get.mjs +7 -3
- package/.output/server/chunks/routes/api/machine/awake.get.mjs +7 -3
- package/.output/server/chunks/routes/api/machine/awake.post.mjs +7 -3
- package/.output/server/chunks/routes/api/machine/order.get.mjs +6 -2
- package/.output/server/chunks/routes/api/machine/order.post.mjs +6 -2
- package/.output/server/chunks/routes/api/machine/rename.post.mjs +7 -3
- package/.output/server/chunks/routes/api/menu.post.mjs +7 -3
- package/.output/server/chunks/routes/api/model.post.mjs +7 -3
- package/.output/server/chunks/routes/api/models.get.mjs +7 -3
- package/.output/server/chunks/routes/api/nav.post.mjs +10 -5
- package/.output/server/chunks/routes/api/onboarding.get.mjs +6 -2
- package/.output/server/chunks/routes/api/onboarding.post.mjs +6 -2
- package/.output/server/chunks/routes/api/phone.get.mjs +6 -7
- package/.output/server/chunks/routes/api/phone.post.mjs +6 -7
- package/.output/server/chunks/routes/api/plugins/actions.get.mjs +7 -2
- package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +7 -2
- package/.output/server/chunks/routes/api/plugins/profiles.get.mjs +7 -2
- package/.output/server/chunks/routes/api/plugins/projects.get.mjs +7 -3
- package/.output/server/chunks/routes/api/plugins/projects.post.mjs +7 -3
- package/.output/server/chunks/routes/api/plugins/thread-limits.get.mjs +7 -3
- package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +7 -3
- package/.output/server/chunks/routes/api/project/inbox.get.mjs +43 -0
- package/.output/server/chunks/routes/api/project/inbox.post.mjs +43 -0
- package/.output/server/chunks/routes/api/project/memory.get.mjs +42 -0
- package/.output/server/chunks/routes/api/project/report.get.mjs +7 -3
- package/.output/server/chunks/routes/api/project/routines.get.mjs +43 -0
- package/.output/server/chunks/routes/api/project/settings.get.mjs +41 -0
- package/.output/server/chunks/routes/api/project/thread.get.mjs +44 -0
- package/.output/server/chunks/routes/api/project/thread.post.mjs +45 -0
- package/.output/server/chunks/routes/api/prompt.post.mjs +26 -4
- package/.output/server/chunks/routes/api/push/quiet.post.mjs +7 -3
- package/.output/server/chunks/routes/api/push/subscribe.post.mjs +7 -3
- package/.output/server/chunks/routes/api/push/test.post.mjs +7 -3
- package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +7 -3
- package/.output/server/chunks/routes/api/quotas.get.mjs +7 -3
- package/.output/server/chunks/routes/api/rename.post.mjs +6 -2
- package/.output/server/chunks/routes/api/requeue.post.mjs +7 -3
- package/.output/server/chunks/routes/api/restart.get.mjs +6 -2
- package/.output/server/chunks/routes/api/restart.post.mjs +6 -2
- package/.output/server/chunks/routes/api/reveal.post.mjs +7 -2
- package/.output/server/chunks/routes/api/screen-key.post.mjs +7 -3
- package/.output/server/chunks/routes/api/screen.get.mjs +10 -4
- package/.output/server/chunks/routes/api/search.get.mjs +7 -3
- package/.output/server/chunks/routes/api/seen.post.mjs +7 -3
- package/.output/server/chunks/routes/api/sessions.get.mjs +7 -3
- package/.output/server/chunks/routes/api/sessions.post.mjs +7 -3
- package/.output/server/chunks/routes/api/space.post.mjs +19 -15
- package/.output/server/chunks/routes/api/state.get.mjs +7 -3
- package/.output/server/chunks/routes/api/unqueue.post.mjs +7 -3
- package/.output/server/chunks/routes/api/update/status.get.mjs +38 -0
- package/.output/server/chunks/routes/api/update.get.mjs +12 -51
- package/.output/server/chunks/routes/api/update.post.mjs +41 -0
- package/.output/server/chunks/routes/api/upload.post.mjs +7 -3
- package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +6 -2
- package/.output/server/chunks/routes/api/worktrees.get.mjs +6 -2
- package/.output/server/chunks/routes/favicon.ico.get.mjs +6 -2
- package/.output/server/chunks/routes/icons/_name_.get.mjs +7 -3
- package/.output/server/chunks/routes/renderer.mjs +8 -4
- package/.output/server/chunks/routes/uploads/_...name_.get.mjs +7 -3
- package/.output/server/chunks/routes/ws/_..._.mjs +7 -3
- package/.output/server/chunks/routes/ws/events.mjs +7 -3
- package/.output/server/chunks/routes/ws/term.mjs +9 -5
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/index.mjs +7 -3
- package/.output/server/package.json +1 -1
- package/README.md +440 -46
- package/bin/install-docker-skill.sh +20 -0
- package/bin/lib/commands.mjs +47 -9
- package/bin/lib/core.mjs +18 -2
- package/bin/lib/install-skill.sh +100 -0
- package/bin/lib/skill.mjs +16 -0
- package/bin/lib/tailnet.d.mts +12 -3
- package/bin/lib/tailnet.mjs +103 -25
- package/bin/lib/updater.mjs +320 -0
- package/bin/lib/verify-release.mjs +20 -0
- package/package.json +4 -2
- package/skills/wherdr/SKILL.md +180 -0
- package/.output/public/_nuxt/BDNMzG2s.js +0 -1
- package/.output/public/_nuxt/BMIe8p_3.js +0 -2
- package/.output/public/_nuxt/BpQH8Ho1.js +0 -1
- package/.output/public/_nuxt/BrAeJIZR.js +0 -30
- package/.output/public/_nuxt/C12eXl5z.js +0 -1
- package/.output/public/_nuxt/CXG-cOdh.js +0 -1
- package/.output/public/_nuxt/CYxHnMNH.js +0 -83
- package/.output/public/_nuxt/CctS2Xn6.js +0 -65
- package/.output/public/_nuxt/CiYar6hg.js +0 -6
- package/.output/public/_nuxt/DLLQgP0d.js +0 -1
- package/.output/public/_nuxt/Dwh-l2rQ.js +0 -17
- package/.output/public/_nuxt/bLP7Jj-b.js +0 -1
- package/.output/public/_nuxt/builds/meta/6466b0ab-7935-460c-abdf-2ea632dae6d0.json +0 -1
- package/.output/public/_nuxt/whFLy_Ne.js +0 -23
- package/.output/server/chunks/_/phone.mjs +0 -343
- 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 "$@"
|
package/bin/lib/commands.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
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)
|
|
327
|
-
|
|
328
|
-
|
|
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 === '
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/lib/tailnet.d.mts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Types of tailnet.mjs, for the server (server/utils/phone.ts).
|
|
2
|
-
|
|
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 }>
|
package/bin/lib/tailnet.mjs
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
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(
|
|
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
|
-
|
|
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({
|
|
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) {
|