wherdr 1.3.4 → 2.0.1
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/072bjdot.js +2 -0
- package/.output/public/_nuxt/8FAMamJX.js +1 -0
- package/.output/public/_nuxt/{AgentView.DzFWBd2A.css → AgentView.CfD2RWZY.css} +1 -1
- package/.output/public/_nuxt/BDNMzG2s.js +1 -0
- package/.output/public/_nuxt/BX8KVYfd.js +1 -0
- package/.output/public/_nuxt/{CJKsMzIp.js → BYZVrreW.js} +1 -1
- package/.output/public/_nuxt/C2aT0CgA.js +62 -0
- package/.output/public/_nuxt/CanLPS0U.js +105 -0
- package/.output/public/_nuxt/DGXKTg7D.js +31 -0
- package/.output/public/_nuxt/{DMLmW6JM.js → DOsw6KCR.js} +41 -42
- package/.output/public/_nuxt/DTiR5P9E.js +25 -0
- package/.output/public/_nuxt/{w7Rm7unj.js → DYKfeNM-.js} +1 -1
- package/.output/public/_nuxt/DZ7VvF3e.js +1 -0
- package/.output/public/_nuxt/DZGKxROq.js +6 -0
- package/.output/public/_nuxt/DeLLm2j1.js +23 -0
- package/.output/public/_nuxt/DumNBIyF.js +1 -0
- package/.output/public/_nuxt/ScQ9FtVj.js +1 -0
- package/.output/public/_nuxt/TS7GZUPU.js +9 -0
- package/.output/public/_nuxt/board.DK5EU4sL.css +1 -0
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/b6ed1ddd-d6d6-4137-beab-2548b20dd998.json +1 -0
- package/.output/public/_nuxt/entry.BhiYrGAW.css +2 -0
- package/.output/public/_nuxt/settings.Cdb21nNw.css +1 -0
- package/.output/public/sw.js +1 -1
- package/.output/server/chunks/_/branding.mjs +1 -1
- package/.output/server/chunks/_/changes.mjs +4 -4
- package/.output/server/chunks/_/fileDownload.mjs +278 -0
- package/.output/server/chunks/_/files.mjs +44 -17
- package/.output/server/chunks/_/machineOrder.mjs +1 -1
- package/.output/server/chunks/_/onboarding.mjs +1 -1
- package/.output/server/chunks/_/package.mjs +9 -3
- package/.output/server/chunks/_/plugins.mjs +92 -125
- package/.output/server/chunks/_/restart.mjs +1 -1
- package/.output/server/chunks/_/selfupdate.mjs +1 -1
- package/.output/server/chunks/_/updates.mjs +1 -1
- package/.output/server/chunks/_/worktrees.mjs +1 -1
- package/.output/server/chunks/_/ws.mjs +43 -6
- package/.output/server/chunks/nitro/nitro.mjs +14073 -10658
- package/.output/server/chunks/raw/check-doc-links.mjs +5 -0
- package/.output/server/chunks/raw/check-leaks.mjs +1 -1
- package/.output/server/chunks/raw/check-release-notes.mjs +5 -0
- package/.output/server/chunks/raw/herdr-plugin.mjs +1 -1
- package/.output/server/chunks/raw/install-claude-statusline.mjs +1 -1
- package/.output/server/chunks/raw/prerelease-summary.mjs +5 -0
- package/.output/server/chunks/raw/prerelease.mjs +7 -0
- package/.output/server/chunks/raw/reveal.mjs +1 -1
- package/.output/server/chunks/raw/test-affected.mjs +5 -0
- package/.output/server/chunks/routes/api/_..._.mjs +6 -5
- package/.output/server/chunks/routes/api/agents.post.mjs +6 -5
- package/.output/server/chunks/routes/api/ask-tab.post.mjs +6 -5
- package/.output/server/chunks/routes/api/attach.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/disable.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/lock-all.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/lock.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/login/options.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/login/verify.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/max-session.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/register/options.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/register/verify.post.mjs +6 -5
- package/.output/server/chunks/routes/api/auth/status.get.mjs +6 -5
- package/.output/server/chunks/routes/api/changes.get.mjs +7 -6
- package/.output/server/chunks/routes/api/chat/image.get.mjs +7 -6
- package/.output/server/chunks/routes/api/chat.get.mjs +20 -7
- package/.output/server/chunks/routes/api/choose.post.mjs +31 -17
- package/.output/server/chunks/routes/api/claude-setup.post.mjs +42 -0
- package/.output/server/chunks/routes/api/claude-statusline.post.mjs +7 -6
- package/.output/server/chunks/routes/api/clear-queued-input.post.mjs +42 -0
- package/.output/server/chunks/routes/api/close/status.get.mjs +7 -6
- package/.output/server/chunks/routes/api/close.post.mjs +6 -5
- package/.output/server/chunks/routes/api/codex-update.post.mjs +7 -6
- package/.output/server/chunks/routes/api/commands.get.mjs +7 -6
- package/.output/server/chunks/routes/api/config.get.mjs +10 -13
- package/.output/server/chunks/routes/api/dirs.get.mjs +7 -6
- package/.output/server/chunks/routes/api/dismiss-stopped.post.mjs +7 -6
- package/.output/server/chunks/routes/api/dismiss.post.mjs +7 -6
- package/.output/server/chunks/routes/api/effort.post.mjs +7 -6
- package/.output/server/chunks/routes/api/efforts.get.mjs +7 -6
- package/.output/server/chunks/routes/api/file.post.mjs +7 -6
- package/.output/server/chunks/routes/api/files/download.get.mjs +12 -31
- package/.output/server/chunks/routes/api/files/download.head.mjs +41 -0
- package/.output/server/chunks/routes/api/files/read.get.mjs +8 -7
- package/.output/server/chunks/routes/api/files.get.mjs +7 -6
- package/.output/server/chunks/routes/api/health.get.mjs +6 -5
- package/.output/server/chunks/routes/api/input.post.mjs +7 -6
- package/.output/server/chunks/routes/api/interrupt.post.mjs +24 -21
- package/.output/server/chunks/routes/api/isgit.get.mjs +8 -8
- package/.output/server/chunks/routes/api/machine/awake.get.mjs +7 -6
- package/.output/server/chunks/routes/api/machine/awake.post.mjs +7 -6
- package/.output/server/chunks/routes/api/machine/order.get.mjs +6 -5
- package/.output/server/chunks/routes/api/machine/order.post.mjs +6 -5
- package/.output/server/chunks/routes/api/machine/reconnect.post.mjs +40 -0
- package/.output/server/chunks/routes/api/machine/rename.post.mjs +7 -6
- package/.output/server/chunks/routes/api/machine/status.post.mjs +39 -0
- package/.output/server/chunks/routes/api/menu-left.post.mjs +40 -0
- package/.output/server/chunks/routes/api/menu.post.mjs +7 -6
- package/.output/server/chunks/routes/api/model.post.mjs +7 -6
- package/.output/server/chunks/routes/api/models.get.mjs +7 -6
- package/.output/server/chunks/routes/api/nav.post.mjs +7 -6
- package/.output/server/chunks/routes/api/onboarding.get.mjs +6 -5
- package/.output/server/chunks/routes/api/onboarding.post.mjs +6 -5
- package/.output/server/chunks/routes/api/phone.get.mjs +8 -10
- package/.output/server/chunks/routes/api/phone.post.mjs +8 -10
- package/.output/server/chunks/routes/api/plugins/actions.get.mjs +6 -10
- package/.output/server/chunks/routes/api/plugins/invoke.post.mjs +6 -10
- package/.output/server/chunks/routes/api/plugins/projects.get.mjs +10 -9
- package/.output/server/chunks/routes/api/plugins/projects.post.mjs +11 -8
- package/.output/server/chunks/routes/api/prompt.post.mjs +22 -13
- package/.output/server/chunks/routes/api/push/quiet.post.mjs +7 -6
- package/.output/server/chunks/routes/api/push/subscribe.post.mjs +7 -7
- package/.output/server/chunks/routes/api/push/test.post.mjs +7 -6
- package/.output/server/chunks/routes/api/push/unsubscribe.post.mjs +7 -6
- package/.output/server/chunks/routes/api/quotas.get.mjs +7 -6
- package/.output/server/chunks/routes/api/rename.post.mjs +6 -5
- package/.output/server/chunks/routes/api/requeue.post.mjs +7 -6
- package/.output/server/chunks/routes/api/restart.get.mjs +6 -5
- package/.output/server/chunks/routes/api/restart.post.mjs +6 -5
- package/.output/server/chunks/routes/api/reveal.post.mjs +91 -8
- package/.output/server/chunks/routes/api/screen-key.post.mjs +7 -6
- package/.output/server/chunks/routes/api/screen.get.mjs +7 -6
- package/.output/server/chunks/routes/api/search.get.mjs +7 -6
- package/.output/server/chunks/routes/api/seen.post.mjs +7 -6
- package/.output/server/chunks/routes/api/sessions.get.mjs +7 -6
- package/.output/server/chunks/routes/api/sessions.post.mjs +7 -6
- package/.output/server/chunks/routes/api/settings-tab.post.mjs +7 -6
- package/.output/server/chunks/routes/api/{plugins/thread-limits.get.mjs → settings.get.mjs} +10 -9
- package/.output/server/chunks/routes/api/settings.post.mjs +40 -0
- package/.output/server/chunks/routes/api/space.post.mjs +34 -18
- package/.output/server/chunks/routes/api/state.get.mjs +7 -6
- package/.output/server/chunks/routes/api/tasks/action.post.mjs +39 -0
- package/.output/server/chunks/routes/api/{plugins/profiles.get.mjs → tasks/add.post.mjs} +12 -16
- package/.output/server/chunks/routes/api/tasks/after.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/approve.post.mjs +39 -0
- package/.output/server/chunks/routes/api/{project/report.get.mjs → tasks/archived.get.mjs} +12 -14
- package/.output/server/chunks/routes/api/{index.get.mjs → tasks/autopilot.get.mjs} +13 -14
- package/.output/server/chunks/routes/api/tasks/autopilot.post.mjs +39 -0
- package/.output/server/chunks/routes/api/{gitroot.get.mjs → tasks/board-settings.get.mjs} +12 -14
- package/.output/server/chunks/routes/api/tasks/board-settings.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/coordinator.get.mjs +40 -0
- package/.output/server/chunks/routes/api/tasks/coordinator.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/details.get.mjs +40 -0
- package/.output/server/chunks/routes/api/tasks/limit.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/links.get.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/models.get.mjs +40 -0
- package/.output/server/chunks/routes/api/tasks/pick.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/question.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks/scheduled.get.mjs +40 -0
- package/.output/server/chunks/routes/api/{project → tasks}/settings.get.mjs +10 -12
- package/.output/server/chunks/routes/api/tasks/start.get.mjs +40 -0
- package/.output/server/chunks/routes/api/tasks/tell.post.mjs +39 -0
- package/.output/server/chunks/routes/api/tasks.get.mjs +39 -0
- package/.output/server/chunks/routes/api/unqueue.post.mjs +7 -6
- package/.output/server/chunks/routes/api/update/status.get.mjs +6 -5
- package/.output/server/chunks/routes/api/update.get.mjs +8 -10
- package/.output/server/chunks/routes/api/update.post.mjs +8 -10
- package/.output/server/chunks/routes/api/upload.post.mjs +7 -6
- package/.output/server/chunks/routes/api/worktrees/remove.post.mjs +6 -5
- package/.output/server/chunks/routes/api/worktrees.get.mjs +6 -5
- package/.output/server/chunks/routes/favicon.ico.get.mjs +6 -5
- package/.output/server/chunks/routes/icons/_name_.get.mjs +7 -6
- package/.output/server/chunks/routes/renderer.mjs +8 -7
- package/.output/server/chunks/routes/uploads/_...name_.get.mjs +7 -6
- package/.output/server/chunks/routes/ws/_..._.mjs +7 -6
- package/.output/server/chunks/routes/ws/events.mjs +9 -7
- package/.output/server/chunks/routes/ws/term.mjs +7 -6
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/index.mjs +7 -6
- package/.output/server/package.json +1 -1
- package/README.md +44 -1445
- package/bin/lib/commands.mjs +44 -9
- package/bin/lib/panel.mjs +3 -11
- package/bin/lib/service.mjs +41 -2
- package/bin/lib/skill.mjs +1 -1
- package/bin/lib/tailnet.d.mts +6 -3
- package/bin/lib/tailnet.mjs +70 -32
- package/bin/lib/userpath.d.mts +14 -0
- package/bin/lib/userpath.mjs +142 -0
- package/docs/README.md +58 -0
- package/docs/RELEASING.md +174 -0
- package/docs/board.md +169 -0
- package/docs/command.md +58 -0
- package/docs/configuration.md +39 -0
- package/docs/conversation.md +132 -0
- package/docs/development.md +38 -0
- package/docs/devices.md +58 -0
- package/docs/files.md +29 -0
- package/docs/herdr-projects-1x.md +278 -0
- package/docs/home.md +52 -0
- package/docs/installation.md +174 -0
- package/docs/machines.md +74 -0
- package/docs/migrate-from-1.x-docker.md +160 -0
- package/docs/networks.md +154 -0
- package/docs/notifications.md +83 -0
- package/docs/overview.md +71 -0
- package/docs/phone.md +26 -0
- package/docs/plugins.md +109 -0
- package/docs/quick-start.md +89 -0
- package/docs/quotas.md +65 -0
- package/docs/release-notes-2.0.0.md +84 -0
- package/docs/security.md +117 -0
- package/docs/terminal.md +15 -0
- package/docs/troubleshooting.md +45 -0
- package/package.json +9 -3
- package/skills/wherdr/SKILL.md +24 -173
- package/.output/public/_nuxt/BEJHMUrj.js +0 -1
- package/.output/public/_nuxt/BOtcEDnA.js +0 -1
- package/.output/public/_nuxt/BUaYhQ2r.js +0 -6
- package/.output/public/_nuxt/CJNhn9sA.js +0 -36
- package/.output/public/_nuxt/COCvtdHN.js +0 -89
- package/.output/public/_nuxt/Cl45J6_Q.js +0 -1
- package/.output/public/_nuxt/CndPDbMI.js +0 -2
- package/.output/public/_nuxt/Ctbx58Um.js +0 -23
- package/.output/public/_nuxt/Dl1PHLOB.js +0 -1
- package/.output/public/_nuxt/Nhex3_iH.js +0 -1
- package/.output/public/_nuxt/OpWX45i7.js +0 -89
- package/.output/public/_nuxt/builds/meta/4994f3a4-4126-4f57-a9e5-c7ebb25850bc.json +0 -1
- package/.output/public/_nuxt/dTj21DMs.js +0 -1
- package/.output/public/_nuxt/entry.BohTWE-n.css +0 -2
- package/.output/public/_nuxt/kwSoIiM7.js +0 -1
- package/.output/public/_nuxt/mkEwYka4.js +0 -1
- package/.output/server/chunks/_/pluginPolicy.mjs +0 -93
- package/.output/server/chunks/_/projectBoard.mjs +0 -363
- package/.output/server/chunks/_/projectDocs.mjs +0 -106
- package/.output/server/chunks/_/projectInbox.mjs +0 -135
- package/.output/server/chunks/_/projectThreads.mjs +0 -46
- package/.output/server/chunks/_/projectThreads2.mjs +0 -61
- package/.output/server/chunks/_/projectsActions.mjs +0 -143
- package/.output/server/chunks/_/projectsPlugin.mjs +0 -30
- package/.output/server/chunks/_/projectsPlugin2.mjs +0 -57
- package/.output/server/chunks/_/reveal.mjs +0 -115
- package/.output/server/chunks/raw/dev.wherdr.open.mjs +0 -7
- package/.output/server/chunks/raw/docker-entrypoint.mjs +0 -7
- package/.output/server/chunks/raw/install-host-open.mjs +0 -7
- package/.output/server/chunks/raw/wherdr-open.mjs +0 -7
- package/.output/server/chunks/routes/api/board.get.mjs +0 -148
- package/.output/server/chunks/routes/api/plugins/thread-limits.post.mjs +0 -45
- package/.output/server/chunks/routes/api/project/inbox.get.mjs +0 -44
- package/.output/server/chunks/routes/api/project/inbox.post.mjs +0 -44
- package/.output/server/chunks/routes/api/project/memory.get.mjs +0 -43
- package/.output/server/chunks/routes/api/project/routines.get.mjs +0 -44
- package/.output/server/chunks/routes/api/project/thread.get.mjs +0 -45
- package/.output/server/chunks/routes/api/project/thread.post.mjs +0 -47
- package/bin/install-docker-skill.sh +0 -20
package/bin/lib/commands.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { pathToFileURL } from 'node:url'
|
|
|
10
10
|
import { BIN, CliError, SERVER, VERSION, bad, c, homebrewInstall, localUrl, ok, portTaken, probe, row, say, serverEnv, settingsFile, warn, wherdrDir, withSettings } from './core.mjs'
|
|
11
11
|
import { RUNTIME_HELP, findRuntime, isBun, runtimeOk, runtimeVersion, temporaryInstall } from './runtime.mjs'
|
|
12
12
|
import { brewDoctor, brewRedirect, brewService, brewStatusRows } from './brew.mjs'
|
|
13
|
-
import { controlService, installService, serviceFile, servicePlatform, serviceState, uninstallService } from './service.mjs'
|
|
13
|
+
import { controlService, enableLinger, installService, lingerOn, serviceFile, servicePlatform, serviceState, uninstallService } from './service.mjs'
|
|
14
|
+
import { loginShellPath, userPath } from './userpath.mjs'
|
|
14
15
|
import { refreshSkill } from './skill.mjs'
|
|
15
16
|
import { LINKS, inspect, publishCommand, reachable } from './tailnet.mjs'
|
|
16
17
|
|
|
@@ -33,7 +34,7 @@ Commands:
|
|
|
33
34
|
|
|
34
35
|
Options:
|
|
35
36
|
--port <n> Listening port (default: 7683, or $PORT)
|
|
36
|
-
--host <addr> Listening address (default: 127.0.0.1, or $HOST)
|
|
37
|
+
--host <addr> Listening address (default: 127.0.0.1, or $HOST): loopback or this machine's tailnet address.
|
|
37
38
|
--data-dir <dir> Passkeys, push keys and settings (default: ~/wherdr/data, or $DATA_DIR)
|
|
38
39
|
--session <name> Named Herdr session to drive (default: Herdr's default session)
|
|
39
40
|
-v, --version Print the version
|
|
@@ -224,7 +225,7 @@ export async function stop(opts, { quiet = false } = {}) {
|
|
|
224
225
|
}
|
|
225
226
|
if (quiet) return false
|
|
226
227
|
const other = await probe(ctx.port, ctx.host)
|
|
227
|
-
if (other) warn(`the wherdr on port ${ctx.port} was not started by \`wherdr start\` (
|
|
228
|
+
if (other) warn(`the wherdr on port ${ctx.port} was not started by \`wherdr start\` (plugin, a terminal…): left running.`)
|
|
228
229
|
else say('wherdr is not running.')
|
|
229
230
|
return false
|
|
230
231
|
}
|
|
@@ -254,7 +255,7 @@ export async function status(opts) {
|
|
|
254
255
|
const hb = brew && brewStatusRows(brew)
|
|
255
256
|
say(`${c.bold('wherdr')} ${VERSION}`)
|
|
256
257
|
row('Address', answer === 'wherdr' ? `${c.green('●')} ${c.cyan(ctx.url)} answers` : answer ? `${c.yellow('●')} port ${ctx.port} is taken by another program` : `${c.dim('○')} port ${ctx.port} free: not running`)
|
|
257
|
-
row('Process', hb?.process ? hb.process : svc.running ? 'login service' : pid ? `pid ${pid} (wherdr start)` : answer === 'wherdr' ? 'started another way (
|
|
258
|
+
row('Process', hb?.process ? hb.process : svc.running ? 'login service' : pid ? `pid ${pid} (wherdr start)` : answer === 'wherdr' ? 'started another way (plugin, a terminal…)' : 'none')
|
|
258
259
|
row('Service', hb ? hb.service : svc.installed ? `installed (${serviceFile()})${svc.running ? '' : ', not running'}` : 'not installed · wherdr service install')
|
|
259
260
|
row('Data', ctx.env.DATA_DIR)
|
|
260
261
|
row('Log', hb ? hb.log : ctx.files.log)
|
|
@@ -403,20 +404,51 @@ export async function service(opts) {
|
|
|
403
404
|
const runtime = findRuntime(ctx.env)
|
|
404
405
|
if (!runtime) throw new CliError(RUNTIME_HELP)
|
|
405
406
|
// The background server of `wherdr start` would hold the port.
|
|
406
|
-
|
|
407
|
+
const stopped = await stop(opts, { quiet: true })
|
|
408
|
+
if (stopped) ok('background wherdr stopped (the service takes over).')
|
|
407
409
|
await checkPort(ctx)
|
|
408
410
|
remember(ctx)
|
|
409
411
|
const args = [bin, 'run', '--port', ctx.port, '--host', ctx.host, '--data-dir', ctx.env.DATA_DIR]
|
|
410
412
|
if (ctx.env.HERDR_WEB_SESSION) args.push('--session', ctx.env.HERDR_WEB_SESSION)
|
|
411
|
-
|
|
413
|
+
// launchd and systemd do not see the shell's PATH: the service gets the
|
|
414
|
+
// user's, so agents in user folders are found by the Board's commands.
|
|
415
|
+
const PATH = servicePath(runtime, ctx.env)
|
|
412
416
|
const env = { PATH, WHERDR_DIR: ctx.dir, WHERDR_RUNTIME: runtime }
|
|
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]
|
|
414
|
-
|
|
417
|
+
for (const k of ['APP_URL', 'HERDR_BIN', 'HOST_LABEL', 'CLAUDE_CONFIG_DIR', 'WHERDR_SKILL', 'HERDR_PROJECTS_DATA_DIR']) if (ctx.env[k]) env[k] = ctx.env[k]
|
|
418
|
+
let file
|
|
419
|
+
try {
|
|
420
|
+
file = installService({ runtime, args, env, log: ctx.files.log })
|
|
421
|
+
} catch (e) {
|
|
422
|
+
// wherdr was stopped above for the service to take over: started again,
|
|
423
|
+
// as it was (the previous service, or in the background).
|
|
424
|
+
let back = ''
|
|
425
|
+
if (stopped) {
|
|
426
|
+
try {
|
|
427
|
+
await start(opts)
|
|
428
|
+
back = '\n wherdr was started again as before.'
|
|
429
|
+
} catch (err) { back = `\n wherdr could not be started again: ${err.message}` }
|
|
430
|
+
}
|
|
431
|
+
throw new CliError(`${e.message}${back}`)
|
|
432
|
+
}
|
|
415
433
|
ok(`login service installed: ${file}`)
|
|
416
434
|
say(c.dim(` ${runtime} ${args.join(' ')}`))
|
|
417
435
|
if (await waitUp(ctx, () => true)) ok(`wherdr runs: ${c.cyan(ctx.url)} (and starts at every login)`)
|
|
418
436
|
else failWithLog(`the service did not answer on port ${ctx.port} after 45 s.`, ctx.files.log, 0)
|
|
419
|
-
if (servicePlatform() === 'systemd')
|
|
437
|
+
if (servicePlatform() === 'systemd') {
|
|
438
|
+
// Without lingering, systemd stops the user's services at logout (and
|
|
439
|
+
// starts them only at the next login): a server reached over SSH or from
|
|
440
|
+
// the phone would go away. Allowed for one's own user, without sudo, on
|
|
441
|
+
// most systems; otherwise the command to run is printed.
|
|
442
|
+
const linger = enableLinger()
|
|
443
|
+
if (linger.ok) ok(linger.already ? 'lingering is on: wherdr keeps running when you log out.' : 'lingering enabled (loginctl enable-linger): wherdr keeps running when you log out.')
|
|
444
|
+
else warn(`lingering is off: wherdr stops when you log out. To keep it running, run once: ${c.cyan(`sudo loginctl enable-linger ${os.userInfo().username}`)}`)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The service's PATH: the runtime's folder, then the user's PATH (the login
|
|
449
|
+
// shell's, asked once, herdr-projects' record and the usual folders).
|
|
450
|
+
export function servicePath(runtime, env = process.env, { home = os.homedir(), login = loginShellPath(env), exists } = {}) {
|
|
451
|
+
return userPath({ env, home, first: [path.dirname(runtime)], login, exists })
|
|
420
452
|
}
|
|
421
453
|
|
|
422
454
|
// ---------------------------------------------------------------- doctor
|
|
@@ -478,6 +510,9 @@ export async function doctor(opts) {
|
|
|
478
510
|
else if (!svc.installed) warn('Login service: not installed (wherdr service install starts wherdr at login).')
|
|
479
511
|
else if (svc.running) ok(`Login service: running (${serviceFile()})`)
|
|
480
512
|
else fail(`Login service: installed but not running (${serviceFile()}). See wherdr logs.`)
|
|
513
|
+
if (!brew && svc.installed && servicePlatform() === 'systemd' && !lingerOn()) {
|
|
514
|
+
warn(`Lingering is off: wherdr stops when you log out (sudo loginctl enable-linger ${os.userInfo().username}).`)
|
|
515
|
+
}
|
|
481
516
|
|
|
482
517
|
try {
|
|
483
518
|
mkdirSync(ctx.env.DATA_DIR, { recursive: true })
|
package/bin/lib/panel.mjs
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// `wherdr panel`: the Herdr plugin's "panel" action opens it in a popup.
|
|
2
|
-
// The state, and two main keys: O opens wherdr, P opens its phone setup
|
|
3
|
-
// (Settings › Phone) in the browser. The other commands stay as a fallback,
|
|
4
|
-
// in a quieter line. Refreshed every few seconds.
|
|
5
|
-
//
|
|
6
|
-
// Commands run through a controller: `wherdr` itself, or the Herdr plugin
|
|
7
|
-
// script when WHERDR_CONTROL holds its argv as JSON (it knows Docker mode).
|
|
8
1
|
import { spawn } from 'node:child_process'
|
|
9
2
|
import os from 'node:os'
|
|
10
3
|
import path from 'node:path'
|
|
@@ -43,7 +36,7 @@ export async function tailnet(port) {
|
|
|
43
36
|
|
|
44
37
|
export async function gather(ctx, env = process.env, net = null) {
|
|
45
38
|
const answer = await probe(ctx.port, ctx.host)
|
|
46
|
-
const mode =
|
|
39
|
+
const mode = 'native'
|
|
47
40
|
return {
|
|
48
41
|
version: VERSION,
|
|
49
42
|
mode,
|
|
@@ -55,7 +48,7 @@ export async function gather(ctx, env = process.env, net = null) {
|
|
|
55
48
|
port: ctx.port,
|
|
56
49
|
answer,
|
|
57
50
|
pid: livePid(ctx.files.pid),
|
|
58
|
-
service:
|
|
51
|
+
service: serviceState(),
|
|
59
52
|
serviceFile: serviceFile(),
|
|
60
53
|
dir: ctx.dir,
|
|
61
54
|
tailnet: net ?? await tailnet(ctx.port),
|
|
@@ -95,14 +88,13 @@ export function render(state, qrText = '') {
|
|
|
95
88
|
out.push(` ${MAIN_KEYS.map(([k, label]) => `${c.bold(k.toUpperCase())} ${label}`).join(' ')}`)
|
|
96
89
|
out.push('')
|
|
97
90
|
out.push(` ${c.dim(OTHER_KEYS.map(([k, label]) => `${k.toUpperCase()} ${label}`).join(' '))}`)
|
|
98
|
-
out.push(` ${c.dim(
|
|
91
|
+
out.push(` ${c.dim(`native · ${state.runtime} · auto-start ${autostart(state)}`)}`)
|
|
99
92
|
if (state.hotkey) out.push(` ${c.dim(`Herdr key ${state.hotkey} opens this panel`)}`)
|
|
100
93
|
out.push(` ${c.dim(removeHint(state))}`)
|
|
101
94
|
return out.join('\n')
|
|
102
95
|
}
|
|
103
96
|
|
|
104
97
|
function autostart(state) {
|
|
105
|
-
if (state.mode === 'docker') return 'yes · Docker restart policy'
|
|
106
98
|
if (state.service.installed) return `at login (${state.service.running ? 'running' : 'not running'}) · A turns it off`
|
|
107
99
|
if (state.plugin) return 'with Herdr (plugin startup) · A: also at login'
|
|
108
100
|
return 'no · A: at login'
|
package/bin/lib/service.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `wherdr service install|uninstall`: start wherdr at login, with the absolute
|
|
2
2
|
// path of the runtime. macOS: a LaunchAgent; Linux: a systemd --user unit.
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import os from 'node:os'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
|
|
@@ -88,11 +88,24 @@ export function serviceState(home = os.homedir()) {
|
|
|
88
88
|
return { installed: true, running: sh('systemctl', ['--user', 'is-active', '--quiet', UNIT]).ok }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// A failure leaves things as they were: the previous service file back (and
|
|
92
|
+
// loaded again), or no file at all, so a later run does not take a half-made
|
|
93
|
+
// service for an installed one. `run` is injectable for the tests.
|
|
94
|
+
export function installService(spec, home = os.homedir(), run = sh) {
|
|
92
95
|
const file = serviceFile(home)
|
|
93
96
|
if (!file) throw new Error(`no login service on ${process.platform}: keep wherdr running with your own service manager.`)
|
|
94
97
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
95
98
|
mkdirSync(path.dirname(spec.log), { recursive: true })
|
|
99
|
+
const before = existsSync(file) ? readFileSync(file, 'utf8') : null
|
|
100
|
+
try {
|
|
101
|
+
return writeService(file, spec, run)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
restoreService(file, before, run)
|
|
104
|
+
throw e
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeService(file, spec, sh) {
|
|
96
109
|
if (servicePlatform() === 'launchd') {
|
|
97
110
|
writeFileSync(file, launchdPlist(spec))
|
|
98
111
|
sh('launchctl', ['bootout', `${domain()}/${LABEL}`])
|
|
@@ -109,6 +122,18 @@ export function installService(spec, home = os.homedir()) {
|
|
|
109
122
|
return file
|
|
110
123
|
}
|
|
111
124
|
|
|
125
|
+
function restoreService(file, before, sh) {
|
|
126
|
+
try {
|
|
127
|
+
if (before === null) rmSync(file, { force: true })
|
|
128
|
+
else writeFileSync(file, before)
|
|
129
|
+
if (servicePlatform() === 'launchd') {
|
|
130
|
+
if (before !== null) sh('launchctl', ['bootstrap', domain(), file])
|
|
131
|
+
} else {
|
|
132
|
+
sh('systemctl', ['--user', 'daemon-reload'])
|
|
133
|
+
}
|
|
134
|
+
} catch { /* best effort: the original error is the one to report */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
export function uninstallService(home = os.homedir()) {
|
|
113
138
|
const file = serviceFile(home)
|
|
114
139
|
if (!file || !existsSync(file)) return null
|
|
@@ -119,6 +144,20 @@ export function uninstallService(home = os.homedir()) {
|
|
|
119
144
|
return file
|
|
120
145
|
}
|
|
121
146
|
|
|
147
|
+
// systemd: is lingering on for this user (its services run without a login)?
|
|
148
|
+
export function lingerOn(user = os.userInfo().username, run = sh) {
|
|
149
|
+
const r = run('loginctl', ['show-user', user, '--property=Linger', '--value'])
|
|
150
|
+
return r.ok && r.out.trim() === 'yes'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Turns lingering on for this user, without sudo (logind allows it for one's
|
|
154
|
+
// own user on most systems). { ok, already }: ok false when it was refused.
|
|
155
|
+
export function enableLinger(user = os.userInfo().username, run = sh) {
|
|
156
|
+
if (lingerOn(user, run)) return { ok: true, already: true }
|
|
157
|
+
const r = run('loginctl', ['enable-linger', user])
|
|
158
|
+
return { ok: r.ok && lingerOn(user, run), already: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
// start | stop | restart through the service manager.
|
|
123
162
|
export function controlService(action) {
|
|
124
163
|
if (servicePlatform() === 'launchd') {
|
package/bin/lib/skill.mjs
CHANGED
package/bin/lib/tailnet.d.mts
CHANGED
|
@@ -4,7 +4,6 @@ export interface PhoneAddress { url: string, served: boolean, httpsPort: string,
|
|
|
4
4
|
export interface Tailnet {
|
|
5
5
|
installed: boolean
|
|
6
6
|
bin?: string | null
|
|
7
|
-
// Tailscale's socket, when the state was read there (no CLI: Docker).
|
|
8
7
|
socket?: string | null
|
|
9
8
|
name?: string | null
|
|
10
9
|
connected?: boolean
|
|
@@ -30,12 +29,16 @@ export function servedPorts(serveJson: string): string[]
|
|
|
30
29
|
export function publishCommand(port: string | number, httpsPort?: string | number): string
|
|
31
30
|
export function publishArgs(port: string | number): string[]
|
|
32
31
|
export function unpublishArgs(httpsPort: string): string[]
|
|
33
|
-
export function tailnetUrl(text: unknown): string | null
|
|
34
32
|
export function serveError(output: string): { code: ServeErrorCode, link: string } | null
|
|
35
33
|
export function inspect(port: string | number, bin?: string | null, socket?: string | null): Promise<Tailnet>
|
|
36
|
-
export function tailnetDomain(resolvConf: string): string | null
|
|
37
34
|
export function reachable(url: string, timeoutMs?: number): Promise<Reach>
|
|
38
35
|
export function healthReach(status: number, text: string): 'ok' | 'host' | null
|
|
39
36
|
export function probe(url: string, timeoutMs?: number): Promise<Probe>
|
|
40
37
|
export function failureCause(err: unknown): ReachCause
|
|
41
38
|
export function runTailscale(bin: string, args: string[], timeoutMs?: number): Promise<{ code: number, output: string }>
|
|
39
|
+
export interface TailnetPeers { readable: boolean, state: string | null, running: boolean, selfIps: string[], peerIps: string[] }
|
|
40
|
+
export interface TailnetFacts extends Omit<TailnetPeers, 'state'> { installed: boolean, funnel: boolean | null }
|
|
41
|
+
export function tailnetPeers(statusJson: string): TailnetPeers
|
|
42
|
+
export function targetPort(target: unknown): number | null
|
|
43
|
+
export function funnelExposes(port: string | number, serveJson: string): boolean | null
|
|
44
|
+
export function tailnetFacts(port: string | number, bin?: string | null, socket?: string | null): Promise<TailnetFacts>
|
package/bin/lib/tailnet.mjs
CHANGED
|
@@ -27,11 +27,7 @@ export function tailscaleBin(env = process.env, exists = existsSync) {
|
|
|
27
27
|
return dirs.find(p => exists(p)) || null
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
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).
|
|
30
|
+
// The local read-only API is useful when the CLI is absent from PATH.
|
|
35
31
|
export const TAILSCALED_SOCKET = '/var/run/tailscale/tailscaled.sock'
|
|
36
32
|
export function tailscaledSocket(env = process.env, exists = existsSync) {
|
|
37
33
|
const file = env.WHERDR_TAILSCALED_SOCKET || TAILSCALED_SOCKET
|
|
@@ -67,6 +63,75 @@ export function tailnetStatus(statusJson) {
|
|
|
67
63
|
}
|
|
68
64
|
}
|
|
69
65
|
|
|
66
|
+
// What wherdr's network check needs from `tailscale status --json` (with
|
|
67
|
+
// peers): whether the answer could be read at all, Tailscale's state, this
|
|
68
|
+
// machine's tailnet addresses and those of its peers. Read from Tailscale
|
|
69
|
+
// itself, never guessed from an interface address in 100.64.0.0/10.
|
|
70
|
+
// readable: false for an empty or broken answer (a failed command, a socket
|
|
71
|
+
// that did not answer): nothing is known then, not "stopped".
|
|
72
|
+
export function tailnetPeers(statusJson) {
|
|
73
|
+
let s = null
|
|
74
|
+
try { s = JSON.parse(statusJson) } catch {}
|
|
75
|
+
const ips = node => (Array.isArray(node?.TailscaleIPs) ? node.TailscaleIPs.filter(ip => typeof ip === 'string') : [])
|
|
76
|
+
const readable = Boolean(s && typeof s === 'object' && typeof s.BackendState === 'string')
|
|
77
|
+
return {
|
|
78
|
+
readable,
|
|
79
|
+
state: readable ? s.BackendState : null,
|
|
80
|
+
running: readable && s.BackendState === 'Running',
|
|
81
|
+
selfIps: readable ? ips(s.Self) : [],
|
|
82
|
+
peerIps: readable ? Object.values(s.Peer || {}).flatMap(ips) : [],
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// States in which tailscaled relays nothing, Funnel included.
|
|
87
|
+
const IDLE_STATES = new Set(['Stopped', 'NeedsLogin', 'NoState', 'NeedsMachineAuth'])
|
|
88
|
+
|
|
89
|
+
// The port a Serve or Funnel target leads to, or null when it cannot be told
|
|
90
|
+
// ("127.0.0.1:7683", "0.0.0.0:7683", "[::1]:7683", "http://localhost:7683/x",
|
|
91
|
+
// "https+insecure://host", "7683"…). The host is deliberately not compared:
|
|
92
|
+
// 0.0.0.0, another spelling of loopback, a LAN address of this machine or a
|
|
93
|
+
// name may all reach wherdr, so only the port can rule a target out.
|
|
94
|
+
export function targetPort(target) {
|
|
95
|
+
const t = String(target ?? '').trim()
|
|
96
|
+
if (/^\d{1,5}$/.test(t)) return Number(t)
|
|
97
|
+
const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\[[^\]]*\]|[^/:\s]*)(?::(\d{1,5}))?(?:[/?#].*)?$/i.exec(t)
|
|
98
|
+
if (!m || !m[2]) return null
|
|
99
|
+
if (m[3]) return Number(m[3])
|
|
100
|
+
const scheme = (m[1] || '').toLowerCase().replace(/\+insecure$/, '')
|
|
101
|
+
return scheme === 'http' ? 80 : scheme === 'https' ? 443 : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function funnelExposes(port, serveJson) {
|
|
105
|
+
let config = null
|
|
106
|
+
try { config = JSON.parse(serveJson) } catch {}
|
|
107
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return null
|
|
108
|
+
const reaches = target => targetPort(target) === null || targetPort(target) === Number(port)
|
|
109
|
+
for (const part of [config, ...Object.values(config.Foreground || {})]) {
|
|
110
|
+
for (const [hostPort, on] of Object.entries(part?.AllowFunnel || {})) {
|
|
111
|
+
if (!on) continue
|
|
112
|
+
const tcpPort = hostPort.slice(hostPort.lastIndexOf(':') + 1)
|
|
113
|
+
const tcp = part?.TCP?.[tcpPort]
|
|
114
|
+
if (tcp?.TCPForward !== undefined && reaches(tcp.TCPForward)) return true
|
|
115
|
+
const handlers = Object.values(part?.Web?.[hostPort]?.Handlers || {})
|
|
116
|
+
if (handlers.some(h => h && typeof h === 'object' && h.Proxy !== undefined && reaches(h.Proxy))) return true
|
|
117
|
+
// Funnel on a port with neither a TCP forward nor web handlers we can read.
|
|
118
|
+
if (tcp?.TCPForward === undefined && !part?.Web?.[hostPort] && !tcp?.HTTPS && !tcp?.HTTP) return true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Only a valid reading may prove the absence of Funnel on this port.
|
|
125
|
+
export async function tailnetFacts(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
|
|
126
|
+
if (!bin && !socket) return { installed: false, readable: true, running: false, selfIps: [], peerIps: [], funnel: false }
|
|
127
|
+
const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
|
|
128
|
+
const { state, ...peers } = tailnetPeers(bin ? await run(['status', '--json']) : await localApi(socket, 'status'))
|
|
129
|
+
if (!peers.readable) return { installed: true, ...peers, funnel: null }
|
|
130
|
+
if (IDLE_STATES.has(state)) return { installed: true, ...peers, funnel: false }
|
|
131
|
+
const serve = bin ? await run(['serve', 'status', '--json']) : await localApi(socket, 'serve-config')
|
|
132
|
+
return { installed: true, ...peers, funnel: funnelExposes(port, serve) }
|
|
133
|
+
}
|
|
134
|
+
|
|
70
135
|
// The HTTPS entries of a serve configuration that proxy to wherdr's local
|
|
71
136
|
// port on this machine's own name: [{ hostPort, httpsPort, funnel }], or null
|
|
72
137
|
// when the configuration could not be read (Tailscale stopped, not JSON).
|
|
@@ -120,22 +185,10 @@ export function servedPorts(serveJson) {
|
|
|
120
185
|
} catch { return [] }
|
|
121
186
|
}
|
|
122
187
|
|
|
123
|
-
// The command that publishes wherdr's port, as typed by hand (Docker mode).
|
|
124
|
-
// `httpsPort`: the tailnet port, wherdr's own by default.
|
|
125
188
|
export const publishCommand = (port, httpsPort = port) => `tailscale serve --bg --https=${httpsPort} http://127.0.0.1:${port}`
|
|
126
189
|
export const publishArgs = port => ['serve', '--bg', `--https=${port}`, `http://127.0.0.1:${port}`]
|
|
127
190
|
export const unpublishArgs = httpsPort => ['serve', `--https=${httpsPort}`, 'off']
|
|
128
191
|
|
|
129
|
-
// A tailnet address typed by hand: https://machine.tailnet.ts.net[:port]/ → its
|
|
130
|
-
// normalized form, or null.
|
|
131
|
-
export function tailnetUrl(text) {
|
|
132
|
-
try {
|
|
133
|
-
const u = new URL(String(text || '').trim())
|
|
134
|
-
if (u.protocol !== 'https:' || !/^[a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net$/i.test(u.hostname) || u.username || u.password) return null
|
|
135
|
-
return `https://${u.host.toLowerCase()}/`
|
|
136
|
-
} catch { return null }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
192
|
// A failed `tailscale serve` → { code, link } for the known causes, else null.
|
|
140
193
|
// code: 'operator' (Linux: the user may not change Tailscale's settings),
|
|
141
194
|
// 'https' (no HTTPS certificates on the tailnet), 'offline' (not connected).
|
|
@@ -148,9 +201,6 @@ export function serveError(output) {
|
|
|
148
201
|
return null
|
|
149
202
|
}
|
|
150
203
|
|
|
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
204
|
export async function inspect(port, bin = tailscaleBin(), socket = bin ? null : tailscaledSocket()) {
|
|
155
205
|
if (!bin && !socket) return { installed: false }
|
|
156
206
|
const run = args => exec(bin, args, { encoding: 'utf8', timeout: 8000 }).then(r => r.stdout, () => '')
|
|
@@ -162,18 +212,6 @@ export async function inspect(port, bin = tailscaleBin(), socket = bin ? null :
|
|
|
162
212
|
return { installed: true, bin, socket, ...status, phone, taken, served: servedAddresses(status.name, port, serve) }
|
|
163
213
|
}
|
|
164
214
|
|
|
165
|
-
// The tailnet's DNS suffix (tailnet.ts.net) from /etc/resolv.conf's search
|
|
166
|
-
// line: a Docker container copies it from the host when MagicDNS is on.
|
|
167
|
-
export function tailnetDomain(resolvConf) {
|
|
168
|
-
for (const line of String(resolvConf || '').split('\n')) {
|
|
169
|
-
const m = /^\s*search\s+(.+)$/.exec(line)
|
|
170
|
-
const hit = m?.[1].split(/\s+/).find(d => /^[a-z0-9-]+\.ts\.net$/i.test(d))
|
|
171
|
-
if (hit) return hit.toLowerCase()
|
|
172
|
-
}
|
|
173
|
-
return null
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Tailscale's DNS (MagicDNS): a Docker container does not use it, but reaches it.
|
|
177
215
|
const MAGIC_DNS = '100.100.100.100'
|
|
178
216
|
|
|
179
217
|
// The system's lookup, then MagicDNS for *.ts.net names.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Types of userpath.mjs, for the server (server/utils/env.ts, projectsTasks.ts).
|
|
2
|
+
export const USER_BIN_DIRS: string[]
|
|
3
|
+
export const LEAD_BIN_DIRS: string[]
|
|
4
|
+
export const TAIL_BIN_DIRS: string[]
|
|
5
|
+
export const SYSTEM_BIN_DIRS: string[]
|
|
6
|
+
export function mergePath(...parts: Array<string | string[] | undefined | null>): string
|
|
7
|
+
export function knownBinDirs(home?: string, exists?: (p: string) => boolean): string[]
|
|
8
|
+
export function projectsDataDir(env?: NodeJS.ProcessEnv, home?: string): string
|
|
9
|
+
export function projectsEnvPath(env?: NodeJS.ProcessEnv, home?: string): string
|
|
10
|
+
export function pathFromEnvOutput(text: string): string
|
|
11
|
+
export function loginShellPath(env?: NodeJS.ProcessEnv, run?: unknown): string
|
|
12
|
+
export function userPath(opts?: { env?: NodeJS.ProcessEnv, home?: string, first?: string[], login?: string, exists?: (p: string) => boolean }): string
|
|
13
|
+
export const JSON_PATH_AWK: string
|
|
14
|
+
export const USER_PATH_SH: string
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The PATH of the user, for what wherdr runs without a login shell: the login
|
|
2
|
+
// service (systemd --user, launchd), the server it starts, and the
|
|
3
|
+
// herdr-projects commands of the Board, on this machine and over SSH.
|
|
4
|
+
//
|
|
5
|
+
// systemd and launchd start wherdr with a short system PATH, and an SSH command
|
|
6
|
+
// gets the remote shell's non-login PATH: agents installed in a user folder
|
|
7
|
+
// (~/.local/bin, ~/.bun/bin, ~/.cargo/bin, ~/.npm-global/bin, Homebrew) are
|
|
8
|
+
// then not found, and herdr-projects refuses with "claude is not installed
|
|
9
|
+
// here". herdr-projects only widens its own PATH when Herdr runs it as a plugin
|
|
10
|
+
// command; wherdr runs it directly. So wherdr gives it the PATH an interactive
|
|
11
|
+
// login shell would have, in this order:
|
|
12
|
+
// 1. at `wherdr service install`: the PATH of the login shell, asked once;
|
|
13
|
+
// 2. the PATH herdr-projects recorded from a login shell
|
|
14
|
+
// ($HERDR_PROJECTS_DATA_DIR/env.json, default ~/.herdr-projects/data);
|
|
15
|
+
// 3. the usual user folders and Homebrew's, when they exist;
|
|
16
|
+
// 4. the PATH it was given;
|
|
17
|
+
// 5. the system folders, when they exist.
|
|
18
|
+
// The user's own folders come before the system ones, as in their shell: an
|
|
19
|
+
// old system-wide copy of an agent never wins over theirs. Each folder once.
|
|
20
|
+
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
// Where agent CLIs install themselves for one user (relative to $HOME).
|
|
26
|
+
export const USER_BIN_DIRS = ['.local/bin', '.bun/bin', '.cargo/bin', '.npm-global/bin', '.deno/bin', '.volta/bin', 'bin']
|
|
27
|
+
// Homebrew (Apple silicon, Intel, Linux) and /usr/local/bin, where a user's
|
|
28
|
+
// own installs go too: before the PATH given, with the user folders.
|
|
29
|
+
export const LEAD_BIN_DIRS = ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/home/linuxbrew/.linuxbrew/bin', '/usr/local/bin']
|
|
30
|
+
// The system's own: after the PATH given.
|
|
31
|
+
export const TAIL_BIN_DIRS = ['/usr/bin', '/bin', '/usr/local/sbin', '/usr/sbin', '/sbin']
|
|
32
|
+
export const SYSTEM_BIN_DIRS = [...LEAD_BIN_DIRS, ...TAIL_BIN_DIRS]
|
|
33
|
+
|
|
34
|
+
// Joins PATH strings or folder lists into one PATH: absolute folders only,
|
|
35
|
+
// each once, in the order first seen.
|
|
36
|
+
export function mergePath(...parts) {
|
|
37
|
+
const seen = new Set()
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
const list = Array.isArray(part) ? part : String(part || '').split(':')
|
|
40
|
+
for (const raw of list) {
|
|
41
|
+
const dir = String(raw || '').trim()
|
|
42
|
+
if (dir && dir.startsWith('/') && !dir.includes('\n')) seen.add(dir.replace(/(.)\/+$/, '$1'))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...seen].join(':')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isDir = (p) => { try { return statSync(p).isDirectory() } catch { return false } }
|
|
49
|
+
|
|
50
|
+
// The usual folders that exist on this machine.
|
|
51
|
+
export function knownBinDirs(home = os.homedir(), exists = isDir) {
|
|
52
|
+
return [...USER_BIN_DIRS.map(d => path.join(home, d)), ...SYSTEM_BIN_DIRS].filter(d => exists(d))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// herdr-projects' data folder, as it finds it: HERDR_PROJECTS_DATA_DIR, else
|
|
56
|
+
// HERDR_PROJECTS_HOME/data, else ~/.herdr-projects/data.
|
|
57
|
+
export function projectsDataDir(env = process.env, home = os.homedir()) {
|
|
58
|
+
return env.HERDR_PROJECTS_DATA_DIR || path.join(env.HERDR_PROJECTS_HOME || path.join(home, '.herdr-projects'), 'data')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The PATH herdr-projects recorded from a login shell, '' when there is none.
|
|
62
|
+
export function projectsEnvPath(env = process.env, home = os.homedir()) {
|
|
63
|
+
const file = path.join(projectsDataDir(env, home), 'env.json')
|
|
64
|
+
if (!existsSync(file)) return ''
|
|
65
|
+
try {
|
|
66
|
+
const p = JSON.parse(readFileSync(file, 'utf8'))?.PATH
|
|
67
|
+
return typeof p === 'string' ? p : ''
|
|
68
|
+
} catch { return '' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The PATH line of `env` output; what a shell prints before it is ignored.
|
|
72
|
+
export function pathFromEnvOutput(text) {
|
|
73
|
+
const line = String(text || '').split('\n').find(l => l.startsWith('PATH='))
|
|
74
|
+
return line ? line.slice(5).trim() : ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The login shell's PATH ($SHELL -lc env), '' when it cannot be had. Never
|
|
78
|
+
// interactive: no terminal, a few seconds at most.
|
|
79
|
+
export function loginShellPath(env = process.env, run = spawnSync) {
|
|
80
|
+
const shell = env.SHELL && path.isAbsolute(env.SHELL) ? env.SHELL : '/bin/sh'
|
|
81
|
+
try {
|
|
82
|
+
const r = run(shell, ['-lc', 'env'], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], env })
|
|
83
|
+
return r.status === 0 ? pathFromEnvOutput(r.stdout) : ''
|
|
84
|
+
} catch { return '' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The PATH for the server and the commands it runs.
|
|
88
|
+
// `first`: folders to put before everything (the runtime's own folder).
|
|
89
|
+
// `login`: the login shell's PATH, when it was asked.
|
|
90
|
+
export function userPath({ env = process.env, home = os.homedir(), first = [], login = '', exists = isDir } = {}) {
|
|
91
|
+
const lead = [...USER_BIN_DIRS.map(d => path.join(home, d)), ...LEAD_BIN_DIRS].filter(d => exists(d))
|
|
92
|
+
return mergePath(first, login, projectsEnvPath(env, home), lead, env.PATH || '', TAIL_BIN_DIRS.filter(d => exists(d)))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The value of the first "PATH" key of a JSON file (env.json), its escapes
|
|
96
|
+
// decoded as JSON.parse does (\" \\ \/ \b \f \n \r \t \uXXXX, surrogate
|
|
97
|
+
// pairs as UTF-8), then an `x` so that the shell keeps a trailing newline.
|
|
98
|
+
// Nothing when there is no such string, or when it is cut short, holds a NUL
|
|
99
|
+
// or a bad escape. POSIX awk (BusyBox, mawk, BSD, gawk), run with LC_ALL=C so
|
|
100
|
+
// that %c writes one byte. No single quote in it: the script quotes it so.
|
|
101
|
+
export const JSON_PATH_AWK = String.raw`{ s = s $0 "\n" } `
|
|
102
|
+
+ String.raw`function b(v) { return sprintf("%c", v) } `
|
|
103
|
+
+ String.raw`function hex(t, j, x, v) { if (length(t) != 4) return -1; v = 0; for (j = 1; j <= 4; j++) { x = index("0123456789abcdef", tolower(substr(t, j, 1))); if (!x) return -1; v = v * 16 + x - 1 } return v } `
|
|
104
|
+
+ String.raw`END { if (!match(s, /"PATH"[ \t\r\n]*:[ \t\r\n]*"/)) exit; s = substr(s, RSTART + RLENGTH); n = length(s); o = ""; `
|
|
105
|
+
+ String.raw`for (k = 1; k <= n; k++) { c = substr(s, k, 1); `
|
|
106
|
+
+ String.raw`if (c == "\"") { printf "%sx", o; exit } `
|
|
107
|
+
+ String.raw`if (c != "\\") { o = o c; continue } `
|
|
108
|
+
+ String.raw`c = substr(s, ++k, 1); `
|
|
109
|
+
+ String.raw`if (c == "\"" || c == "\\" || c == "/") o = o c; else if (c == "b") o = o "\b"; else if (c == "f") o = o "\f"; else if (c == "n") o = o "\n"; else if (c == "r") o = o "\r"; else if (c == "t") o = o "\t"; `
|
|
110
|
+
+ String.raw`else if (c == "u") { v = hex(substr(s, k + 1, 4)); k += 4; `
|
|
111
|
+
+ String.raw`if (v >= 55296 && v < 56320) { if (substr(s, k + 1, 2) != "\\u") exit; w = hex(substr(s, k + 3, 4)); if (w < 56320 || w >= 57344) exit; k += 6; v = 65536 + (v - 55296) * 1024 + w - 56320 } `
|
|
112
|
+
+ String.raw`if (v <= 0 || (v >= 56320 && v < 57344)) exit; `
|
|
113
|
+
+ String.raw`if (v < 128) o = o b(v); else if (v < 2048) o = o b(192 + int(v / 64)) b(128 + v % 64); `
|
|
114
|
+
+ String.raw`else if (v < 65536) o = o b(224 + int(v / 4096)) b(128 + int(v / 64) % 64) b(128 + v % 64); `
|
|
115
|
+
+ String.raw`else o = o b(240 + int(v / 262144)) b(128 + int(v / 4096) % 64) b(128 + int(v / 64) % 64) b(128 + v % 64) } `
|
|
116
|
+
+ String.raw`else exit } }`
|
|
117
|
+
|
|
118
|
+
// The same for a shell on another machine (SSH), by the rules of userPath:
|
|
119
|
+
// the PATH herdr-projects recorded there, the usual user folders that exist
|
|
120
|
+
// there, the shell's own PATH, then the system folders; each folder trimmed,
|
|
121
|
+
// without a trailing slash, absolute, on one line and once. Fixed text, POSIX
|
|
122
|
+
// sh and awk only; the recorded value is only split and assigned (no
|
|
123
|
+
// globbing), never run.
|
|
124
|
+
const ADD_SH = [
|
|
125
|
+
' while :; do case "$d" in [$ws]*) d=${d#?} ;; *[$ws]) d=${d%?} ;; ?*/) d=${d%/} ;; *) break ;; esac; done',
|
|
126
|
+
' case "$d" in /*) ;; *) continue ;; esac; case "$d" in *"$nl"*) continue ;; esac',
|
|
127
|
+
' case ":$n:" in *":$d:"*) ;; *) n="${n:+$n:}$d" ;; esac',
|
|
128
|
+
'done',
|
|
129
|
+
]
|
|
130
|
+
const DIRS_SH = (dirs, home = false) => `for d in ${dirs.map(d => (home ? `"$HOME/${d}"` : d)).join(' ')}; do [ -d "$d" ] || continue`
|
|
131
|
+
export const USER_PATH_SH = [
|
|
132
|
+
'f="${HERDR_PROJECTS_DATA_DIR:-${HERDR_PROJECTS_HOME:-$HOME/.herdr-projects}/data}/env.json"',
|
|
133
|
+
// awk where the system keeps it, even from a PATH without /usr/bin.
|
|
134
|
+
`p=""; [ -r "$f" ] && p=$(PATH="\${PATH:+$PATH:}/usr/bin:/bin" LC_ALL=C awk '${JSON_PATH_AWK}' "$f" 2>/dev/null); p=\${p%x}`,
|
|
135
|
+
"nl='\n'; ws=\" \t\r$nl\"; n=''; old_ifs=$IFS; IFS=:; set -f",
|
|
136
|
+
'for d in $p; do', ...ADD_SH,
|
|
137
|
+
DIRS_SH(USER_BIN_DIRS, true), ...ADD_SH,
|
|
138
|
+
DIRS_SH(LEAD_BIN_DIRS), ...ADD_SH,
|
|
139
|
+
'for d in $PATH; do', ...ADD_SH,
|
|
140
|
+
DIRS_SH(TAIL_BIN_DIRS), ...ADD_SH,
|
|
141
|
+
'set +f; IFS=$old_ifs; [ -n "$n" ] && PATH=$n; export PATH; ',
|
|
142
|
+
].join('\n')
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Documentation
|
|
2
|
+
|
|
3
|
+
**Upgrading to 2.0.0?** Read the [release notes](release-notes-2.0.0.md) for breaking changes and the 1.x migration checklist.
|
|
4
|
+
|
|
5
|
+
Read only the page relevant to your task. Start with [Installation and updates](installation.md) for setup,
|
|
6
|
+
[Security](security.md) for remote access, or [Development and tests](development.md) for repository changes.
|
|
7
|
+
|
|
8
|
+
- [Overview and architecture](overview.md) — What wherdr does, why it exists and how it connects to Herdr. Includes the launch film and license.
|
|
9
|
+
- [First launch](quick-start.md) — Install the native service and follow the setup guide. Includes the desktop, Board and phone screenshots.
|
|
10
|
+
- [Home and agent list](home.md) — Find agents, create a session and filter machines or projects. Covers compact rows and state grouping.
|
|
11
|
+
- [Conversation and replies](conversation.md) — Read transcripts, answer questions, attach files and manage drafts. Includes quoted replies and decision tables.
|
|
12
|
+
- [Terminal](terminal.md) — Use the real terminal, copy text, open links and paste images. Explains take-over and size restoration.
|
|
13
|
+
- [Files and changes](files.md) — Review Git changes and comment on lines or blocks. Compose a review for the agent.
|
|
14
|
+
- [Computer, phone and iPad](devices.md) — Use split panes, keyboard shortcuts and touch controls. Describes device-specific layouts.
|
|
15
|
+
- [Installation and updates](installation.md) — Requirements, native installer, agent PATH, updates and migration from Docker.
|
|
16
|
+
- [Phone access and private networks](networks.md) — Set up Tailscale or another private network with HTTPS. Includes always-on servers and keep-awake.
|
|
17
|
+
- [The wherdr command](command.md) — Install through npm or Homebrew and manage the native service from the command line.
|
|
18
|
+
- [Herdr plugins](plugins.md) — Install and configure the wherdr plugin, its panel key and plugin actions.
|
|
19
|
+
- [Install as an app](phone.md) — Add wherdr to your home screen on a phone or install it in a desktop browser.
|
|
20
|
+
- [Settings and configuration](configuration.md) — Configure the service through environment variables and the app through shared and device-specific preferences.
|
|
21
|
+
- [Machines and SSH](machines.md) — Register remote machines and use their agents through SSH. Includes sessions and connection troubleshooting.
|
|
22
|
+
- [Claude and Codex quotas](quotas.md) — Show remaining usage and handle Codex updates, limits and restarts.
|
|
23
|
+
- [Notifications](notifications.md) — Enable Web Push, relay Herdr notifications and configure Do not disturb.
|
|
24
|
+
- [Board and coordinators](board.md) — Work with herdr-projects tasks, reviews, approvals and coordinators across machines.
|
|
25
|
+
- [Security](security.md) — Understand remote-shell access, passkeys and network restrictions. Read this before enabling remote access.
|
|
26
|
+
- [Development and tests](development.md) — Run from source and follow the contribution and agent test rules.
|
|
27
|
+
- [Troubleshooting](troubleshooting.md) — Resolve startup, permissions, network, SSH and conversation problems.
|
|
28
|
+
|
|
29
|
+
Also see [herdr-projects 1.x migration](herdr-projects-1x.md), [Releasing](RELEASING.md),
|
|
30
|
+
[keyboard shortcut specification](product/keyboard-shortcuts.md) and [Contributing](../CONTRIBUTING.md).
|
|
31
|
+
|
|
32
|
+
## Section reference
|
|
33
|
+
|
|
34
|
+
- [Quick start](quick-start.md#quick-start)
|
|
35
|
+
- [Why wherdr](overview.md#why-wherdr)
|
|
36
|
+
- [Features](home.md#features)
|
|
37
|
+
- [How it works](overview.md#how-it-works)
|
|
38
|
+
- [Requirements](installation.md#requirements)
|
|
39
|
+
- [Installation](installation.md#installation): [ask your AI agent](installation.md#ask-your-ai-agent) ·
|
|
40
|
+
[or run the installer yourself](installation.md#the-one-command) ·
|
|
41
|
+
[which setup?](installation.md#which-setup) ·
|
|
42
|
+
[always-on server + Tailscale](networks.md#recommended-always-on-server--tailscale) ·
|
|
43
|
+
[other private networks](networks.md#other-private-networks) ·
|
|
44
|
+
[as a Herdr plugin](plugins.md#install-as-a-herdr-plugin) ·
|
|
45
|
+
[the `wherdr` command (npm, Homebrew)](command.md#the-wherdr-command) ·
|
|
46
|
+
[from source](development.md#from-source) ·
|
|
47
|
+
[moving from Docker to the native service](migrate-from-1.x-docker.md)
|
|
48
|
+
- [Updating](installation.md#updating)
|
|
49
|
+
- [Install it as an app](phone.md#install-it-as-an-app)
|
|
50
|
+
- [Configuration](configuration.md#configuration)
|
|
51
|
+
- [Several machines (SSH)](machines.md#several-machines-ssh)
|
|
52
|
+
- [Claude and Codex quotas](quotas.md#claude-and-codex-quotas)
|
|
53
|
+
- [Notifications](notifications.md#notifications)
|
|
54
|
+
- [Works great with herdr-projects](board.md#works-great-with-herdr-projects)
|
|
55
|
+
- [Herdr plugins](plugins.md#herdr-plugins)
|
|
56
|
+
- [Security](security.md#security)
|
|
57
|
+
- [Troubleshooting](troubleshooting.md#troubleshooting)
|
|
58
|
+
- [Contributing](development.md#contributing) · [License](overview.md#license)
|