herdr-remote 0.2.17 → 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 (92) hide show
  1. package/bin/herdr-remote.js +3 -221
  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 +10 -9
  70. package/dist/tui.mjs +0 -4613
  71. package/src/agent-status.js +0 -139
  72. package/src/config.js +0 -454
  73. package/src/herdr-api.js +0 -244
  74. package/src/herdr-command.js +0 -231
  75. package/src/herdr-plugin.js +0 -130
  76. package/src/herdr-server.js +0 -201
  77. package/src/host-connector.js +0 -927
  78. package/src/i18n/en.js +0 -240
  79. package/src/i18n/index.js +0 -56
  80. package/src/i18n/zh.js +0 -239
  81. package/src/keepalive.js +0 -460
  82. package/src/lifecycle.js +0 -62
  83. package/src/net-interfaces.js +0 -76
  84. package/src/pasted-files.js +0 -176
  85. package/src/pty-session.js +0 -92
  86. package/src/service.js +0 -516
  87. package/src/settings-model.js +0 -323
  88. package/src/socket-discovery.js +0 -60
  89. package/src/state.js +0 -47
  90. package/src/supervisor.js +0 -250
  91. package/src/terminal-palette.js +0 -325
  92. package/src/updater.js +0 -394
@@ -0,0 +1,298 @@
1
+ // Editable settings, independent of how they are rendered.
2
+ //
3
+ // The TUI holds a *draft* copy of the config, mutates it through these pure
4
+ // functions and writes it out on save. Keeping the rules here (rather than in
5
+ // the view) means they can be tested without a terminal, and the same
6
+ // validation applies to the first-run wizard and the settings screen.
7
+ import { ACCESS_MODES, KEEPALIVE_MANAGERS, LANGUAGES, OFFICIAL_RELAY_URL, loadConfig, } from './config.js';
8
+ import { configDir, configPath } from './paths.js';
9
+ import { isLoopbackHost, isUnspecifiedAddress, isUnspecifiedHost, resolvePublicUrl, } from './relay-urls.js';
10
+ import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
11
+ import { preferredLanAddress } from './net-interfaces.js';
12
+ function isOneOf(list, value) {
13
+ return list.includes(value);
14
+ }
15
+ /**
16
+ * Field metadata. `kind` drives the editor the TUI shows; `visibleFor` limits a
17
+ * field to the access modes where it means anything.
18
+ */
19
+ const FIELDS = [
20
+ { id: 'mode', kind: 'choice', choices: ACCESS_MODES, labelKey: 'field.mode' },
21
+ { id: 'port', kind: 'text', labelKey: 'field.port', visibleFor: ['local', 'lan'] },
22
+ { id: 'lanHost', kind: 'address', labelKey: 'field.lanHost', visibleFor: ['lan'] },
23
+ { id: 'remoteUrl', kind: 'text', labelKey: 'field.remoteUrl', visibleFor: ['remote'] },
24
+ { id: 'publicUrl', kind: 'text', labelKey: 'field.publicUrl' },
25
+ { id: 'socketPath', kind: 'text', labelKey: 'field.socketPath' },
26
+ { id: 'herdrArgs', kind: 'text', labelKey: 'field.herdrArgs' },
27
+ { id: 'herdrAutoStart', kind: 'toggle', labelKey: 'field.herdrAutoStart' },
28
+ { id: 'language', kind: 'choice', choices: LANGUAGES, labelKey: 'field.language' },
29
+ {
30
+ id: 'keepaliveManager',
31
+ kind: 'choice',
32
+ choices: KEEPALIVE_MANAGERS,
33
+ labelKey: 'field.keepalive',
34
+ },
35
+ ];
36
+ const EMPTY = '';
37
+ /**
38
+ * The access modes a *person* chooses between.
39
+ *
40
+ * The official relay is stored as `remote` with a known URL, because that is
41
+ * exactly what it is and nothing downstream should have to learn a fourth mode.
42
+ * It is still a separate answer to "how do I reach this workstation", though:
43
+ * one option needs no server and no credentials, the other needs both. Keeping
44
+ * that distinction here — rather than in whichever screen happens to draw it —
45
+ * is what stops the official relay from being displayed as "self-hosted relay"
46
+ * that merely happens to hold our address.
47
+ */
48
+ const SELECTABLE_MODES = ['local', 'lan', 'official', 'remote'];
49
+ /** Which of `SELECTABLE_MODES` this draft represents. */
50
+ function selectedMode(draft) {
51
+ if (draft.relay.mode === 'remote' && draft.relay.remoteUrl === OFFICIAL_RELAY_URL)
52
+ return 'official';
53
+ return draft.relay.mode;
54
+ }
55
+ /** True when the relay is the one we run, so its address and password are ours. */
56
+ function isOfficialRelay(draft) {
57
+ return selectedMode(draft) === 'official';
58
+ }
59
+ function clone(value) {
60
+ return JSON.parse(JSON.stringify(value));
61
+ }
62
+ function createDraft(config = loadConfig()) {
63
+ return clone(config);
64
+ }
65
+ function fieldsForMode(mode) {
66
+ return FIELDS.filter((field) => !field.visibleFor || field.visibleFor.includes(mode));
67
+ }
68
+ /** Current value of a field as an editable string. */
69
+ function getField(draft, id) {
70
+ switch (id) {
71
+ case 'mode':
72
+ return draft.relay.mode;
73
+ case 'port':
74
+ return String(draft.relay.port);
75
+ case 'lanHost':
76
+ return draft.relay.lanHost || EMPTY;
77
+ case 'remoteUrl':
78
+ return draft.relay.remoteUrl || EMPTY;
79
+ case 'publicUrl':
80
+ return draft.relay.publicUrl || EMPTY;
81
+ case 'socketPath':
82
+ return draft.herdr.socketPath || EMPTY;
83
+ case 'herdrArgs':
84
+ return (draft.herdr.args || []).join(' ');
85
+ case 'herdrAutoStart':
86
+ return draft.herdr.autoStart ? 'on' : 'off';
87
+ case 'language':
88
+ return draft.ui.language;
89
+ case 'keepaliveManager':
90
+ return draft.keepalive.manager;
91
+ default:
92
+ return EMPTY;
93
+ }
94
+ }
95
+ /**
96
+ * What the field shows when it is empty: the value that will actually be used.
97
+ * Displaying the derived value beats an empty box the user cannot interpret.
98
+ */
99
+ function getFieldPlaceholder(draft, id) {
100
+ switch (id) {
101
+ case 'lanHost':
102
+ return preferredLanAddress() || '0.0.0.0';
103
+ case 'publicUrl':
104
+ return resolvePublicUrl(draft, preferredLanAddress());
105
+ case 'socketPath':
106
+ return 'placeholder.autoDiscovered';
107
+ case 'herdrArgs':
108
+ return 'placeholder.none';
109
+ case 'remoteUrl':
110
+ return 'wss://relay.example.com';
111
+ default:
112
+ return EMPTY;
113
+ }
114
+ }
115
+ function isValidUrl(value, protocols) {
116
+ try {
117
+ const url = new URL(value);
118
+ return protocols.includes(url.protocol);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ /**
125
+ * Apply a value to a copy of the draft.
126
+ *
127
+ * Returns `{ draft, errorKey }`. On a validation failure the draft comes back
128
+ * unchanged and `errorKey` names a translatable message, so callers never have
129
+ * to guess whether the edit landed.
130
+ */
131
+ function setField(draft, id, rawValue) {
132
+ const next = clone(draft);
133
+ const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
134
+ // Every field but the toggle is edited as text.
135
+ const text = value;
136
+ switch (id) {
137
+ case 'mode': {
138
+ if (!isOneOf(SELECTABLE_MODES, value))
139
+ return { draft, errorKey: 'error.invalidMode' };
140
+ // Picking the official relay fills in its address in the same edit: a
141
+ // remote mode with no URL is not a valid state, and asking for the
142
+ // address we already know would be asking the user to do our filing.
143
+ if (value === 'official') {
144
+ next.relay.mode = 'remote';
145
+ next.relay.remoteUrl = OFFICIAL_RELAY_URL;
146
+ next.relay.publicUrl = EMPTY;
147
+ break;
148
+ }
149
+ // Leaving the official relay clears its address, so the self-hosted URL
150
+ // field is empty and asking to be filled in rather than pre-loaded with
151
+ // an address that belongs to somebody else's server.
152
+ if (value === 'remote' && next.relay.remoteUrl === OFFICIAL_RELAY_URL) {
153
+ next.relay.remoteUrl = EMPTY;
154
+ }
155
+ next.relay.mode = value;
156
+ if (value === 'lan' &&
157
+ (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
158
+ next.relay.lanHost = EMPTY;
159
+ }
160
+ // A public URL pinned for one mode is wrong for the next one; clearing it
161
+ // lets derivation produce the right address again.
162
+ next.relay.publicUrl = EMPTY;
163
+ break;
164
+ }
165
+ case 'port': {
166
+ const port = Number.parseInt(text, 10);
167
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
168
+ return { draft, errorKey: 'error.invalidPort' };
169
+ next.relay.port = port;
170
+ break;
171
+ }
172
+ case 'lanHost': {
173
+ if (text && (isLoopbackHost(text) || isUnspecifiedAddress(text))) {
174
+ return { draft, errorKey: 'error.invalidLanHost' };
175
+ }
176
+ next.relay.lanHost = text || EMPTY;
177
+ break;
178
+ }
179
+ case 'remoteUrl': {
180
+ if (text && !isValidUrl(text, ['ws:', 'wss:', 'http:', 'https:'])) {
181
+ return { draft, errorKey: 'error.invalidRelayUrl' };
182
+ }
183
+ next.relay.remoteUrl = text.replace(/\/+$/, '');
184
+ break;
185
+ }
186
+ case 'publicUrl': {
187
+ if (text && (!isValidUrl(text, ['http:', 'https:']) || isUnspecifiedHost(text))) {
188
+ return { draft, errorKey: 'error.invalidPublicUrl' };
189
+ }
190
+ next.relay.publicUrl = text.replace(/\/+$/, '');
191
+ break;
192
+ }
193
+ case 'socketPath': {
194
+ next.herdr.socketPath = text || null;
195
+ break;
196
+ }
197
+ case 'herdrArgs': {
198
+ const args = text ? text.split(/\s+/).filter(Boolean) : [];
199
+ if (args.includes('--no-session')) {
200
+ return { draft, errorKey: 'error.removedHerdrArg' };
201
+ }
202
+ next.herdr.args = args;
203
+ break;
204
+ }
205
+ case 'herdrAutoStart': {
206
+ if (value !== true && value !== false && value !== 'on' && value !== 'off') {
207
+ return { draft, errorKey: 'error.unknownField' };
208
+ }
209
+ next.herdr.autoStart = value === true || value === 'on';
210
+ break;
211
+ }
212
+ case 'language': {
213
+ if (!isOneOf(LANGUAGES, value))
214
+ return { draft, errorKey: 'error.invalidLanguage' };
215
+ next.ui.language = value;
216
+ break;
217
+ }
218
+ case 'keepaliveManager': {
219
+ if (!isOneOf(KEEPALIVE_MANAGERS, value))
220
+ return { draft, errorKey: 'error.invalidKeepalive' };
221
+ next.keepalive.manager = value;
222
+ break;
223
+ }
224
+ default:
225
+ return { draft, errorKey: 'error.unknownField' };
226
+ }
227
+ return { draft: next, errorKey: null };
228
+ }
229
+ /** Problems that should block saving, as translatable keys. */
230
+ function validateDraft(draft) {
231
+ const problems = [];
232
+ if (draft.relay.mode === 'remote' && !draft.relay.remoteUrl)
233
+ problems.push('error.remoteUrlRequired');
234
+ return problems;
235
+ }
236
+ /**
237
+ * Persist a draft, merging into whatever is already on disk so keys this
238
+ * version does not know about survive the round trip.
239
+ */
240
+ function saveDraft(draft) {
241
+ const problems = validateDraft(draft);
242
+ if (problems.length > 0) {
243
+ throw Object.assign(new Error(`configuration is incomplete: ${problems.join(', ')}`), {
244
+ problems,
245
+ });
246
+ }
247
+ const current = (readJson(configPath(), {}) || {});
248
+ const merged = { ...current };
249
+ merged.ui = { ...(current.ui || {}), language: draft.ui.language };
250
+ const relay = {
251
+ ...(current.relay || {}),
252
+ mode: draft.relay.mode,
253
+ port: draft.relay.port,
254
+ lanHost: draft.relay.mode === 'lan' &&
255
+ (isLoopbackHost(draft.relay.lanHost) || isUnspecifiedAddress(draft.relay.lanHost))
256
+ ? EMPTY
257
+ : draft.relay.lanHost,
258
+ publicUrl: draft.relay.publicUrl,
259
+ remoteUrl: draft.relay.remoteUrl,
260
+ };
261
+ merged.relay = relay;
262
+ // Fields from the 0.1 schema would otherwise keep overriding the new ones on
263
+ // the next load.
264
+ delete relay.local;
265
+ delete relay.host;
266
+ delete relay.url;
267
+ // The optional Herdr source patch is gone. Nothing reads this section any
268
+ // more, so the next save is where a config written by an older build sheds
269
+ // it rather than carrying stale checksums and paths forever.
270
+ delete merged.patch;
271
+ const herdr = {
272
+ ...(current.herdr || {}),
273
+ socketPath: draft.herdr.socketPath,
274
+ args: draft.herdr.args,
275
+ autoStart: draft.herdr.autoStart === true,
276
+ };
277
+ merged.herdr = herdr;
278
+ if (!herdr.socketPath)
279
+ delete herdr.socketPath;
280
+ merged.keepalive = { ...(current.keepalive || {}), manager: draft.keepalive.manager };
281
+ ensureDir(configDir());
282
+ writeJsonAtomic(configPath(), merged);
283
+ return { ok: true, path: configPath() };
284
+ }
285
+ /**
286
+ * Settings that only take effect after the services restart. Used to show a
287
+ * "restart required" hint rather than silently doing nothing.
288
+ */
289
+ function requiresRestart(before, after) {
290
+ return (before.relay.mode !== after.relay.mode ||
291
+ before.relay.port !== after.relay.port ||
292
+ before.relay.lanHost !== after.relay.lanHost ||
293
+ before.relay.remoteUrl !== after.relay.remoteUrl ||
294
+ before.relay.publicUrl !== after.relay.publicUrl ||
295
+ before.herdr.socketPath !== after.herdr.socketPath ||
296
+ before.herdr.args.join(' ') !== after.herdr.args.join(' '));
297
+ }
298
+ export { createDraft, fieldsForMode, isOfficialRelay, selectedMode, getField, getFieldPlaceholder, setField, validateDraft, saveDraft, requiresRestart, };
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function defaultSocketPath(env = process.env, platform = process.platform) {
5
+ if (env.HERDR_SOCKET_PATH)
6
+ return env.HERDR_SOCKET_PATH;
7
+ if (platform === 'win32') {
8
+ const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
9
+ return path.join(appData, 'herdr', 'herdr.sock');
10
+ }
11
+ const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
12
+ return path.join(configHome, 'herdr', 'herdr.sock');
13
+ }
14
+ function resolveSocketPath(configuredPath = null, env = process.env) {
15
+ return configuredPath || env.HERDR_SOCKET_PATH || defaultSocketPath(env);
16
+ }
17
+ function inspectSocket(socketPath, options = {}) {
18
+ if (typeof socketPath !== 'string' || socketPath.length === 0) {
19
+ return { ok: false, reason: 'socket path is empty', path: socketPath };
20
+ }
21
+ try {
22
+ const stat = fs.statSync(socketPath);
23
+ if (process.platform !== 'win32' && !stat.isSocket()) {
24
+ return { ok: false, reason: 'path is not a Unix socket', path: socketPath };
25
+ }
26
+ if (options.requireOwner !== false &&
27
+ typeof process.getuid === 'function' &&
28
+ stat.uid !== process.getuid()) {
29
+ return {
30
+ ok: false,
31
+ reason: 'socket is not owned by the current user',
32
+ path: socketPath,
33
+ uid: stat.uid,
34
+ };
35
+ }
36
+ return { ok: true, path: socketPath, uid: stat.uid, mode: stat.mode };
37
+ }
38
+ catch (error) {
39
+ const { code, message } = error;
40
+ return {
41
+ ok: false,
42
+ missing: code === 'ENOENT',
43
+ reason: code === 'ENOENT' ? 'socket does not exist' : message,
44
+ path: socketPath,
45
+ };
46
+ }
47
+ }
48
+ export { defaultSocketPath, resolveSocketPath, inspectSocket };
@@ -0,0 +1,319 @@
1
+ import fs from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { loadConfig } from './config.js';
4
+ import { logPath, PACKAGE_ROOT, stateDir } from './paths.js';
5
+ import { ensureDir } from 'herdr-remote-relay/state';
6
+ import { ensureRuntime, managedPids, recordManagedPid, updateRuntime, } from './runtime.js';
7
+ import { baseEnvironment, serviceSpecs } from './service.js';
8
+ import { EXIT_REPLACED, EXIT_AUTH_FAILED } from './exit-codes.js';
9
+ import { pidAlive } from './lib/process.js';
10
+ const MIN_BACKOFF_MS = 500;
11
+ const MAX_BACKOFF_MS = 30_000;
12
+ // A child that stayed up this long is considered healthy, so the next crash
13
+ // starts backing off from scratch instead of inheriting an old penalty.
14
+ const HEALTHY_UPTIME_MS = 30_000;
15
+ /**
16
+ * Runs the relay and host connector as managed children and restarts them when
17
+ * they die.
18
+ *
19
+ * This is the process a service manager supervises: `herdr-remote run` stays in
20
+ * the foreground so systemd/launchd can track it, and the same class backs the
21
+ * fallback daemon on systems where neither is available.
22
+ */
23
+ class Supervisor {
24
+ config;
25
+ state;
26
+ logToFiles;
27
+ onEvent;
28
+ /** What to run; the relay and host connector unless a test says otherwise. */
29
+ specs;
30
+ children = new Map();
31
+ stopping = false;
32
+ constructor({ config = loadConfig(), state = ensureRuntime(), logToFiles = false, onEvent = null, specs = null, } = {}) {
33
+ this.config = config;
34
+ this.state = state;
35
+ this.logToFiles = logToFiles;
36
+ this.onEvent = onEvent;
37
+ this.specs = specs;
38
+ }
39
+ emit(event) {
40
+ if (this.onEvent)
41
+ this.onEvent(event);
42
+ const line = `[${new Date().toISOString()}] herdr-remote supervisor: ${event.message}\n`;
43
+ process.stdout.write(line);
44
+ }
45
+ /**
46
+ * Stop anything a previous, unmanaged start left behind.
47
+ *
48
+ * Taking over without this is what produced the runaway: a detached relay
49
+ * from `herdr-remote start` kept port 8787, so every relay this supervisor
50
+ * spawned died with EADDRINUSE and retried forever, while the orphaned host
51
+ * connector and ours both claimed the same host id on the relay and kicked
52
+ * each other off — which is what browsers saw as an endless reconnect.
53
+ */
54
+ async reclaimStrays({ timeoutMs = 5000 } = {}) {
55
+ const strays = managedPids().filter((entry) => entry.pid !== process.pid);
56
+ if (strays.length === 0)
57
+ return strays;
58
+ for (const { pid, name } of strays) {
59
+ this.emit({
60
+ type: 'reclaim',
61
+ name,
62
+ pid,
63
+ message: `stopping stray ${name} from an earlier start (pid ${pid})`,
64
+ });
65
+ try {
66
+ process.kill(pid, 'SIGTERM');
67
+ }
68
+ catch {
69
+ // Already gone, or not ours to signal; the liveness check below decides.
70
+ }
71
+ }
72
+ // Wait for them to actually go: the relay only releases its port on exit,
73
+ // and spawning ours before then just reproduces the EADDRINUSE loop.
74
+ const deadline = Date.now() + timeoutMs;
75
+ while (Date.now() < deadline) {
76
+ if (strays.every(({ pid }) => !pidAlive(pid)))
77
+ return strays;
78
+ await new Promise((resolve) => {
79
+ setTimeout(resolve, 100);
80
+ });
81
+ }
82
+ for (const { pid, name } of strays) {
83
+ if (!pidAlive(pid))
84
+ continue;
85
+ this.emit({
86
+ type: 'reclaim',
87
+ name,
88
+ pid,
89
+ message: `stray ${name} (pid ${pid}) ignored SIGTERM, killing`,
90
+ });
91
+ try {
92
+ process.kill(pid, 'SIGKILL');
93
+ }
94
+ catch {
95
+ // Already gone; SIGKILL was the last resort anyway.
96
+ }
97
+ }
98
+ return strays;
99
+ }
100
+ async start() {
101
+ this.stopping = false;
102
+ ensureDir(stateDir());
103
+ await this.reclaimStrays();
104
+ if (this.stopping)
105
+ return;
106
+ for (const spec of this.specs ?? serviceSpecs(this.config, this.state)) {
107
+ this.children.set(spec.name, {
108
+ spec,
109
+ child: null,
110
+ pid: null,
111
+ restarts: 0,
112
+ backoffMs: MIN_BACKOFF_MS,
113
+ timer: null,
114
+ });
115
+ this.spawnChild(spec.name);
116
+ }
117
+ this.persistPids();
118
+ }
119
+ spawnChild(name) {
120
+ if (this.stopping)
121
+ return;
122
+ const entry = this.children.get(name);
123
+ if (!entry || entry.child)
124
+ return;
125
+ let stdio = ['ignore', 'inherit', 'inherit'];
126
+ let fd = null;
127
+ if (this.logToFiles) {
128
+ fd = fs.openSync(logPath(name), 'a');
129
+ stdio = ['ignore', fd, fd];
130
+ }
131
+ let child;
132
+ try {
133
+ child = spawn(entry.spec.command, entry.spec.args, {
134
+ cwd: PACKAGE_ROOT,
135
+ env: { ...baseEnvironment(), ...entry.spec.env },
136
+ stdio,
137
+ });
138
+ }
139
+ catch (error) {
140
+ this.emit({
141
+ type: 'spawn_failed',
142
+ name,
143
+ message: `could not start ${name}: ${error.message}`,
144
+ });
145
+ this.scheduleRestart(name);
146
+ return;
147
+ }
148
+ finally {
149
+ // The child inherited the descriptor during spawn, so this copy is done.
150
+ if (fd !== null) {
151
+ try {
152
+ fs.closeSync(fd);
153
+ }
154
+ catch {
155
+ // The child holds its own copy; failing to close ours leaks one descriptor at worst.
156
+ }
157
+ }
158
+ }
159
+ entry.child = child;
160
+ entry.pid = child.pid;
161
+ entry.startedAt = Date.now();
162
+ this.emit({
163
+ type: 'started',
164
+ name,
165
+ pid: child.pid,
166
+ message: `${name} started (pid ${child.pid})`,
167
+ });
168
+ this.persistPids();
169
+ child.on('exit', (code, signal) => {
170
+ const uptimeMs = Date.now() - (entry.startedAt ?? Date.now());
171
+ entry.child = null;
172
+ entry.pid = null;
173
+ this.persistPids();
174
+ if (this.stopping)
175
+ return;
176
+ if (code === EXIT_REPLACED) {
177
+ this.emit({
178
+ type: 'replaced',
179
+ name,
180
+ message: `${name} stood down: another instance owns this workstation. Not restarting it.`,
181
+ });
182
+ return;
183
+ }
184
+ if (code === EXIT_AUTH_FAILED) {
185
+ this.emit({
186
+ type: 'fatal',
187
+ name,
188
+ message: `${name} stopped: relay authentication failed. Update credentials before restarting it.`,
189
+ });
190
+ return;
191
+ }
192
+ if (uptimeMs >= HEALTHY_UPTIME_MS)
193
+ entry.backoffMs = MIN_BACKOFF_MS;
194
+ this.emit({
195
+ type: 'exited',
196
+ name,
197
+ code,
198
+ signal,
199
+ message: `${name} exited (${signal || `code ${code}`}) after ${Math.round(uptimeMs / 1000)}s, restarting in ${entry.backoffMs}ms`,
200
+ });
201
+ this.scheduleRestart(name);
202
+ });
203
+ child.on('error', (error) => {
204
+ this.emit({ type: 'error', name, message: `${name} error: ${error.message}` });
205
+ });
206
+ }
207
+ scheduleRestart(name) {
208
+ if (this.stopping)
209
+ return;
210
+ const entry = this.children.get(name);
211
+ if (!entry || entry.timer)
212
+ return;
213
+ const delay = entry.backoffMs;
214
+ entry.restarts += 1;
215
+ entry.backoffMs = Math.min(MAX_BACKOFF_MS, Math.round(entry.backoffMs * 2));
216
+ const timer = setTimeout(() => {
217
+ entry.timer = null;
218
+ this.spawnChild(name);
219
+ }, delay);
220
+ entry.timer = timer;
221
+ timer.unref?.();
222
+ }
223
+ persistPids() {
224
+ try {
225
+ updateRuntime((current) => {
226
+ current.supervisorPid = process.pid;
227
+ current.relayPid = this.children.get('relay')?.pid || null;
228
+ current.hostPid = this.children.get('host')?.pid || null;
229
+ current.startedAt = current.startedAt || new Date().toISOString();
230
+ current.mode = this.config.relay.mode;
231
+ // Merge into the ledger rather than replacing it, so a pid recorded by
232
+ // another writer is never dropped and left running with nobody tracking
233
+ // it.
234
+ recordManagedPid(current, 'supervisor', process.pid);
235
+ for (const [name, entry] of this.children) {
236
+ if (entry.pid)
237
+ recordManagedPid(current, name, entry.pid);
238
+ }
239
+ });
240
+ }
241
+ catch (error) {
242
+ process.stderr.write(`herdr-remote supervisor: could not persist pids: ${error.message}\n`);
243
+ }
244
+ }
245
+ async stop({ graceMs = 5000 } = {}) {
246
+ this.stopping = true;
247
+ const pending = [];
248
+ for (const entry of this.children.values()) {
249
+ if (entry.timer) {
250
+ clearTimeout(entry.timer);
251
+ entry.timer = null;
252
+ }
253
+ const child = entry.child;
254
+ if (!child)
255
+ continue;
256
+ pending.push(new Promise((resolve) => {
257
+ const killTimer = setTimeout(() => {
258
+ try {
259
+ child.kill('SIGKILL');
260
+ }
261
+ catch {
262
+ // Already exited between the timer and the kill.
263
+ }
264
+ resolve();
265
+ }, graceMs);
266
+ killTimer.unref?.();
267
+ child.once('exit', () => {
268
+ clearTimeout(killTimer);
269
+ resolve();
270
+ });
271
+ try {
272
+ child.kill('SIGTERM');
273
+ }
274
+ catch {
275
+ resolve();
276
+ }
277
+ }));
278
+ }
279
+ await Promise.all(pending);
280
+ try {
281
+ updateRuntime((current) => {
282
+ current.supervisorPid = null;
283
+ current.relayPid = null;
284
+ current.hostPid = null;
285
+ current.startedAt = null;
286
+ current.managedPids = (current.managedPids || []).filter((entry) => entry && pidAlive(entry.pid));
287
+ });
288
+ }
289
+ catch {
290
+ // The runtime file is advisory; stopping must finish even if it cannot be written.
291
+ }
292
+ }
293
+ }
294
+ /** Entry point for `herdr-remote run`: supervise in the foreground until told to stop. */
295
+ async function runForeground({ logToFiles = false } = {}) {
296
+ const supervisor = new Supervisor({ logToFiles });
297
+ await supervisor.start();
298
+ return new Promise((resolve) => {
299
+ let shuttingDown = false;
300
+ const shutdown = async (signal) => {
301
+ if (shuttingDown)
302
+ return;
303
+ shuttingDown = true;
304
+ supervisor.emit({ type: 'shutdown', message: `${signal} received, stopping services` });
305
+ await supervisor.stop();
306
+ resolve(0);
307
+ };
308
+ process.on('SIGINT', () => {
309
+ shutdown('SIGINT');
310
+ });
311
+ process.on('SIGTERM', () => {
312
+ shutdown('SIGTERM');
313
+ });
314
+ process.on('SIGHUP', () => {
315
+ shutdown('SIGHUP');
316
+ });
317
+ });
318
+ }
319
+ export { MIN_BACKOFF_MS, runForeground, Supervisor };