herdr-remote 0.2.6 → 0.2.8

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dibin
3
+ Copyright (c) 2026 dibin666
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -45,6 +45,18 @@ herdr-remote plugin link
45
45
  - Settings: `~/.config/herdr-remote/config.json`
46
46
  - Runtime state: `~/.local/state/herdr-remote/runtime.json` (mode `0600`)
47
47
 
48
+ ## Locating Herdr
49
+
50
+ The `herdr` binary is looked up in `HERDR_BIN_PATH`, then `PATH`, then the usual
51
+ install directories (`~/.local/bin`, `~/.cargo/bin`, `~/bin`, `/opt/homebrew/bin`,
52
+ `/usr/local/bin`, …). `herdr-remote keepalive install` writes the binary it found
53
+ and the current `PATH` into the systemd unit or launchd agent, because a service
54
+ manager does not inherit your shell's environment.
55
+
56
+ `herdr-remote status --json` reports `host.herdrCommand` and `host.herdrCommandFound`.
57
+ If it was not found, set `HERDR_BIN_PATH` to the full path and reinstall the
58
+ keep-alive service.
59
+
48
60
  ## License
49
61
 
50
62
  MIT
package/README.zh-CN.md CHANGED
@@ -45,6 +45,16 @@ herdr-remote plugin link
45
45
  - 配置文件:`~/.config/herdr-remote/config.json`
46
46
  - 运行状态:`~/.local/state/herdr-remote/runtime.json` (权限 `0600`)
47
47
 
48
+ ## herdr 命令的查找方式
49
+
50
+ 依次查找 `HERDR_BIN_PATH`、`PATH`,以及常见安装目录(`~/.local/bin`、`~/.cargo/bin`、
51
+ `~/bin`、`/opt/homebrew/bin`、`/usr/local/bin` 等)。服务管理器不会继承 shell 环境,
52
+ 因此 `herdr-remote keepalive install` 会把找到的可执行文件路径和当前 `PATH` 写入
53
+ systemd unit 或 launchd plist。
54
+
55
+ `herdr-remote status --json` 会输出 `host.herdrCommand` 与 `host.herdrCommandFound`。
56
+ 若显示未找到,请将 `HERDR_BIN_PATH` 设为完整路径后重新安装保活服务。
57
+
48
58
  ## 开源协议
49
59
 
50
60
  MIT
package/dist/tui.mjs CHANGED
@@ -526,7 +526,7 @@ var require_en = __commonJS({
526
526
  "herdr.registerDone": "Registered: {path}",
527
527
  "herdr.unregisterDone": "Plugin unregistered.",
528
528
  "herdr.registerFailed": "Registration failed: {message}",
529
- "herdr.cliMissing": "Command herdr not found in PATH.",
529
+ "herdr.cliMissing": "Command herdr not found. Set HERDR_BIN_PATH to its full path.",
530
530
  "about.title": "Language & about",
531
531
  "about.language": "Interface language",
532
532
  "about.languageAuto": "Follow system ({detected})",
@@ -751,7 +751,7 @@ var require_zh = __commonJS({
751
751
  "herdr.registerDone": "\u5DF2\u6CE8\u518C\uFF1A{path}",
752
752
  "herdr.unregisterDone": "\u5DF2\u53D6\u6D88\u6CE8\u518C\u3002",
753
753
  "herdr.registerFailed": "\u6CE8\u518C\u5931\u8D25\uFF1A{message}",
754
- "herdr.cliMissing": "PATH \u4E2D\u672A\u627E\u5230 herdr \u547D\u4EE4\u3002",
754
+ "herdr.cliMissing": "\u672A\u627E\u5230 herdr \u547D\u4EE4\uFF0C\u53EF\u5C06 HERDR_BIN_PATH \u8BBE\u4E3A\u5176\u5B8C\u6574\u8DEF\u5F84\u3002",
755
755
  "about.title": "\u8BED\u8A00\u4E0E\u5173\u4E8E",
756
756
  "about.language": "\u754C\u9762\u8BED\u8A00",
757
757
  "about.languageAuto": "\u8DDF\u968F\u7CFB\u7EDF\uFF08{detected}\uFF09",
@@ -1459,10 +1459,104 @@ var require_socket_discovery = __commonJS({
1459
1459
  var require_herdr_command = __commonJS({
1460
1460
  "src/herdr-command.js"(exports, module) {
1461
1461
  "use strict";
1462
- function resolveHerdrCommand() {
1463
- return process.env.HERDR_BIN_PATH || "herdr";
1462
+ var fs = __require("node:fs");
1463
+ var os = __require("node:os");
1464
+ var path = __require("node:path");
1465
+ var COMMAND_NAME = "herdr";
1466
+ var FALLBACK_DIRECTORIES = [
1467
+ "~/.local/bin",
1468
+ "~/.cargo/bin",
1469
+ "~/bin",
1470
+ "/opt/homebrew/bin",
1471
+ "/usr/local/bin",
1472
+ "/home/linuxbrew/.linuxbrew/bin",
1473
+ "/usr/bin"
1474
+ ];
1475
+ function expandHome(directory, home) {
1476
+ if (directory === "~") return home;
1477
+ if (directory.startsWith("~/")) return path.join(home, directory.slice(2));
1478
+ return directory;
1479
+ }
1480
+ function candidateNames(base = COMMAND_NAME) {
1481
+ return process.platform === "win32" ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`] : [base];
1482
+ }
1483
+ function isExecutableFile(candidate) {
1484
+ try {
1485
+ if (!fs.statSync(candidate).isFile()) return false;
1486
+ fs.accessSync(candidate, fs.constants.X_OK);
1487
+ return true;
1488
+ } catch {
1489
+ return false;
1490
+ }
1491
+ }
1492
+ function findInDirectory(directory, names = candidateNames()) {
1493
+ if (!directory) return null;
1494
+ for (const name of names) {
1495
+ const candidate = path.join(directory, name);
1496
+ if (isExecutableFile(candidate)) return candidate;
1497
+ }
1498
+ return null;
1499
+ }
1500
+ function findOnSearchPath(searchPath, names = candidateNames()) {
1501
+ for (const entry of String(searchPath || "").split(path.delimiter)) {
1502
+ const found = findInDirectory(entry.trim(), names);
1503
+ if (found) return found;
1504
+ }
1505
+ return null;
1464
1506
  }
1465
- module.exports = { resolveHerdrCommand };
1507
+ function fallbackDirectories(home = os.homedir()) {
1508
+ return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
1509
+ }
1510
+ function looksLikePath(value) {
1511
+ return value.includes("/") || value.includes(path.sep);
1512
+ }
1513
+ function resolveOverride(value, searchPath) {
1514
+ const trimmed = String(value || "").trim();
1515
+ if (!trimmed) return null;
1516
+ if (looksLikePath(trimmed)) {
1517
+ const absolute = path.resolve(trimmed);
1518
+ if (isExecutableFile(absolute)) return absolute;
1519
+ return findInDirectory(absolute);
1520
+ }
1521
+ return findOnSearchPath(searchPath, candidateNames(trimmed));
1522
+ }
1523
+ function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
1524
+ const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
1525
+ if (override) return { command: override, source: "env", found: true };
1526
+ const onPath = findOnSearchPath(env.PATH);
1527
+ if (onPath) return { command: onPath, source: "path", found: true };
1528
+ for (const directory of directories) {
1529
+ const found = findInDirectory(directory);
1530
+ if (found) return { command: found, source: "fallback", found: true };
1531
+ }
1532
+ return { command: COMMAND_NAME, source: "unresolved", found: false };
1533
+ }
1534
+ function resolveHerdrCommand(options) {
1535
+ return findHerdrCommand(options).command;
1536
+ }
1537
+ function verifyHerdrCommand(command, options = {}) {
1538
+ if (typeof command === "string" && looksLikePath(command) && isExecutableFile(command)) {
1539
+ return { command, source: "verified", found: true };
1540
+ }
1541
+ return findHerdrCommand(options);
1542
+ }
1543
+ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
1544
+ const override = String(env.HERDR_BIN_PATH || "").trim();
1545
+ const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
1546
+ if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
1547
+ parts.push(directories.length ? `Searched PATH and ${directories.join(", ")}.` : "Searched PATH.");
1548
+ parts.push("Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.");
1549
+ return parts.join(" ");
1550
+ }
1551
+ module.exports = {
1552
+ COMMAND_NAME,
1553
+ FALLBACK_DIRECTORIES,
1554
+ fallbackDirectories,
1555
+ findHerdrCommand,
1556
+ herdrNotFoundMessage,
1557
+ resolveHerdrCommand,
1558
+ verifyHerdrCommand
1559
+ };
1466
1560
  }
1467
1561
  });
1468
1562
 
@@ -1496,7 +1590,7 @@ var require_service = __commonJS({
1496
1590
  } = require_terminal_palette();
1497
1591
  var { resolveSocketPath } = require_socket_discovery();
1498
1592
  var { preferredLanAddress: preferredLanAddress2 } = require_net_interfaces();
1499
- var { resolveHerdrCommand } = require_herdr_command();
1593
+ var { findHerdrCommand, resolveHerdrCommand } = require_herdr_command();
1500
1594
  var RUNTIME_VERSION = 2;
1501
1595
  function pidAlive(pid) {
1502
1596
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1802,6 +1896,7 @@ var require_service = __commonJS({
1802
1896
  health = { ok: false, message: error.message };
1803
1897
  }
1804
1898
  const socketPath = resolveSocketPath(config.herdr.socketPath);
1899
+ const herdr = findHerdrCommand();
1805
1900
  return {
1806
1901
  ok: true,
1807
1902
  mode: config.relay.mode,
@@ -1819,7 +1914,10 @@ var require_service = __commonJS({
1819
1914
  alive: pidAlive(state.hostPid),
1820
1915
  hostId: state.hostId || null,
1821
1916
  socketPath,
1822
- socketExists: Boolean(socketPath) && fs.existsSync(socketPath)
1917
+ socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
1918
+ herdrCommand: herdr.command,
1919
+ herdrCommandFound: herdr.found,
1920
+ herdrCommandSource: herdr.source
1823
1921
  },
1824
1922
  publicUrl: resolvePublicUrl2(config, lanAddress),
1825
1923
  startedAt: state.startedAt || null
@@ -1896,6 +1994,7 @@ var require_keepalive = __commonJS({
1896
1994
  var { PACKAGE_ROOT, loadConfig: loadConfig2, stateDir: stateDir2 } = require_config();
1897
1995
  var { ensureDir, readJson, writeJsonAtomic } = require_state();
1898
1996
  var { logPath, pidAlive } = require_service();
1997
+ var { findHerdrCommand } = require_herdr_command();
1899
1998
  var SYSTEMD_UNIT_NAME = "herdr-remote.service";
1900
1999
  var LAUNCHD_LABEL = "dev.herdr.remote";
1901
2000
  var FALLBACK_PID_FILE = "supervisor.pid";
@@ -1936,8 +2035,28 @@ var require_keepalive = __commonJS({
1936
2035
  }
1937
2036
  return "supervisor";
1938
2037
  }
2038
+ function servicePath({ env = process.env, herdrCommand = null } = {}) {
2039
+ const entries = String(env.PATH || "").split(path.delimiter).map((entry) => entry.trim()).filter(Boolean);
2040
+ if (herdrCommand) entries.push(path.dirname(herdrCommand));
2041
+ const seen = /* @__PURE__ */ new Set();
2042
+ const unique = entries.filter((entry) => seen.has(entry) ? false : seen.add(entry));
2043
+ return unique.join(path.delimiter);
2044
+ }
2045
+ function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
2046
+ const environment = {};
2047
+ const herdr = findHerdrCommand({ env, home, ...directories ? { directories } : {} });
2048
+ if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
2049
+ const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
2050
+ if (searchPath) environment.PATH = searchPath;
2051
+ return environment;
2052
+ }
2053
+ function systemdEnvironmentLine(key, value) {
2054
+ const text = String(value).replace(/[\r\n]+/g, " ").replace(/%/g, "%%");
2055
+ if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
2056
+ return `Environment="${key}=${text.replace(/([\\"])/g, "\\$1")}"`;
2057
+ }
1939
2058
  function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
1940
- const environmentLines = Object.entries(environment).map(([key, value]) => `Environment=${key}=${value}`).join("\n");
2059
+ const environmentLines = Object.entries(environment).map(([key, value]) => systemdEnvironmentLine(key, value)).join("\n");
1941
2060
  return `[Unit]
1942
2061
  Description=Herdr Remote (relay and host connector)
1943
2062
  Documentation=https://github.com/herdr/herdr-remote
@@ -2025,7 +2144,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2025
2144
  function systemdInstall() {
2026
2145
  const unitPath = systemdUnitPath();
2027
2146
  ensureDir(path.dirname(unitPath));
2028
- fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 420 });
2147
+ fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 420 });
2029
2148
  const reload = systemctl(["daemon-reload"]);
2030
2149
  if (reload.status !== 0) {
2031
2150
  throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || "").trim()}`);
@@ -2065,7 +2184,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2065
2184
  const plistPath = launchdPlistPath();
2066
2185
  ensureDir(path.dirname(plistPath));
2067
2186
  ensureDir(stateDir2());
2068
- fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 420 });
2187
+ fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 420 });
2069
2188
  spawnSync("launchctl", ["bootout", `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
2070
2189
  const result = spawnSync("launchctl", ["bootstrap", launchdDomainTarget(), plistPath], { encoding: "utf8" });
2071
2190
  if (result.status !== 0) {
@@ -2104,7 +2223,7 @@ ${environmentBlock} <key>StandardOutPath</key>
2104
2223
  try {
2105
2224
  const child = spawn(process.execPath, [cliEntryPoint(), "run", "--daemon"], {
2106
2225
  cwd: PACKAGE_ROOT,
2107
- env: { ...process.env, HERDR_REMOTE_SERVICE: "1" },
2226
+ env: { ...process.env, HERDR_REMOTE_SERVICE: "1", ...serviceEnvironment() },
2108
2227
  detached: true,
2109
2228
  stdio: ["ignore", logFd, logFd]
2110
2229
  });
@@ -2209,6 +2328,9 @@ ${environmentBlock} <key>StandardOutPath</key>
2209
2328
  renderSystemdUnit,
2210
2329
  renderLaunchdPlist,
2211
2330
  escapeXml,
2331
+ servicePath,
2332
+ serviceEnvironment,
2333
+ systemdEnvironmentLine,
2212
2334
  status,
2213
2335
  install,
2214
2336
  uninstall,
@@ -2275,17 +2397,18 @@ var require_herdr_plugin = __commonJS({
2275
2397
  var path = __require("node:path");
2276
2398
  var { spawnSync } = __require("node:child_process");
2277
2399
  var { PACKAGE_ROOT } = require_config();
2400
+ var { resolveHerdrCommand } = require_herdr_command();
2278
2401
  var PLUGIN_ID = "herdr.remote.web";
2279
2402
  var MANIFEST_NAME = "herdr-plugin.toml";
2280
2403
  function manifestPath() {
2281
2404
  return path.join(PACKAGE_ROOT, MANIFEST_NAME);
2282
2405
  }
2283
2406
  function herdrAvailable() {
2284
- const result = spawnSync("herdr", ["--version"], { stdio: "ignore" });
2407
+ const result = spawnSync(resolveHerdrCommand(), ["--version"], { stdio: "ignore" });
2285
2408
  return result.status === 0 || result.status === 1;
2286
2409
  }
2287
2410
  function runHerdr(args, { timeout = 15e3 } = {}) {
2288
- const result = spawnSync("herdr", args, { encoding: "utf8", timeout });
2411
+ const result = spawnSync(resolveHerdrCommand(), args, { encoding: "utf8", timeout });
2289
2412
  if (result.error && result.error.code === "ENOENT") {
2290
2413
  const error = new Error("herdr command not found");
2291
2414
  error.code = "HERDR_NOT_FOUND";
@@ -3889,7 +4012,7 @@ function About({ ctx }) {
3889
4012
  children: updateLabel
3890
4013
  }
3891
4014
  ) }),
3892
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.6" }) }),
4015
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.8" }) }),
3893
4016
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3894
4017
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3895
4018
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.6"
3
+ version = "0.2.8"
4
4
  min_herdr_version = "0.9.0"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,10 +3,157 @@
3
3
  // How a spawned Herdr is located.
4
4
  //
5
5
  // `HERDR_BIN_PATH` is what the service layer hands to the host connector, and
6
- // it is also the escape hatch for an install that is not on `PATH`.
6
+ // it is also the escape hatch for an install that is not on `PATH`. Neither is
7
+ // enough on its own once a service manager is in the picture: `systemd --user`
8
+ // and `launchd` start their units with a minimal `PATH` that contains none of
9
+ // the directories a user-level install writes to. A bare `herdr` then reaches
10
+ // `execvp(3)` inside the forked PTY child, which prints
11
+ // "execvp(3) failed.: No such file or directory" and exits — the failure the
12
+ // browser sees as an endless reconnect loop.
13
+ //
14
+ // So the name is resolved to an absolute path here, ahead of any spawn:
15
+ // an honoured override first, then `PATH`, then the handful of directories
16
+ // installers actually use.
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ const COMMAND_NAME = 'herdr';
23
+
24
+ /** Where a user-level install lands, in the order we trust it. */
25
+ const FALLBACK_DIRECTORIES = [
26
+ '~/.local/bin',
27
+ '~/.cargo/bin',
28
+ '~/bin',
29
+ '/opt/homebrew/bin',
30
+ '/usr/local/bin',
31
+ '/home/linuxbrew/.linuxbrew/bin',
32
+ '/usr/bin',
33
+ ];
34
+
35
+ function expandHome(directory, home) {
36
+ if (directory === '~') return home;
37
+ if (directory.startsWith('~/')) return path.join(home, directory.slice(2));
38
+ return directory;
39
+ }
40
+
41
+ /** Windows keeps the executable bit in the extension instead of the mode. */
42
+ function candidateNames(base = COMMAND_NAME) {
43
+ return process.platform === 'win32'
44
+ ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`]
45
+ : [base];
46
+ }
47
+
48
+ function isExecutableFile(candidate) {
49
+ try {
50
+ if (!fs.statSync(candidate).isFile()) return false;
51
+ fs.accessSync(candidate, fs.constants.X_OK);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function findInDirectory(directory, names = candidateNames()) {
59
+ if (!directory) return null;
60
+ for (const name of names) {
61
+ const candidate = path.join(directory, name);
62
+ if (isExecutableFile(candidate)) return candidate;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function findOnSearchPath(searchPath, names = candidateNames()) {
68
+ for (const entry of String(searchPath || '').split(path.delimiter)) {
69
+ const found = findInDirectory(entry.trim(), names);
70
+ if (found) return found;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function fallbackDirectories(home = os.homedir()) {
76
+ return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
77
+ }
78
+
79
+ function looksLikePath(value) {
80
+ return value.includes('/') || value.includes(path.sep);
81
+ }
82
+
83
+ /**
84
+ * Honour `HERDR_BIN_PATH`.
85
+ *
86
+ * It is documented as the executable, but a directory is an easy thing to
87
+ * write there, and a bare name — someone copying `herdr` into a unit file —
88
+ * still has to go through the search. A value that resolves to nothing is
89
+ * treated as absent rather than fatal: a stale override left behind by a
90
+ * moved install is precisely the case we are trying to survive.
91
+ */
92
+ function resolveOverride(value, searchPath) {
93
+ const trimmed = String(value || '').trim();
94
+ if (!trimmed) return null;
95
+ if (looksLikePath(trimmed)) {
96
+ const absolute = path.resolve(trimmed);
97
+ if (isExecutableFile(absolute)) return absolute;
98
+ return findInDirectory(absolute);
99
+ }
100
+ return findOnSearchPath(searchPath, candidateNames(trimmed));
101
+ }
102
+
103
+ /**
104
+ * Everything known about where Herdr is, so callers can report it and not only
105
+ * spawn it.
106
+ *
107
+ * `source` is `env` for an honoured `HERDR_BIN_PATH`, `path` for a hit on
108
+ * `PATH`, `fallback` for one of the well-known install directories, and
109
+ * `unresolved` when nothing matched. In that last case `command` is still the
110
+ * bare name: `PATH` may be right in a way we cannot see from here, and letting
111
+ * the spawn proceed keeps the old behaviour for anyone relying on it.
112
+ */
113
+ function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
114
+ const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
115
+ if (override) return { command: override, source: 'env', found: true };
116
+ const onPath = findOnSearchPath(env.PATH);
117
+ if (onPath) return { command: onPath, source: 'path', found: true };
118
+ for (const directory of directories) {
119
+ const found = findInDirectory(directory);
120
+ if (found) return { command: found, source: 'fallback', found: true };
121
+ }
122
+ return { command: COMMAND_NAME, source: 'unresolved', found: false };
123
+ }
124
+
125
+ function resolveHerdrCommand(options) {
126
+ return findHerdrCommand(options).command;
127
+ }
128
+
129
+ /**
130
+ * Re-check a command resolved earlier. A connector outlives the install that
131
+ * was missing when it started, and an absolute path outlives the binary it
132
+ * pointed at, so neither answer stays true for the life of the service.
133
+ */
134
+ function verifyHerdrCommand(command, options = {}) {
135
+ if (typeof command === 'string' && looksLikePath(command) && isExecutableFile(command)) {
136
+ return { command, source: 'verified', found: true };
137
+ }
138
+ return findHerdrCommand(options);
139
+ }
7
140
 
8
- function resolveHerdrCommand() {
9
- return process.env.HERDR_BIN_PATH || 'herdr';
141
+ /** The one message a user needs to fix a missing install themselves. */
142
+ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
143
+ const override = String(env.HERDR_BIN_PATH || '').trim();
144
+ const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
145
+ if (override) parts.push(`HERDR_BIN_PATH=${override} does not point at an executable.`);
146
+ parts.push(directories.length ? `Searched PATH and ${directories.join(', ')}.` : 'Searched PATH.');
147
+ parts.push('Install Herdr or set HERDR_BIN_PATH to its full path, then restart herdr-remote.');
148
+ return parts.join(' ');
10
149
  }
11
150
 
12
- module.exports = { resolveHerdrCommand };
151
+ module.exports = {
152
+ COMMAND_NAME,
153
+ FALLBACK_DIRECTORIES,
154
+ fallbackDirectories,
155
+ findHerdrCommand,
156
+ herdrNotFoundMessage,
157
+ resolveHerdrCommand,
158
+ verifyHerdrCommand,
159
+ };
@@ -10,6 +10,7 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { spawnSync } = require('node:child_process');
12
12
  const { PACKAGE_ROOT } = require('./config');
13
+ const { resolveHerdrCommand } = require('./herdr-command');
13
14
 
14
15
  const PLUGIN_ID = 'herdr.remote.web';
15
16
  const MANIFEST_NAME = 'herdr-plugin.toml';
@@ -18,13 +19,16 @@ function manifestPath() {
18
19
  return path.join(PACKAGE_ROOT, MANIFEST_NAME);
19
20
  }
20
21
 
22
+ // Registration also runs from `herdr plugin` actions and from the service
23
+ // manager, neither of which is guaranteed the PATH the user installed with, so
24
+ // the command is resolved rather than named.
21
25
  function herdrAvailable() {
22
- const result = spawnSync('herdr', ['--version'], { stdio: 'ignore' });
26
+ const result = spawnSync(resolveHerdrCommand(), ['--version'], { stdio: 'ignore' });
23
27
  return result.status === 0 || result.status === 1;
24
28
  }
25
29
 
26
30
  function runHerdr(args, { timeout = 15_000 } = {}) {
27
- const result = spawnSync('herdr', args, { encoding: 'utf8', timeout });
31
+ const result = spawnSync(resolveHerdrCommand(), args, { encoding: 'utf8', timeout });
28
32
  if (result.error && result.error.code === 'ENOENT') {
29
33
  const error = new Error('herdr command not found');
30
34
  error.code = 'HERDR_NOT_FOUND';
@@ -9,7 +9,7 @@ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require(
9
9
  const { ensureDir } = require('./state');
10
10
  const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
11
11
  const { PtySession } = require('./pty-session');
12
- const { resolveHerdrCommand } = require('./herdr-command');
12
+ const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
13
13
  // The wire format lives in the relay package so both ends of the protocol are
14
14
  // generated from one definition.
15
15
  const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
@@ -40,6 +40,14 @@ function pidAlive(pid) {
40
40
  }
41
41
 
42
42
  class HostConnector {
43
+ /**
44
+ * A session that dies this fast never really started. Anything longer is a
45
+ * session the user actually used and then left.
46
+ */
47
+ static FAST_FAILURE_MS = 1500;
48
+ /** How many broken starts in a row before we stop calling them exits. */
49
+ static FAST_FAILURE_LIMIT = 3;
50
+
43
51
  constructor(options = {}) {
44
52
  const config = options.config || loadConfig();
45
53
  this.config = config;
@@ -50,7 +58,10 @@ class HostConnector {
50
58
  // Optional; a relay without a password accepts any workstation.
51
59
  this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
52
60
  this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
53
- this.herdrCommand = options.herdrCommand || resolveHerdrCommand();
61
+ // Where to look for Herdr when the command has to be resolved again; the
62
+ // real environment unless a caller narrows it.
63
+ this.herdrLookup = options.herdrLookup || {};
64
+ this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
54
65
  this.herdrArgs = options.herdrArgs || config.herdr.args;
55
66
  this.cwd = options.cwd || config.herdr.cwd;
56
67
  /**
@@ -65,6 +76,7 @@ class HostConnector {
65
76
  : resolveHostPalette();
66
77
  this.ws = null;
67
78
  this.sessions = new Map();
79
+ this.fastFailures = 0;
68
80
  this.reconnectTimer = null;
69
81
  this.heartbeatTimer = null;
70
82
  this.reconnectAttempts = 0;
@@ -270,10 +282,39 @@ class HostConnector {
270
282
  else if (message.type === 'resize') this.resizeSession(message);
271
283
  }
272
284
 
285
+ /**
286
+ * Confirm Herdr is really there before the name reaches `pty.spawn`.
287
+ *
288
+ * node-pty resolves the command with `execvp(3)` inside the forked child, so
289
+ * a missing binary is not a spawn error anyone can catch: the child writes
290
+ * "execvp(3) failed.: No such file or directory" into the PTY and exits. The
291
+ * `session_exit` that follows makes the relay drop the browser's socket, the
292
+ * browser reconnects into the same broken start, and that is the reconnect
293
+ * loop of issue #1. Refusing to start keeps the socket up and puts the actual
294
+ * reason in front of the user.
295
+ *
296
+ * Re-resolved per session rather than cached from the constructor: a service
297
+ * that started before Herdr was installed should pick it up without a restart.
298
+ */
299
+ ensureHerdrCommand() {
300
+ const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
301
+ if (resolved.found) {
302
+ this.herdrCommand = resolved.command;
303
+ return null;
304
+ }
305
+ return herdrNotFoundMessage(this.herdrLookup);
306
+ }
307
+
273
308
  startSession(message) {
274
309
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
275
310
  if (!streamId) return;
276
311
  this.stopSession(streamId);
312
+ const missingHerdr = this.ensureHerdrCommand();
313
+ if (missingHerdr) {
314
+ process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
315
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
316
+ return;
317
+ }
277
318
  const socketInfo = inspectSocket(this.socketPath);
278
319
  if (!socketInfo.ok) {
279
320
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
@@ -291,6 +332,7 @@ class HostConnector {
291
332
  cols: Number(message.cols) || PtySession.DEFAULT_COLS,
292
333
  rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
293
334
  createdAt: new Date().toISOString(),
335
+ startedAtMs: Date.now(),
294
336
  clientId: streamId,
295
337
  };
296
338
  try {
@@ -304,7 +346,7 @@ class HostConnector {
304
346
  onExit: ({ exitCode }) => {
305
347
  if (this.sessions.get(streamId) !== session) return;
306
348
  this.sessions.delete(streamId);
307
- sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
349
+ this.reportSessionExit(session, exitCode);
308
350
  this.sendHeartbeat();
309
351
  },
310
352
  });
@@ -318,6 +360,32 @@ class HostConnector {
318
360
  this.sendHeartbeat();
319
361
  }
320
362
 
363
+ /**
364
+ * Tell the relay how a session ended.
365
+ *
366
+ * `session_exit` is the honest answer for a session that ran, and the relay
367
+ * closes the browser's socket on it so the window can start a fresh one. That
368
+ * is also what turns a start that keeps failing into a reconnect loop: exit,
369
+ * close, reconnect, exit. Once a few starts in a row have died immediately
370
+ * with a non-zero code the failure is systemic, not a session ending, so it is
371
+ * reported as an error — which the relay forwards without dropping the socket,
372
+ * leaving the user with a message instead of a spinner.
373
+ */
374
+ reportSessionExit(session, exitCode) {
375
+ const streamId = session.id;
376
+ const lifetimeMs = Date.now() - session.startedAtMs;
377
+ const failedFast = exitCode !== 0 && lifetimeMs < HostConnector.FAST_FAILURE_MS;
378
+ this.fastFailures = failedFast ? this.fastFailures + 1 : 0;
379
+ if (this.fastFailures < HostConnector.FAST_FAILURE_LIMIT) {
380
+ sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
381
+ return;
382
+ }
383
+ const message = `"${this.herdrCommand}" exited immediately with code ${exitCode} on ${this.fastFailures} attempts in a row. `
384
+ + 'Check that it runs from a terminal, and see the host connector log for what it printed.';
385
+ process.stderr.write(`herdr-remote host connector: ${message}\n`);
386
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message });
387
+ }
388
+
321
389
  stopSession(streamId) {
322
390
  const session = this.sessions.get(streamId);
323
391
  if (!session) return;
package/src/i18n/en.js CHANGED
@@ -154,7 +154,7 @@ module.exports = {
154
154
  'herdr.registerDone': 'Registered: {path}',
155
155
  'herdr.unregisterDone': 'Plugin unregistered.',
156
156
  'herdr.registerFailed': 'Registration failed: {message}',
157
- 'herdr.cliMissing': 'Command herdr not found in PATH.',
157
+ 'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
158
158
 
159
159
  'about.title': 'Language & about',
160
160
  'about.language': 'Interface language',
package/src/i18n/zh.js CHANGED
@@ -153,7 +153,7 @@ module.exports = {
153
153
  'herdr.registerDone': '已注册:{path}',
154
154
  'herdr.unregisterDone': '已取消注册。',
155
155
  'herdr.registerFailed': '注册失败:{message}',
156
- 'herdr.cliMissing': 'PATH 中未找到 herdr 命令。',
156
+ 'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
157
157
 
158
158
  'about.title': '语言与关于',
159
159
  'about.language': '界面语言',
package/src/keepalive.js CHANGED
@@ -15,6 +15,7 @@ const { spawn, spawnSync } = require('node:child_process');
15
15
  const { PACKAGE_ROOT, loadConfig, stateDir } = require('./config');
16
16
  const { ensureDir, readJson, writeJsonAtomic } = require('./state');
17
17
  const { logPath, pidAlive } = require('./service');
18
+ const { findHerdrCommand } = require('./herdr-command');
18
19
 
19
20
  const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
20
21
  const LAUNCHD_LABEL = 'dev.herdr.remote';
@@ -71,13 +72,65 @@ function detectManager(preference = 'auto') {
71
72
  return 'supervisor';
72
73
  }
73
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // The environment a supervised copy needs
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * `PATH` for the unit: the one that is resolving commands right now, plus the
81
+ * directory Herdr was found in.
82
+ *
83
+ * A service manager does not inherit the shell's environment. `systemd --user`
84
+ * hands a unit something close to `/usr/local/bin:/usr/bin:/bin` and `launchd`
85
+ * is no more generous, so a copy that works from a terminal loses `~/.local/bin`
86
+ * — and with it `herdr` — the moment it is installed as a service. Freezing the
87
+ * installing shell's `PATH` into the unit keeps the supervised copy able to find
88
+ * Herdr, and Herdr able to find the tools it spawns in turn.
89
+ */
90
+ function servicePath({ env = process.env, herdrCommand = null } = {}) {
91
+ const entries = String(env.PATH || '')
92
+ .split(path.delimiter)
93
+ .map((entry) => entry.trim())
94
+ .filter(Boolean);
95
+ // The override already pins the exact binary; this is only so the session
96
+ // itself can still reach it by name.
97
+ if (herdrCommand) entries.push(path.dirname(herdrCommand));
98
+ const seen = new Set();
99
+ const unique = entries.filter((entry) => (seen.has(entry) ? false : seen.add(entry)));
100
+ return unique.join(path.delimiter);
101
+ }
102
+
103
+ /**
104
+ * What has to be written into the unit file for a supervised start to behave
105
+ * like the one the user just ran by hand.
106
+ */
107
+ function serviceEnvironment({ env = process.env, home = os.homedir(), directories } = {}) {
108
+ const environment = {};
109
+ const herdr = findHerdrCommand({ env, home, ...(directories ? { directories } : {}) });
110
+ if (herdr.found) environment.HERDR_BIN_PATH = herdr.command;
111
+ const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
112
+ if (searchPath) environment.PATH = searchPath;
113
+ return environment;
114
+ }
115
+
74
116
  // ---------------------------------------------------------------------------
75
117
  // Unit file rendering (pure, so it can be asserted in tests)
76
118
  // ---------------------------------------------------------------------------
77
119
 
120
+ /**
121
+ * One `KEY=value` per directive. An unquoted systemd value ends at the first
122
+ * space, and `%` starts a specifier, so a `PATH` with either in it would be
123
+ * silently truncated or rewritten.
124
+ */
125
+ function systemdEnvironmentLine(key, value) {
126
+ const text = String(value).replace(/[\r\n]+/g, ' ').replace(/%/g, '%%');
127
+ if (/^[\w@+=:,./-]*$/.test(text)) return `Environment=${key}=${text}`;
128
+ return `Environment="${key}=${text.replace(/([\\"])/g, '\\$1')}"`;
129
+ }
130
+
78
131
  function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
79
132
  const environmentLines = Object.entries(environment)
80
- .map(([key, value]) => `Environment=${key}=${value}`)
133
+ .map(([key, value]) => systemdEnvironmentLine(key, value))
81
134
  .join('\n');
82
135
  return `[Unit]
83
136
  Description=Herdr Remote (relay and host connector)
@@ -179,7 +232,7 @@ function systemdStatus() {
179
232
  function systemdInstall() {
180
233
  const unitPath = systemdUnitPath();
181
234
  ensureDir(path.dirname(unitPath));
182
- fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 0o644 });
235
+ fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), { mode: 0o644 });
183
236
  const reload = systemctl(['daemon-reload']);
184
237
  if (reload.status !== 0) {
185
238
  throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || '').trim()}`);
@@ -227,7 +280,7 @@ function launchdInstall() {
227
280
  const plistPath = launchdPlistPath();
228
281
  ensureDir(path.dirname(plistPath));
229
282
  ensureDir(stateDir());
230
- fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 0o644 });
283
+ fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), { mode: 0o644 });
231
284
  // bootout first so a re-install picks up the rewritten plist.
232
285
  spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
233
286
  const result = spawnSync('launchctl', ['bootstrap', launchdDomainTarget(), plistPath], { encoding: 'utf8' });
@@ -275,7 +328,7 @@ function fallbackInstall() {
275
328
  try {
276
329
  const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
277
330
  cwd: PACKAGE_ROOT,
278
- env: { ...process.env, HERDR_REMOTE_SERVICE: '1' },
331
+ env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
279
332
  detached: true,
280
333
  stdio: ['ignore', logFd, logFd],
281
334
  });
@@ -394,6 +447,9 @@ module.exports = {
394
447
  renderSystemdUnit,
395
448
  renderLaunchdPlist,
396
449
  escapeXml,
450
+ servicePath,
451
+ serviceEnvironment,
452
+ systemdEnvironmentLine,
397
453
  status,
398
454
  install,
399
455
  uninstall,
package/src/service.js CHANGED
@@ -26,7 +26,7 @@ const {
26
26
  } = require('./terminal-palette');
27
27
  const { resolveSocketPath } = require('./socket-discovery');
28
28
  const { preferredLanAddress } = require('./net-interfaces');
29
- const { resolveHerdrCommand } = require('./herdr-command');
29
+ const { findHerdrCommand, resolveHerdrCommand } = require('./herdr-command');
30
30
 
31
31
  const RUNTIME_VERSION = 2;
32
32
 
@@ -415,6 +415,9 @@ async function statusServices() {
415
415
  health = { ok: false, message: error.message };
416
416
  }
417
417
  const socketPath = resolveSocketPath(config.herdr.socketPath);
418
+ // Where Herdr was found, and whether it was found at all: under a service
419
+ // manager this is the first thing to check when sessions will not start.
420
+ const herdr = findHerdrCommand();
418
421
  return {
419
422
  ok: true,
420
423
  mode: config.relay.mode,
@@ -433,6 +436,9 @@ async function statusServices() {
433
436
  hostId: state.hostId || null,
434
437
  socketPath,
435
438
  socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
439
+ herdrCommand: herdr.command,
440
+ herdrCommandFound: herdr.found,
441
+ herdrCommandSource: herdr.source,
436
442
  },
437
443
  publicUrl: resolvePublicUrl(config, lanAddress),
438
444
  startedAt: state.startedAt || null,