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 +1 -1
- package/README.md +12 -0
- package/README.zh-CN.md +10 -0
- package/dist/tui.mjs +137 -14
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/herdr-command.js +151 -4
- package/src/herdr-plugin.js +6 -2
- package/src/host-connector.js +71 -3
- package/src/i18n/en.js +1 -1
- package/src/i18n/zh.js +1 -1
- package/src/keepalive.js +60 -4
- package/src/service.js +7 -1
package/LICENSE
CHANGED
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
|
|
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": "
|
|
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
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
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]) =>
|
|
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(
|
|
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(
|
|
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.
|
|
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
package/package.json
CHANGED
package/src/herdr-command.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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 = {
|
|
151
|
+
module.exports = {
|
|
152
|
+
COMMAND_NAME,
|
|
153
|
+
FALLBACK_DIRECTORIES,
|
|
154
|
+
fallbackDirectories,
|
|
155
|
+
findHerdrCommand,
|
|
156
|
+
herdrNotFoundMessage,
|
|
157
|
+
resolveHerdrCommand,
|
|
158
|
+
verifyHerdrCommand,
|
|
159
|
+
};
|
package/src/herdr-plugin.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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';
|
package/src/host-connector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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': '
|
|
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]) =>
|
|
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,
|