herdr-remote 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/bin/herdr-remote.js +3 -225
  2. package/dist/agent-status.js +130 -0
  3. package/dist/binary-plist.js +157 -0
  4. package/dist/cli.js +255 -0
  5. package/dist/config.js +270 -0
  6. package/dist/connector/agent-watch.js +110 -0
  7. package/dist/connector/font-server.js +165 -0
  8. package/dist/connector/host-connector.js +430 -0
  9. package/dist/connector/lock.js +66 -0
  10. package/dist/connector/main.js +18 -0
  11. package/dist/connector/sessions.js +366 -0
  12. package/dist/connector/update-report.js +67 -0
  13. package/dist/connector/wire.js +42 -0
  14. package/{src → dist}/exit-codes.js +1 -4
  15. package/dist/font-subset.js +110 -0
  16. package/dist/herdr-api.js +246 -0
  17. package/dist/herdr-command.js +223 -0
  18. package/dist/herdr-plugin.js +116 -0
  19. package/dist/herdr-server.js +189 -0
  20. package/dist/i18n/en.js +223 -0
  21. package/dist/i18n/index.js +48 -0
  22. package/dist/i18n/zh.js +222 -0
  23. package/dist/keepalive/detached.js +78 -0
  24. package/dist/keepalive/environment.js +44 -0
  25. package/dist/keepalive/index.js +110 -0
  26. package/dist/keepalive/launchd.js +117 -0
  27. package/dist/keepalive/systemd.js +108 -0
  28. package/dist/keepalive/types.js +1 -0
  29. package/dist/lib/process.js +12 -0
  30. package/dist/lifecycle.js +59 -0
  31. package/dist/net-interfaces.js +73 -0
  32. package/dist/pasted-files.js +115 -0
  33. package/dist/paths.js +62 -0
  34. package/dist/pty-session.js +96 -0
  35. package/dist/relay-client.js +106 -0
  36. package/dist/relay-urls.js +99 -0
  37. package/dist/runtime.js +106 -0
  38. package/dist/service.js +260 -0
  39. package/dist/settings-model.js +298 -0
  40. package/dist/socket-discovery.js +48 -0
  41. package/dist/supervisor.js +319 -0
  42. package/dist/terminal-font/deps.js +73 -0
  43. package/dist/terminal-font/files.js +354 -0
  44. package/dist/terminal-font/formats.js +335 -0
  45. package/dist/terminal-font/index.js +187 -0
  46. package/dist/terminal-font/readers.js +316 -0
  47. package/dist/terminal-font/terminals.js +128 -0
  48. package/dist/terminal-palette.js +339 -0
  49. package/dist/tui/App.js +195 -0
  50. package/dist/tui/api.js +79 -0
  51. package/dist/tui/components/TextField.js +50 -0
  52. package/dist/tui/components/common.js +80 -0
  53. package/dist/tui/index.js +36 -0
  54. package/dist/tui/mouse/index.js +164 -0
  55. package/dist/tui/mouse/protocol.js +96 -0
  56. package/dist/tui/mouse/source.js +74 -0
  57. package/dist/tui/screens/About.js +166 -0
  58. package/dist/tui/screens/Herdr.js +74 -0
  59. package/dist/tui/screens/Keepalive.js +84 -0
  60. package/dist/tui/screens/Overview.js +36 -0
  61. package/dist/tui/screens/Pair.js +74 -0
  62. package/dist/tui/screens/Relay.js +242 -0
  63. package/dist/tui/screens/Services.js +63 -0
  64. package/dist/tui/screens/Wizard.js +235 -0
  65. package/dist/tui/terminal.js +39 -0
  66. package/dist/tui/theme.js +33 -0
  67. package/dist/updater.js +382 -0
  68. package/herdr-plugin.toml +1 -1
  69. package/package.json +8 -8
  70. package/dist/tui.mjs +0 -5775
  71. package/src/agent-status.js +0 -139
  72. package/src/binary-plist.js +0 -146
  73. package/src/config.js +0 -454
  74. package/src/font-subset.js +0 -110
  75. package/src/herdr-api.js +0 -244
  76. package/src/herdr-command.js +0 -231
  77. package/src/herdr-plugin.js +0 -130
  78. package/src/herdr-server.js +0 -201
  79. package/src/host-connector.js +0 -1069
  80. package/src/i18n/en.js +0 -240
  81. package/src/i18n/index.js +0 -56
  82. package/src/i18n/zh.js +0 -239
  83. package/src/keepalive.js +0 -460
  84. package/src/lifecycle.js +0 -62
  85. package/src/net-interfaces.js +0 -76
  86. package/src/pasted-files.js +0 -176
  87. package/src/pty-session.js +0 -92
  88. package/src/service.js +0 -530
  89. package/src/settings-model.js +0 -323
  90. package/src/socket-discovery.js +0 -60
  91. package/src/state.js +0 -47
  92. package/src/supervisor.js +0 -250
  93. package/src/terminal-font.js +0 -1204
  94. package/src/terminal-palette.js +0 -325
  95. package/src/updater.js +0 -394
@@ -0,0 +1,108 @@
1
+ // systemd --user: a unit that runs `herdr-remote run` and restarts it.
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { ensureDir } from 'herdr-remote-relay/state';
7
+ import { cliEntryPoint, serviceEnvironment } from './environment.js';
8
+ const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
9
+ function systemdUnitPath() {
10
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
11
+ return path.join(configHome, 'systemd', 'user', SYSTEMD_UNIT_NAME);
12
+ }
13
+ /**
14
+ * One `KEY=value` per directive. An unquoted systemd value ends at the first
15
+ * space, and `%` starts a specifier, so a `PATH` with either in it would be
16
+ * silently truncated or rewritten.
17
+ */
18
+ export function systemdEnvironmentLine(key, value) {
19
+ const text = String(value)
20
+ .replace(/[\r\n]+/g, ' ')
21
+ .replace(/%/g, '%%');
22
+ if (/^[\w@+=:,./-]*$/.test(text))
23
+ return `Environment=${key}=${text}`;
24
+ return `Environment="${key}=${text.replace(/([\\"])/g, '\\$1')}"`;
25
+ }
26
+ export function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {}, } = {}) {
27
+ const environmentLines = Object.entries(environment)
28
+ .map(([key, value]) => systemdEnvironmentLine(key, value))
29
+ .join('\n');
30
+ return `[Unit]
31
+ Description=Herdr Remote (relay and host connector)
32
+ Documentation=https://github.com/dibin666/herdr-remote
33
+ After=default.target
34
+
35
+ [Service]
36
+ Type=simple
37
+ ExecStart=${nodePath} ${entryPoint} run
38
+ Restart=always
39
+ RestartSec=3
40
+ # Give the relay and host connector time to close sessions cleanly.
41
+ TimeoutStopSec=15
42
+ ${environmentLines}
43
+
44
+ [Install]
45
+ WantedBy=default.target
46
+ `;
47
+ }
48
+ function systemctl(args) {
49
+ return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: 'pipe' });
50
+ }
51
+ /** Run a systemctl command that must succeed, throwing its stderr when it does not. */
52
+ function systemctlOrThrow(args, failure) {
53
+ const result = systemctl(args);
54
+ if (result.status !== 0) {
55
+ throw new Error(`${failure}: ${String(result.stderr || '').trim()}`);
56
+ }
57
+ }
58
+ export const systemd = {
59
+ name: 'systemd',
60
+ status() {
61
+ const installed = fs.existsSync(systemdUnitPath());
62
+ if (!installed)
63
+ return { manager: 'systemd', installed: false, active: false, enabled: false };
64
+ const active = systemctl(['is-active', SYSTEMD_UNIT_NAME]);
65
+ const enabled = systemctl(['is-enabled', SYSTEMD_UNIT_NAME]);
66
+ const lingering = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8' });
67
+ return {
68
+ manager: 'systemd',
69
+ installed: true,
70
+ active: String(active.stdout || '').trim() === 'active',
71
+ enabled: String(enabled.stdout || '')
72
+ .trim()
73
+ .startsWith('enabled'),
74
+ linger: String(lingering.stdout || '').includes('Linger=yes'),
75
+ unitPath: systemdUnitPath(),
76
+ state: String(active.stdout || active.stderr || '').trim(),
77
+ };
78
+ },
79
+ install() {
80
+ const unitPath = systemdUnitPath();
81
+ ensureDir(path.dirname(unitPath));
82
+ fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), {
83
+ mode: 0o644,
84
+ });
85
+ systemctlOrThrow(['daemon-reload'], 'systemctl --user daemon-reload failed');
86
+ systemctlOrThrow(['enable', '--now', SYSTEMD_UNIT_NAME], 'systemctl --user enable --now failed');
87
+ return { ok: true, unitPath, hint: `loginctl enable-linger ${os.userInfo().username}` };
88
+ },
89
+ uninstall() {
90
+ const unitPath = systemdUnitPath();
91
+ systemctl(['disable', '--now', SYSTEMD_UNIT_NAME]);
92
+ fs.rmSync(unitPath, { force: true });
93
+ systemctl(['daemon-reload']);
94
+ return { ok: true, unitPath };
95
+ },
96
+ restart() {
97
+ const result = systemctl(['restart', SYSTEMD_UNIT_NAME]);
98
+ if (result.status !== 0)
99
+ throw new Error(String(result.stderr || '').trim() || 'systemctl restart failed');
100
+ return { ok: true };
101
+ },
102
+ stop() {
103
+ systemctl(['stop', SYSTEMD_UNIT_NAME]);
104
+ },
105
+ logsHint() {
106
+ return `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`;
107
+ },
108
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ /** Whether `pid` names a running process, including one owned by another user. */
2
+ export function pidAlive(pid) {
3
+ if (!Number.isInteger(pid) || pid <= 0)
4
+ return false;
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch (error) {
10
+ return error.code === 'EPERM';
11
+ }
12
+ }
@@ -0,0 +1,59 @@
1
+ // Start/stop/restart that respect the keep-alive manager.
2
+ //
3
+ // When systemd or launchd supervises the services, killing the processes just
4
+ // makes the manager start them again — and starting them by hand produces a
5
+ // second, unmanaged copy fighting for the port. Every entry point goes through
6
+ // here so the managed and unmanaged paths behave the same from the outside.
7
+ import { loadConfig } from './config.js';
8
+ import * as keepalive from './keepalive/index.js';
9
+ import { restartServices, startServices, statusServices, stopServices } from './service.js';
10
+ function managerInUse(config) {
11
+ const status = keepalive.status(config);
12
+ return status.installed || status.active ? status : null;
13
+ }
14
+ function startAll(config = loadConfig()) {
15
+ const managed = managerInUse(config);
16
+ if (managed) {
17
+ // Start means "make it run", not "restart it". Herdr runs this through the
18
+ // plugin's startup hook every time a Herdr server starts, and a restart
19
+ // there dropped every browser attached to the workstation — including the
20
+ // one that had just asked for that Herdr to be started.
21
+ if (!managed.active)
22
+ keepalive.restart(config);
23
+ return {
24
+ ok: true,
25
+ managed: true,
26
+ manager: managed.manager,
27
+ alreadyRunning: Boolean(managed.active),
28
+ };
29
+ }
30
+ return { ...startServices(), managed: false };
31
+ }
32
+ function stopAll(config = loadConfig()) {
33
+ const result = keepalive.stopManaged(config);
34
+ if (result.managed) {
35
+ // The manager stops the supervisor, which stops its children; clear the
36
+ // recorded pids so status does not report ghosts.
37
+ stopServices();
38
+ return { ok: true, managed: true, manager: result.manager };
39
+ }
40
+ return { ...stopServices(), managed: false };
41
+ }
42
+ function restartAll(config = loadConfig()) {
43
+ const managed = managerInUse(config);
44
+ if (managed) {
45
+ keepalive.restart(config);
46
+ return { ok: true, managed: true, manager: managed.manager };
47
+ }
48
+ return { ...restartServices(), managed: false };
49
+ }
50
+ /** Service status plus the keep-alive picture, which is what the TUI shows. */
51
+ async function fullStatus(config = loadConfig()) {
52
+ const [services, keepaliveStatus] = [await statusServices(), keepalive.status(config)];
53
+ return {
54
+ ...services,
55
+ keepalive: keepaliveStatus,
56
+ logsHint: keepalive.logsHint(config),
57
+ };
58
+ }
59
+ export { startAll, stopAll, restartAll, fullStatus, managerInUse };
@@ -0,0 +1,73 @@
1
+ import os from 'node:os';
2
+ // Interface names that are almost always virtual bridges rather than something
3
+ // a phone can reach. They are still listed, just ranked last.
4
+ const VIRTUAL_NAME_PATTERN = /^(docker|br-|virbr|veth|vmnet|vboxnet|lxcbr|cni|flannel|kube)/i;
5
+ const TAILSCALE_NAME_PATTERN = /^(tailscale|ts)\d*$/i;
6
+ /**
7
+ * Is this address inside Tailscale's 100.64.0.0/10 CGNAT range? Interface names
8
+ * differ per platform (tailscale0 on Linux, utunN on macOS), so the address
9
+ * range is the portable signal.
10
+ */
11
+ function isTailscaleAddress(address) {
12
+ const octets = String(address).split('.');
13
+ if (octets.length !== 4)
14
+ return false;
15
+ const first = Number(octets[0]);
16
+ const second = Number(octets[1]);
17
+ if (!Number.isInteger(first) || !Number.isInteger(second))
18
+ return false;
19
+ return first === 100 && second >= 64 && second <= 127;
20
+ }
21
+ function classify(name, info) {
22
+ if (info.internal)
23
+ return 'loopback';
24
+ if (TAILSCALE_NAME_PATTERN.test(name) || isTailscaleAddress(info.address))
25
+ return 'tailscale';
26
+ if (VIRTUAL_NAME_PATTERN.test(name))
27
+ return 'virtual';
28
+ return 'lan';
29
+ }
30
+ const KIND_ORDER = { tailscale: 0, lan: 1, virtual: 2, loopback: 3 };
31
+ /**
32
+ * Addresses this machine can be reached at, best candidate first.
33
+ *
34
+ * Tailscale addresses come first because a device on the tailnet reaches them
35
+ * from anywhere, which is exactly the "works away from home without running a
36
+ * relay" case; plain LAN addresses follow, then virtual bridges, then loopback.
37
+ */
38
+ function listReachableAddresses({ includeLoopback = true, includeIpv6 = false, } = {}) {
39
+ const interfaces = os.networkInterfaces();
40
+ const results = [];
41
+ for (const [name, entries] of Object.entries(interfaces)) {
42
+ for (const info of entries || []) {
43
+ // Older Node releases reported the family as a number.
44
+ const rawFamily = info.family;
45
+ const family = typeof rawFamily === 'string' ? rawFamily : `IPv${rawFamily}`;
46
+ if (family !== 'IPv4' && !(includeIpv6 && family === 'IPv6'))
47
+ continue;
48
+ const kind = classify(name, info);
49
+ if (kind === 'loopback' && !includeLoopback)
50
+ continue;
51
+ results.push({
52
+ name,
53
+ address: info.address,
54
+ family,
55
+ kind,
56
+ internal: Boolean(info.internal),
57
+ });
58
+ }
59
+ }
60
+ results.sort((a, b) => {
61
+ const byKind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
62
+ if (byKind !== 0)
63
+ return byKind;
64
+ return a.address.localeCompare(b.address);
65
+ });
66
+ return results;
67
+ }
68
+ /** Best guess for the address to advertise when binding to every interface. */
69
+ function preferredLanAddress() {
70
+ const candidates = listReachableAddresses({ includeLoopback: false });
71
+ return candidates.length > 0 ? candidates[0].address : null;
72
+ }
73
+ export { isTailscaleAddress, listReachableAddresses, preferredLanAddress };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Secure file storage and validation for pasted clipboard images.
3
+ *
4
+ * Security requirements:
5
+ * 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
6
+ * 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
7
+ * 3. Whitelisted extension: derived exclusively from verified MIME type.
8
+ * 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
9
+ * 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
10
+ * 6. File mode: files are created with mode 0o600.
11
+ * 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { stateDir } from './paths.js';
17
+ import { ensureDir } from 'herdr-remote-relay/state';
18
+ import { PASTE_MAX_BYTES, PASTE_IMAGE_EXTENSIONS, isPasteImageMime, hasImageSignature, } from 'herdr-remote-relay/protocol';
19
+ const MAX_SAVED_FILES = 20;
20
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
21
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
22
+ function getPastedDir() {
23
+ const dir = path.join(stateDir(), 'pasted');
24
+ ensureDir(dir);
25
+ return dir;
26
+ }
27
+ /**
28
+ * Prunes the pasted directory:
29
+ * - Removes files older than maxAgeMs (default 24 hours).
30
+ * - Caps total file count (default 20) and total disk footprint (default 50 MB),
31
+ * deleting oldest files first.
32
+ */
33
+ function cleanPastedDir({ dir = getPastedDir(), maxFiles = MAX_SAVED_FILES, maxBytes = MAX_TOTAL_BYTES, maxAgeMs = MAX_AGE_MS, now = Date.now(), } = {}) {
34
+ let entries;
35
+ try {
36
+ entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ const fileInfos = [];
42
+ for (const entry of entries) {
43
+ if (!entry.isFile())
44
+ continue;
45
+ const fullPath = path.join(dir, entry.name);
46
+ try {
47
+ const stat = fs.statSync(fullPath);
48
+ if (now - stat.mtimeMs > maxAgeMs) {
49
+ try {
50
+ fs.unlinkSync(fullPath);
51
+ }
52
+ catch {
53
+ // Removed by another process meanwhile, or not ours to remove: skip it.
54
+ }
55
+ continue;
56
+ }
57
+ fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
58
+ }
59
+ catch {
60
+ // The file vanished between listing and stat; nothing to account for.
61
+ }
62
+ }
63
+ // Sort oldest first
64
+ fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
65
+ let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
66
+ while (fileInfos.length > maxFiles || totalSize > maxBytes) {
67
+ const oldest = fileInfos.shift();
68
+ if (!oldest)
69
+ break;
70
+ try {
71
+ fs.unlinkSync(oldest.path);
72
+ totalSize -= oldest.size;
73
+ }
74
+ catch {
75
+ // Pruning is best effort; the next save prunes again.
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
81
+ * writes to state storage with mode 0o600, and returns the absolute local path.
82
+ */
83
+ function savePastedFile({ mime, dataBase64, dir = getPastedDir(), }) {
84
+ if (!isPasteImageMime(mime)) {
85
+ throw new Error(`unsupported MIME type: ${mime}`);
86
+ }
87
+ if (typeof dataBase64 !== 'string') {
88
+ throw new Error('missing or invalid dataBase64 payload');
89
+ }
90
+ const buf = Buffer.from(dataBase64, 'base64');
91
+ if (buf.length > PASTE_MAX_BYTES) {
92
+ throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
93
+ }
94
+ if (buf.length === 0) {
95
+ throw new Error('file payload is empty');
96
+ }
97
+ if (!hasImageSignature(mime, buf)) {
98
+ throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
99
+ }
100
+ // Prune before writing new file
101
+ cleanPastedDir({ dir });
102
+ const fileName = `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[mime]}`;
103
+ const filePath = path.join(dir, fileName);
104
+ fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
105
+ try {
106
+ fs.chmodSync(filePath, 0o600);
107
+ }
108
+ catch {
109
+ // The file was created 0600 already; chmod only guards against a lax umask.
110
+ }
111
+ // Prune again after writing to strictly observe count/size ceiling
112
+ cleanPastedDir({ dir });
113
+ return filePath;
114
+ }
115
+ export { cleanPastedDir, savePastedFile };
package/dist/paths.js ADDED
@@ -0,0 +1,62 @@
1
+ // Where herdr-remote keeps its files: the package itself, the configuration
2
+ // directory and the state directory.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ /**
7
+ * Locate the package directory by walking up to our own package.json.
8
+ *
9
+ * This module runs from src/ under the tests and from dist/ once built.
10
+ * Everything that matters — the plugin manifest, the host connector entry
11
+ * point, the systemd unit's ExecStart — is resolved from this value, so it has
12
+ * to be independent of where the code happens to be loaded from.
13
+ */
14
+ function findPackageRoot(start) {
15
+ let directory = start;
16
+ for (let depth = 0; depth < 8; depth += 1) {
17
+ const candidate = path.join(directory, 'package.json');
18
+ try {
19
+ if (JSON.parse(fs.readFileSync(candidate, 'utf8')).name === 'herdr-remote')
20
+ return directory;
21
+ }
22
+ catch {
23
+ // Keep climbing: a missing or unrelated package.json is expected.
24
+ }
25
+ const parent = path.dirname(directory);
26
+ if (parent === directory)
27
+ break;
28
+ directory = parent;
29
+ }
30
+ return path.resolve(start, '..');
31
+ }
32
+ const PACKAGE_ROOT = findPackageRoot(import.meta.dirname);
33
+ /**
34
+ * The canonical configuration directory.
35
+ *
36
+ * Deliberately NOT $HERDR_PLUGIN_CONFIG_DIR: Herdr sets that variable only when
37
+ * it launches the plugin itself, so honouring it would give "herdr-remote" run
38
+ * from a shell and the same tool run from a Herdr pane two different config
39
+ * files. One path, one config; `migrateLegacyConfig()` imports the old one.
40
+ */
41
+ function configDir() {
42
+ return process.env.HERDR_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.config', 'herdr-remote');
43
+ }
44
+ function stateDir() {
45
+ return (process.env.HERDR_REMOTE_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'herdr-remote'));
46
+ }
47
+ function configPath() {
48
+ return path.join(configDir(), 'config.json');
49
+ }
50
+ function runtimeStatePath() {
51
+ return path.join(stateDir(), 'runtime.json');
52
+ }
53
+ /** Where a service (relay, host, supervisor) writes its output. */
54
+ function logPath(name) {
55
+ return path.join(stateDir(), `${name}.log`);
56
+ }
57
+ function legacyConfigPath() {
58
+ return process.env.HERDR_PLUGIN_CONFIG_DIR
59
+ ? path.join(process.env.HERDR_PLUGIN_CONFIG_DIR, 'config.json')
60
+ : null;
61
+ }
62
+ export { PACKAGE_ROOT, configDir, stateDir, configPath, runtimeStatePath, logPath, legacyConfigPath, };
@@ -0,0 +1,96 @@
1
+ import os from 'node:os';
2
+ import pty from 'node-pty';
3
+ class PtySession {
4
+ static DEFAULT_COLS = 100;
5
+ static DEFAULT_ROWS = 30;
6
+ static MIN_DIMENSION = 2;
7
+ static MAX_DIMENSION = 500;
8
+ command;
9
+ args;
10
+ cwd;
11
+ socketPath;
12
+ terminal;
13
+ startedAt;
14
+ constructor({ command, args = [], cwd = os.homedir(), socketPath } = {}) {
15
+ if (typeof command !== 'string' || command.length === 0)
16
+ throw new TypeError('command must be a non-empty string');
17
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
18
+ throw new TypeError('args must be strings');
19
+ this.command = command;
20
+ this.args = args;
21
+ this.cwd = cwd;
22
+ this.socketPath = socketPath;
23
+ this.terminal = null;
24
+ this.startedAt = null;
25
+ }
26
+ static childEnv(socketPath) {
27
+ const env = { ...process.env };
28
+ for (const key of Object.keys(env)) {
29
+ if (key.startsWith('HERDR_'))
30
+ delete env[key];
31
+ }
32
+ if (socketPath)
33
+ env.HERDR_SOCKET_PATH = socketPath;
34
+ env.TERM = 'xterm-256color';
35
+ env.COLORTERM = 'truecolor';
36
+ return env;
37
+ }
38
+ static clampDimension(value, fallback) {
39
+ const numeric = Number(value);
40
+ if (!Number.isInteger(numeric))
41
+ return fallback;
42
+ return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
43
+ }
44
+ start({ cols, rows, onData, onExit }) {
45
+ if (this.terminal)
46
+ throw new Error('PTY session already started');
47
+ this.terminal = pty.spawn(this.command, this.args, {
48
+ name: 'xterm-256color',
49
+ cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
50
+ rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
51
+ cwd: this.cwd,
52
+ env: PtySession.childEnv(this.socketPath),
53
+ });
54
+ this.startedAt = new Date().toISOString();
55
+ const terminal = this.terminal;
56
+ if (onData)
57
+ terminal.onData(onData);
58
+ if (onExit) {
59
+ terminal.onExit((event) => {
60
+ this.terminal = null;
61
+ onExit(event);
62
+ });
63
+ }
64
+ return this;
65
+ }
66
+ write(data) {
67
+ if (!this.terminal)
68
+ return;
69
+ this.terminal.write(Buffer.isBuffer(data) ? data.toString('utf8') : String(data));
70
+ }
71
+ resize(cols, rows) {
72
+ if (!this.terminal)
73
+ return;
74
+ this.terminal.resize(PtySession.clampDimension(cols, PtySession.DEFAULT_COLS), PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS));
75
+ }
76
+ info() {
77
+ return {
78
+ pid: this.terminal?.pid || null,
79
+ command: this.command,
80
+ cols: this.terminal?.cols || null,
81
+ rows: this.terminal?.rows || null,
82
+ cwd: this.cwd,
83
+ createdAt: this.startedAt,
84
+ };
85
+ }
86
+ kill() {
87
+ try {
88
+ this.terminal?.kill();
89
+ }
90
+ catch {
91
+ // The process has already exited.
92
+ }
93
+ this.terminal = null;
94
+ }
95
+ }
96
+ export { PtySession };
@@ -0,0 +1,106 @@
1
+ // Talking to a relay's HTTP API from this workstation: health, status, pairing.
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import { resolveAdminOrigin } from './relay-urls.js';
5
+ import { ensureRuntime } from './runtime.js';
6
+ /**
7
+ * Minimal JSON client. Picks http or https from the URL: a self-hosted relay is
8
+ * reached over https, while the local relay is plain http on loopback.
9
+ */
10
+ export function requestJson(urlString, options = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ let url;
13
+ try {
14
+ url = new URL(urlString);
15
+ }
16
+ catch (error) {
17
+ reject(error);
18
+ return;
19
+ }
20
+ const transport = url.protocol === 'https:' ? https : http;
21
+ const request = transport.request(url, {
22
+ method: options.method || 'GET',
23
+ headers: options.headers || {},
24
+ timeout: options.timeout || 1500,
25
+ }, (response) => {
26
+ const chunks = [];
27
+ response.on('data', (chunk) => chunks.push(chunk));
28
+ response.on('end', () => {
29
+ const text = Buffer.concat(chunks).toString('utf8');
30
+ let body;
31
+ try {
32
+ body = JSON.parse(text);
33
+ }
34
+ catch {
35
+ body = { raw: text };
36
+ }
37
+ const status = response.statusCode || 0;
38
+ if (status >= 400) {
39
+ const error = new Error(typeof body.message === 'string' ? body.message : `HTTP ${status}`);
40
+ error.statusCode = status;
41
+ error.body = body;
42
+ reject(error);
43
+ }
44
+ else
45
+ resolve(body);
46
+ });
47
+ });
48
+ request.on('timeout', () => request.destroy(new Error('request timed out')));
49
+ request.on('error', reject);
50
+ if (options.body)
51
+ request.write(options.body);
52
+ request.end();
53
+ });
54
+ }
55
+ export function healthUrl(config) {
56
+ return `${resolveAdminOrigin(config)}/healthz`;
57
+ }
58
+ /** This workstation's own credentials, which scope a relay's answers to it. */
59
+ export function hostHeaders() {
60
+ const state = ensureRuntime();
61
+ return { 'X-Herdr-Host-Id': state.hostId, 'X-Herdr-Host-Token': state.hostToken };
62
+ }
63
+ export async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
64
+ let lastError;
65
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
66
+ try {
67
+ return await requestJson(healthUrl(config), { timeout: 800 });
68
+ }
69
+ catch (error) {
70
+ lastError = error;
71
+ }
72
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
73
+ }
74
+ throw lastError || new Error('relay did not become ready');
75
+ }
76
+ export async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
77
+ const headers = hostHeaders();
78
+ const hostId = headers['X-Herdr-Host-Id'];
79
+ const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
80
+ let lastStatus = null;
81
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
82
+ try {
83
+ lastStatus = await requestJson(statusEndpoint, {
84
+ timeout: 800,
85
+ headers,
86
+ });
87
+ if (lastStatus.hosts?.some((host) => host.id === hostId))
88
+ return lastStatus;
89
+ }
90
+ catch (error) {
91
+ lastStatus = { ok: false, message: error.message };
92
+ }
93
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
94
+ }
95
+ const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
96
+ error.health = lastStatus;
97
+ throw error;
98
+ }
99
+ export function extractPairingCode(response) {
100
+ const answer = response;
101
+ const code = answer?.code || answer?.pairCode;
102
+ if (typeof code !== 'string' || code.length === 0) {
103
+ throw new Error('the relay response did not contain a valid pairing code');
104
+ }
105
+ return code;
106
+ }