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,99 @@
1
+ // The addresses a configuration implies: where the local relay binds, the URL
2
+ // a browser opens, and the relay URLs the host connector and the CLI dial.
3
+ import { WS_HOST_PATH } from 'herdr-remote-relay/protocol';
4
+ function isLoopbackHost(value) {
5
+ return value === '127.0.0.1' || value === 'localhost' || value === '::1' || value === '[::1]';
6
+ }
7
+ function isUnspecifiedAddress(value) {
8
+ const address = typeof value === 'string' ? value.trim().toLowerCase() : '';
9
+ return address === '0.0.0.0' || address === '::' || address === '[::]';
10
+ }
11
+ /** An unspecified host is valid for a server bind, but not for a browser URL. */
12
+ function isUnspecifiedHost(value) {
13
+ try {
14
+ const hostname = new URL(value).hostname.toLowerCase();
15
+ return hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]';
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function normalizeUrl(value) {
22
+ return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
23
+ }
24
+ /** Whether this machine runs its own relay process. */
25
+ function runsLocalRelay(config) {
26
+ return config.relay.mode !== 'remote';
27
+ }
28
+ /** Address the local relay binds to for the configured mode. */
29
+ function bindAddress(config) {
30
+ return config.relay.mode === 'lan' ? '0.0.0.0' : '127.0.0.1';
31
+ }
32
+ /**
33
+ * Host name to put into pairing URLs. For "lan" this is the interface address a
34
+ * phone can actually reach; `lanHost` is the user's pick from the TUI, and the
35
+ * fallback keeps things working if that interface disappeared.
36
+ */
37
+ function advertisedHost(config, fallbackLanHost = null) {
38
+ if (config.relay.mode === 'lan') {
39
+ const configured = config.relay.lanHost;
40
+ if (configured && !isLoopbackHost(configured) && !isUnspecifiedAddress(configured))
41
+ return configured;
42
+ if (fallbackLanHost &&
43
+ !isLoopbackHost(fallbackLanHost) &&
44
+ !isUnspecifiedAddress(fallbackLanHost)) {
45
+ return fallbackLanHost;
46
+ }
47
+ return '127.0.0.1';
48
+ }
49
+ return '127.0.0.1';
50
+ }
51
+ /** The URL a browser opens. */
52
+ function resolvePublicUrl(config, fallbackLanHost = null) {
53
+ if (config.relay.publicUrl)
54
+ return config.relay.publicUrl;
55
+ if (config.relay.mode === 'remote')
56
+ return httpOrigin(config.relay.remoteUrl);
57
+ return `http://${advertisedHost(config, fallbackLanHost)}:${config.relay.port}`;
58
+ }
59
+ /** Base HTTP origin used for relay admin calls (pairing, health). */
60
+ function resolveAdminOrigin(config) {
61
+ if (config.relay.mode === 'remote')
62
+ return httpOrigin(config.relay.remoteUrl);
63
+ return `http://127.0.0.1:${config.relay.port}`;
64
+ }
65
+ /** WebSocket URL the host connector dials. */
66
+ function resolveHostRelayUrl(config) {
67
+ const base = config.relay.mode === 'remote' ? config.relay.remoteUrl : `ws://127.0.0.1:${config.relay.port}`;
68
+ return hostWebSocketUrl(base);
69
+ }
70
+ function httpOrigin(value) {
71
+ try {
72
+ const url = new URL(value);
73
+ if (url.protocol === 'ws:')
74
+ url.protocol = 'http:';
75
+ if (url.protocol === 'wss:')
76
+ url.protocol = 'https:';
77
+ const pathname = url.pathname.replace(/\/+$/, '');
78
+ return `${url.origin}${pathname}`;
79
+ }
80
+ catch {
81
+ return normalizeUrl(value);
82
+ }
83
+ }
84
+ function hostWebSocketUrl(base) {
85
+ const url = new URL(base);
86
+ if (url.protocol === 'http:')
87
+ url.protocol = 'ws:';
88
+ if (url.protocol === 'https:')
89
+ url.protocol = 'wss:';
90
+ const pathname = url.pathname.replace(/\/+$/, '');
91
+ if (!pathname || pathname === '/') {
92
+ url.pathname = WS_HOST_PATH;
93
+ }
94
+ else if (!pathname.endsWith(WS_HOST_PATH)) {
95
+ url.pathname = `${pathname}${WS_HOST_PATH}`;
96
+ }
97
+ return url.toString();
98
+ }
99
+ export { isLoopbackHost, isUnspecifiedAddress, isUnspecifiedHost, normalizeUrl, runsLocalRelay, bindAddress, advertisedHost, resolvePublicUrl, resolveAdminOrigin, resolveHostRelayUrl, hostWebSocketUrl, };
@@ -0,0 +1,106 @@
1
+ // runtime.json: this workstation's relay credentials, the processes started
2
+ // for it, and facts other modules remember there (terminal palette and font).
3
+ // Every read and write of that file goes through here.
4
+ import { ensureDir, randomToken, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
5
+ import { pidAlive } from './lib/process.js';
6
+ import { configDir, runtimeStatePath, stateDir } from './paths.js';
7
+ const RUNTIME_VERSION = 2;
8
+ export function readRuntime() {
9
+ const state = readJson(runtimeStatePath(), {});
10
+ return state && typeof state === 'object' ? state : {};
11
+ }
12
+ export function writeRuntime(state) {
13
+ writeJsonAtomic(runtimeStatePath(), state);
14
+ }
15
+ /** Read, change and write back in one step, so no field another writer set is lost. */
16
+ export function updateRuntime(change) {
17
+ const state = readRuntime();
18
+ change(state);
19
+ writeRuntime(state);
20
+ return state;
21
+ }
22
+ /**
23
+ * Load (creating if needed) the long-lived identifiers and secrets this
24
+ * workstation uses. These live in the state directory rather than config.json
25
+ * because they are credentials: writeJsonAtomic stores them mode 0600.
26
+ */
27
+ export function ensureRuntime() {
28
+ ensureDir(configDir());
29
+ ensureDir(stateDir());
30
+ return updateRuntime((state) => {
31
+ if (!state.version)
32
+ state.version = RUNTIME_VERSION;
33
+ if (!state.hostId)
34
+ state.hostId = `host-${randomToken(9)}`;
35
+ if (!state.hostToken)
36
+ state.hostToken = randomToken(32);
37
+ });
38
+ }
39
+ /**
40
+ * Set the password used to join a relay. An empty value means "no password",
41
+ * which is what a public relay expects.
42
+ *
43
+ * It lives in the state file rather than config.json because it is a secret:
44
+ * writeJsonAtomic stores that file mode 0600.
45
+ */
46
+ export function setRelayPassword(password) {
47
+ const state = ensureRuntime();
48
+ state.relayPassword = typeof password === 'string' ? password.trim() : '';
49
+ writeRuntime(state);
50
+ return state;
51
+ }
52
+ /**
53
+ * Issue this workstation a new identity on the relay.
54
+ *
55
+ * The host token is what proves ownership of this workstation, so replacing it
56
+ * means the relay no longer recognises the old one — useful if it leaked, but
57
+ * it also orphans the previous record on a relay that already enrolled it.
58
+ */
59
+ export function regenerateHostIdentity() {
60
+ const state = ensureRuntime();
61
+ state.hostId = `host-${randomToken(9)}`;
62
+ state.hostToken = randomToken(32);
63
+ writeRuntime(state);
64
+ return state;
65
+ }
66
+ /**
67
+ * Remember a process we started, in a list that survives being overwritten.
68
+ *
69
+ * `relayPid`/`hostPid` alone were not enough: the supervisor rewrote them with
70
+ * its own children, which orphaned anything an earlier detached start had
71
+ * spawned. Those orphans kept the port and the host session, so the relay could
72
+ * never bind and "stop" had nothing left to kill. The ledger is append-only
73
+ * (minus dead entries) precisely so no writer can lose another writer's pids.
74
+ */
75
+ export function recordManagedPid(state, name, pid) {
76
+ const existing = Array.isArray(state.managedPids) ? state.managedPids : [];
77
+ const kept = existing.filter((entry) => entry && entry.pid !== pid && pidAlive(entry.pid));
78
+ // Only track something that is actually running: a pid that already exited
79
+ // would sit in the ledger until its number is recycled by an unrelated
80
+ // process, which we would then happily signal.
81
+ if (Number.isInteger(pid) && pidAlive(pid)) {
82
+ kept.push({ name, pid: pid, startedAt: new Date().toISOString() });
83
+ }
84
+ state.managedPids = kept;
85
+ return state;
86
+ }
87
+ /** Every process we believe we started, alive right now. */
88
+ export function managedPids(state = readRuntime()) {
89
+ const pids = new Map();
90
+ // Supervisor first and never overwritten: `stopServices` relies on the order
91
+ // to signal it before its children, and one pid can appear under several
92
+ // fields.
93
+ for (const [name, pid] of [
94
+ ['supervisor', state.supervisorPid],
95
+ ['host', state.hostPid],
96
+ ['relay', state.relayPid],
97
+ ]) {
98
+ if (pidAlive(pid) && !pids.has(pid))
99
+ pids.set(pid, name);
100
+ }
101
+ for (const entry of state.managedPids || []) {
102
+ if (entry && pidAlive(entry.pid) && !pids.has(entry.pid))
103
+ pids.set(entry.pid, entry.name);
104
+ }
105
+ return [...pids].map(([pid, name]) => ({ pid, name }));
106
+ }
@@ -0,0 +1,260 @@
1
+ // The processes that make up a running herdr-remote — the local relay and the
2
+ // host connector — and starting, stopping and describing them.
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { ensureDir } from 'herdr-remote-relay/state';
8
+ import { loadConfig } from './config.js';
9
+ import { findHerdrCommand, herdrVersion, MIN_HERDR_VERSION, resolveHerdrCommand, } from './herdr-command.js';
10
+ import { pidAlive } from './lib/process.js';
11
+ import { preferredLanAddress } from './net-interfaces.js';
12
+ import { logPath, PACKAGE_ROOT, stateDir } from './paths.js';
13
+ import { healthUrl, hostHeaders, requestJson, waitForHost, waitForRelay } from './relay-client.js';
14
+ import { bindAddress, resolveAdminOrigin, resolveHostRelayUrl, resolvePublicUrl, runsLocalRelay, } from './relay-urls.js';
15
+ import { ensureRuntime, managedPids, readRuntime, recordManagedPid, updateRuntime, writeRuntime, } from './runtime.js';
16
+ import { resolveSocketPath } from './socket-discovery.js';
17
+ import { resolveHostFont } from './terminal-font/index.js';
18
+ import { hostTerminalPalette } from './terminal-palette.js';
19
+ export function relayBinPath() {
20
+ // Resolved rather than hard-coded: the relay is a separate npm package, so
21
+ // its location depends on how npm hoisted it.
22
+ const manifest = createRequire(import.meta.url).resolve('herdr-remote-relay/package.json');
23
+ return path.join(path.dirname(manifest), 'bin', 'herdr-remote-relay.js');
24
+ }
25
+ function relayAuthStatePath() {
26
+ return path.join(stateDir(), 'relay-auth.json');
27
+ }
28
+ /**
29
+ * The processes that make up a running herdr-remote, as declarative specs.
30
+ *
31
+ * Both the detached starter and the foreground supervisor build their children
32
+ * from this one list so the two paths cannot drift in how they configure the
33
+ * relay or the host connector.
34
+ */
35
+ export function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
36
+ const specs = [];
37
+ const publicUrl = resolvePublicUrl(config, preferredLanAddress());
38
+ if (runsLocalRelay(config)) {
39
+ specs.push({
40
+ name: 'relay',
41
+ command: process.execPath,
42
+ args: [relayBinPath()],
43
+ env: {
44
+ // Let the shared web UI tell a private workstation relay apart from
45
+ // the operator-facing self-hosted relay. Standalone relay installs
46
+ // default to remote mode.
47
+ RELAY_DEPLOYMENT_MODE: 'local',
48
+ RELAY_BIND: bindAddress(config),
49
+ RELAY_PORT: String(config.relay.port),
50
+ RELAY_PUBLIC_URL: publicUrl,
51
+ // A relay we start ourselves is closed to everything but this
52
+ // workstation: reusing the host token as its password costs nothing and
53
+ // stops another machine on the LAN from enrolling into it.
54
+ RELAY_PASSWORD: state.hostToken,
55
+ RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
56
+ RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
57
+ RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
58
+ RELAY_MAX_HOSTS: String(config.relay.maxHosts),
59
+ RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
60
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
61
+ RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs),
62
+ },
63
+ });
64
+ }
65
+ const terminalPalette = hostTerminalPalette();
66
+ // What this command detected at its entry point, else what an earlier start
67
+ // with a terminal wrote down. Only the family, size and terminal travel here;
68
+ // the connector finds the files itself, on the machine that has them.
69
+ const terminalFont = resolveHostFont();
70
+ specs.push({
71
+ name: 'host',
72
+ command: process.execPath,
73
+ args: [path.join(PACKAGE_ROOT, 'dist', 'connector', 'main.js')],
74
+ env: {
75
+ // Captured here, where a terminal may still be attached, because the
76
+ // connector itself usually runs detached with no terminal to ask.
77
+ ...(terminalPalette ? { HERDR_TERM_PALETTE_JSON: JSON.stringify(terminalPalette) } : {}),
78
+ ...(terminalFont ? { HERDR_TERM_FONT_JSON: JSON.stringify(terminalFont) } : {}),
79
+ RELAY_URL: resolveHostRelayUrl(config),
80
+ RELAY_HOST_ID: state.hostId,
81
+ RELAY_HOST_TOKEN: state.hostToken,
82
+ RELAY_PASSWORD: runsLocalRelay(config) ? state.hostToken : state.relayPassword || '',
83
+ HERDR_SOCKET_PATH: resolveSocketPath(config.herdr.socketPath),
84
+ HERDR_ARGS_JSON: JSON.stringify(config.herdr.args),
85
+ HERDR_CWD: config.herdr.cwd,
86
+ HERDR_BIN_PATH: resolveHerdrCommand(),
87
+ },
88
+ });
89
+ return specs;
90
+ }
91
+ export function baseEnvironment() {
92
+ return { ...process.env, HERDR_REMOTE_SERVICE: '1' };
93
+ }
94
+ function spawnDetached(spec) {
95
+ ensureDir(stateDir());
96
+ const logFd = fs.openSync(logPath(spec.name), 'a');
97
+ try {
98
+ const child = spawn(spec.command, spec.args, {
99
+ cwd: PACKAGE_ROOT,
100
+ env: { ...baseEnvironment(), ...spec.env },
101
+ detached: true,
102
+ stdio: ['ignore', logFd, logFd],
103
+ });
104
+ child.unref();
105
+ return child.pid;
106
+ }
107
+ finally {
108
+ fs.closeSync(logFd);
109
+ }
110
+ }
111
+ /** Where each service's pid is kept in runtime.json. */
112
+ const PID_FIELDS = { relay: 'relayPid', host: 'hostPid' };
113
+ export function startServices() {
114
+ const config = loadConfig();
115
+ const state = ensureRuntime();
116
+ const next = { ...state };
117
+ const specs = serviceSpecs(config, state);
118
+ for (const spec of specs) {
119
+ const field = PID_FIELDS[spec.name];
120
+ if (pidAlive(next[field]))
121
+ continue;
122
+ next[field] = spawnDetached(spec) ?? null;
123
+ recordManagedPid(next, spec.name, next[field]);
124
+ }
125
+ // A relay that is no longer part of the plan (switched to remote mode) must
126
+ // not be left running on the old port.
127
+ if (!specs.some((spec) => spec.name === 'relay') && pidAlive(next.relayPid)) {
128
+ try {
129
+ process.kill(next.relayPid, 'SIGTERM');
130
+ }
131
+ catch {
132
+ // Exited between the check and the signal: the port is free either way.
133
+ }
134
+ next.relayPid = null;
135
+ }
136
+ next.startedAt = next.startedAt || new Date().toISOString();
137
+ next.mode = config.relay.mode;
138
+ writeRuntime(next);
139
+ return {
140
+ ok: true,
141
+ mode: config.relay.mode,
142
+ relay: {
143
+ local: runsLocalRelay(config),
144
+ pid: next.relayPid || null,
145
+ alive: pidAlive(next.relayPid),
146
+ bind: runsLocalRelay(config) ? bindAddress(config) : null,
147
+ port: config.relay.port,
148
+ remoteUrl: config.relay.remoteUrl || null,
149
+ },
150
+ host: {
151
+ pid: next.hostPid || null,
152
+ alive: pidAlive(next.hostPid),
153
+ socketPath: resolveSocketPath(config.herdr.socketPath),
154
+ },
155
+ publicUrl: resolvePublicUrl(config, preferredLanAddress()),
156
+ };
157
+ }
158
+ export function stopServices() {
159
+ const stopped = [];
160
+ ensureDir(stateDir());
161
+ updateRuntime((state) => {
162
+ // Supervisor first: it would otherwise see its children die and restart
163
+ // them faster than we can kill them.
164
+ const targets = managedPids(state).sort((a, b) => (a.name === 'supervisor' ? 0 : 1) - (b.name === 'supervisor' ? 0 : 1));
165
+ for (const { pid, name } of targets) {
166
+ try {
167
+ process.kill(pid, 'SIGTERM');
168
+ stopped.push({ name, pid });
169
+ }
170
+ catch (error) {
171
+ process.stderr.write(`herdr-remote: could not stop ${name} (pid ${pid}): ${error.message}\n`);
172
+ }
173
+ }
174
+ state.hostPid = null;
175
+ state.relayPid = null;
176
+ state.supervisorPid = null;
177
+ state.managedPids = [];
178
+ state.startedAt = null;
179
+ });
180
+ return { ok: true, stopped };
181
+ }
182
+ export function restartServices() {
183
+ stopServices();
184
+ return startServices();
185
+ }
186
+ export async function statusServices() {
187
+ const config = loadConfig();
188
+ const state = readRuntime();
189
+ const lanAddress = preferredLanAddress();
190
+ let health;
191
+ try {
192
+ health = await requestJson(healthUrl(config), { timeout: 1200 });
193
+ }
194
+ catch (error) {
195
+ health = { ok: false, message: error.message };
196
+ }
197
+ const socketPath = resolveSocketPath(config.herdr.socketPath);
198
+ // Where Herdr was found, and whether it was found at all: under a service
199
+ // manager this is the first thing to check when sessions will not start.
200
+ const herdr = findHerdrCommand();
201
+ // Only worth spawning when there is something to spawn; an unresolved command
202
+ // has already answered the question the version would.
203
+ const installed = herdr.found
204
+ ? herdrVersion({ command: herdr.command })
205
+ : { version: null, supported: true };
206
+ return {
207
+ ok: true,
208
+ mode: config.relay.mode,
209
+ relay: {
210
+ local: runsLocalRelay(config),
211
+ pid: state.relayPid || null,
212
+ alive: runsLocalRelay(config) ? pidAlive(state.relayPid) : null,
213
+ bind: runsLocalRelay(config) ? bindAddress(config) : null,
214
+ port: config.relay.port,
215
+ remoteUrl: config.relay.remoteUrl || null,
216
+ health,
217
+ },
218
+ host: {
219
+ pid: state.hostPid || null,
220
+ alive: pidAlive(state.hostPid),
221
+ hostId: state.hostId || null,
222
+ socketPath,
223
+ socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
224
+ herdrCommand: herdr.command,
225
+ herdrCommandFound: herdr.found,
226
+ herdrCommandSource: herdr.source,
227
+ herdrVersion: installed.version,
228
+ herdrVersionSupported: installed.supported,
229
+ herdrVersionMinimum: MIN_HERDR_VERSION,
230
+ },
231
+ publicUrl: resolvePublicUrl(config, lanAddress),
232
+ startedAt: state.startedAt || null,
233
+ };
234
+ }
235
+ /** Start what pairing needs, then ask the relay for a code. */
236
+ export async function pair() {
237
+ const config = loadConfig();
238
+ if (runsLocalRelay(config))
239
+ startServices();
240
+ await waitForRelay(config);
241
+ await waitForHost(config);
242
+ // Authenticated with this workstation's own host token: on a shared relay
243
+ // that is what proves the pairing code is being minted for our terminal and
244
+ // nobody else's.
245
+ return requestJson(`${resolveAdminOrigin(config)}/api/pair/start`, {
246
+ method: 'POST',
247
+ headers: hostHeaders(),
248
+ timeout: 5000,
249
+ });
250
+ }
251
+ export function readLogTail(name, lines = 40) {
252
+ try {
253
+ const content = fs.readFileSync(logPath(name), 'utf8');
254
+ return content.split('\n').filter(Boolean).slice(-lines);
255
+ }
256
+ catch {
257
+ // No log yet: the service has never run.
258
+ return [];
259
+ }
260
+ }