herdr-remote 0.2.0

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.
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ // Editable settings, independent of how they are rendered.
4
+ //
5
+ // The TUI holds a *draft* copy of the config, mutates it through these pure
6
+ // functions and writes it out on save. Keeping the rules here (rather than in
7
+ // the view) means they can be tested without a terminal, and the same
8
+ // validation applies to the first-run wizard and the settings screen.
9
+
10
+ const {
11
+ ACCESS_MODES,
12
+ KEEPALIVE_MANAGERS,
13
+ LANGUAGES,
14
+ configDir,
15
+ configPath,
16
+ isLoopbackHost,
17
+ isUnspecifiedAddress,
18
+ isUnspecifiedHost,
19
+ loadConfig,
20
+ resolvePublicUrl,
21
+ } = require('./config');
22
+ const { ensureDir, readJson, writeJsonAtomic } = require('./state');
23
+ const { preferredLanAddress } = require('./net-interfaces');
24
+
25
+ /**
26
+ * Field metadata. `kind` drives the editor the TUI shows; `visibleFor` limits a
27
+ * field to the access modes where it means anything.
28
+ */
29
+ const FIELDS = [
30
+ { id: 'mode', kind: 'choice', choices: ACCESS_MODES, labelKey: 'field.mode' },
31
+ { id: 'port', kind: 'text', labelKey: 'field.port', visibleFor: ['local', 'lan'] },
32
+ { id: 'lanHost', kind: 'address', labelKey: 'field.lanHost', visibleFor: ['lan'] },
33
+ { id: 'remoteUrl', kind: 'text', labelKey: 'field.remoteUrl', visibleFor: ['remote'] },
34
+ { id: 'publicUrl', kind: 'text', labelKey: 'field.publicUrl' },
35
+ { id: 'socketPath', kind: 'text', labelKey: 'field.socketPath' },
36
+ { id: 'herdrArgs', kind: 'text', labelKey: 'field.herdrArgs' },
37
+ { id: 'language', kind: 'choice', choices: LANGUAGES, labelKey: 'field.language' },
38
+ { id: 'keepaliveManager', kind: 'choice', choices: KEEPALIVE_MANAGERS, labelKey: 'field.keepalive' },
39
+ ];
40
+
41
+ const EMPTY = '';
42
+
43
+ function clone(value) {
44
+ return JSON.parse(JSON.stringify(value));
45
+ }
46
+
47
+ function createDraft(config = loadConfig()) {
48
+ return clone(config);
49
+ }
50
+
51
+ function fieldsForMode(mode) {
52
+ return FIELDS.filter((field) => !field.visibleFor || field.visibleFor.includes(mode));
53
+ }
54
+
55
+ /** Current value of a field as an editable string. */
56
+ function getField(draft, id) {
57
+ switch (id) {
58
+ case 'mode':
59
+ return draft.relay.mode;
60
+ case 'port':
61
+ return String(draft.relay.port);
62
+ case 'lanHost':
63
+ return draft.relay.lanHost || EMPTY;
64
+ case 'remoteUrl':
65
+ return draft.relay.remoteUrl || EMPTY;
66
+ case 'publicUrl':
67
+ return draft.relay.publicUrl || EMPTY;
68
+ case 'socketPath':
69
+ return draft.herdr.socketPath || EMPTY;
70
+ case 'herdrArgs':
71
+ return (draft.herdr.args || []).join(' ');
72
+ case 'language':
73
+ return draft.ui.language;
74
+ case 'keepaliveManager':
75
+ return draft.keepalive.manager;
76
+ default:
77
+ return EMPTY;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * What the field shows when it is empty: the value that will actually be used.
83
+ * Displaying the derived value beats an empty box the user cannot interpret.
84
+ */
85
+ function getFieldPlaceholder(draft, id) {
86
+ switch (id) {
87
+ case 'lanHost':
88
+ return preferredLanAddress() || '0.0.0.0';
89
+ case 'publicUrl':
90
+ return resolvePublicUrl(draft, preferredLanAddress());
91
+ case 'socketPath':
92
+ return 'placeholder.autoDiscovered';
93
+ case 'herdrArgs':
94
+ return 'placeholder.none';
95
+ case 'remoteUrl':
96
+ return 'wss://relay.example.com';
97
+ default:
98
+ return EMPTY;
99
+ }
100
+ }
101
+
102
+ function isValidUrl(value, protocols) {
103
+ try {
104
+ const url = new URL(value);
105
+ return protocols.includes(url.protocol);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Apply a value to a copy of the draft.
113
+ *
114
+ * Returns `{ draft, errorKey }`. On a validation failure the draft comes back
115
+ * unchanged and `errorKey` names a translatable message, so callers never have
116
+ * to guess whether the edit landed.
117
+ */
118
+ function setField(draft, id, rawValue) {
119
+ const next = clone(draft);
120
+ const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
121
+
122
+ switch (id) {
123
+ case 'mode': {
124
+ if (!ACCESS_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
125
+ next.relay.mode = value;
126
+ if (value === 'lan'
127
+ && (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
128
+ next.relay.lanHost = EMPTY;
129
+ }
130
+ // A public URL pinned for one mode is wrong for the next one; clearing it
131
+ // lets derivation produce the right address again.
132
+ next.relay.publicUrl = EMPTY;
133
+ break;
134
+ }
135
+ case 'port': {
136
+ const port = Number.parseInt(value, 10);
137
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { draft, errorKey: 'error.invalidPort' };
138
+ next.relay.port = port;
139
+ break;
140
+ }
141
+ case 'lanHost': {
142
+ if (value && (isLoopbackHost(value) || isUnspecifiedAddress(value))) {
143
+ return { draft, errorKey: 'error.invalidLanHost' };
144
+ }
145
+ next.relay.lanHost = value || EMPTY;
146
+ break;
147
+ }
148
+ case 'remoteUrl': {
149
+ if (value && !isValidUrl(value, ['ws:', 'wss:', 'http:', 'https:'])) {
150
+ return { draft, errorKey: 'error.invalidRelayUrl' };
151
+ }
152
+ next.relay.remoteUrl = value.replace(/\/+$/, '');
153
+ break;
154
+ }
155
+ case 'publicUrl': {
156
+ if (value && (!isValidUrl(value, ['http:', 'https:']) || isUnspecifiedHost(value))) {
157
+ return { draft, errorKey: 'error.invalidPublicUrl' };
158
+ }
159
+ next.relay.publicUrl = value.replace(/\/+$/, '');
160
+ break;
161
+ }
162
+ case 'socketPath': {
163
+ next.herdr.socketPath = value || null;
164
+ break;
165
+ }
166
+ case 'herdrArgs': {
167
+ next.herdr.args = value ? value.split(/\s+/).filter(Boolean) : [];
168
+ break;
169
+ }
170
+ case 'language': {
171
+ if (!LANGUAGES.includes(value)) return { draft, errorKey: 'error.invalidLanguage' };
172
+ next.ui.language = value;
173
+ break;
174
+ }
175
+ case 'keepaliveManager': {
176
+ if (!KEEPALIVE_MANAGERS.includes(value)) return { draft, errorKey: 'error.invalidKeepalive' };
177
+ next.keepalive.manager = value;
178
+ break;
179
+ }
180
+ default:
181
+ return { draft, errorKey: 'error.unknownField' };
182
+ }
183
+ return { draft: next, errorKey: null };
184
+ }
185
+
186
+ /** Problems that should block saving, as translatable keys. */
187
+ function validateDraft(draft) {
188
+ const problems = [];
189
+ if (draft.relay.mode === 'remote' && !draft.relay.remoteUrl) problems.push('error.remoteUrlRequired');
190
+ return problems;
191
+ }
192
+
193
+ /**
194
+ * Persist a draft, merging into whatever is already on disk so keys this
195
+ * version does not know about survive the round trip.
196
+ */
197
+ function saveDraft(draft) {
198
+ const problems = validateDraft(draft);
199
+ if (problems.length > 0) {
200
+ const error = new Error(`configuration is incomplete: ${problems.join(', ')}`);
201
+ error.problems = problems;
202
+ throw error;
203
+ }
204
+
205
+ const current = readJson(configPath(), {}) || {};
206
+ const merged = { ...current };
207
+ merged.ui = { ...(current.ui || {}), language: draft.ui.language };
208
+ merged.relay = {
209
+ ...(current.relay || {}),
210
+ mode: draft.relay.mode,
211
+ port: draft.relay.port,
212
+ lanHost: draft.relay.mode === 'lan'
213
+ && (isLoopbackHost(draft.relay.lanHost) || isUnspecifiedAddress(draft.relay.lanHost))
214
+ ? EMPTY
215
+ : draft.relay.lanHost,
216
+ publicUrl: draft.relay.publicUrl,
217
+ remoteUrl: draft.relay.remoteUrl,
218
+ };
219
+ // Fields from the 0.1 schema would otherwise keep overriding the new ones on
220
+ // the next load.
221
+ delete merged.relay.local;
222
+ delete merged.relay.host;
223
+ delete merged.relay.url;
224
+ // The optional Herdr source patch is gone. Nothing reads this section any
225
+ // more, so the next save is where a config written by an older build sheds
226
+ // it rather than carrying stale checksums and paths forever.
227
+ delete merged.patch;
228
+ merged.herdr = {
229
+ ...(current.herdr || {}),
230
+ socketPath: draft.herdr.socketPath,
231
+ args: draft.herdr.args,
232
+ };
233
+ if (!merged.herdr.socketPath) delete merged.herdr.socketPath;
234
+ merged.keepalive = { ...(current.keepalive || {}), manager: draft.keepalive.manager };
235
+
236
+ ensureDir(configDir());
237
+ writeJsonAtomic(configPath(), merged);
238
+ return { ok: true, path: configPath() };
239
+ }
240
+
241
+ /**
242
+ * Settings that only take effect after the services restart. Used to show a
243
+ * "restart required" hint rather than silently doing nothing.
244
+ */
245
+ function requiresRestart(before, after) {
246
+ return before.relay.mode !== after.relay.mode
247
+ || before.relay.port !== after.relay.port
248
+ || before.relay.lanHost !== after.relay.lanHost
249
+ || before.relay.remoteUrl !== after.relay.remoteUrl
250
+ || before.relay.publicUrl !== after.relay.publicUrl
251
+ || before.herdr.socketPath !== after.herdr.socketPath
252
+ || before.herdr.args.join(' ') !== after.herdr.args.join(' ');
253
+ }
254
+
255
+ module.exports = {
256
+ FIELDS,
257
+ createDraft,
258
+ fieldsForMode,
259
+ getField,
260
+ getFieldPlaceholder,
261
+ setField,
262
+ validateDraft,
263
+ saveDraft,
264
+ requiresRestart,
265
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ function defaultSocketPath(env = process.env, platform = process.platform) {
8
+ if (env.HERDR_SOCKET_PATH) return env.HERDR_SOCKET_PATH;
9
+ if (platform === 'win32') {
10
+ const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
11
+ return path.join(appData, 'herdr', 'herdr.sock');
12
+ }
13
+ const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
14
+ return path.join(configHome, 'herdr', 'herdr.sock');
15
+ }
16
+
17
+ function resolveSocketPath(configuredPath = null, env = process.env) {
18
+ return configuredPath || env.HERDR_SOCKET_PATH || defaultSocketPath(env);
19
+ }
20
+
21
+ function inspectSocket(socketPath, options = {}) {
22
+ if (typeof socketPath !== 'string' || socketPath.length === 0) {
23
+ return { ok: false, reason: 'socket path is empty', path: socketPath };
24
+ }
25
+ try {
26
+ const stat = fs.statSync(socketPath);
27
+ if (process.platform !== 'win32' && !stat.isSocket()) {
28
+ return { ok: false, reason: 'path is not a Unix socket', path: socketPath };
29
+ }
30
+ if (options.requireOwner !== false && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
31
+ return { ok: false, reason: 'socket is not owned by the current user', path: socketPath, uid: stat.uid };
32
+ }
33
+ return { ok: true, path: socketPath, uid: stat.uid, mode: stat.mode };
34
+ } catch (error) {
35
+ return { ok: false, reason: error.code === 'ENOENT' ? 'socket does not exist' : error.message, path: socketPath };
36
+ }
37
+ }
38
+
39
+ function assertSocket(socketPath, options = {}) {
40
+ const result = inspectSocket(socketPath, options);
41
+ if (!result.ok) {
42
+ const error = new Error(`Herdr socket unavailable at ${socketPath}: ${result.reason}`);
43
+ error.code = 'HERDR_SOCKET_UNAVAILABLE';
44
+ error.details = result;
45
+ throw error;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ module.exports = {
51
+ defaultSocketPath,
52
+ resolveSocketPath,
53
+ inspectSocket,
54
+ assertSocket,
55
+ };
package/src/state.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ function ensureDir(dirPath) {
8
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
9
+ try {
10
+ fs.chmodSync(dirPath, 0o700);
11
+ } catch {}
12
+ }
13
+
14
+ function writeJsonAtomic(filePath, value) {
15
+ ensureDir(path.dirname(filePath));
16
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
17
+ fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
18
+ try {
19
+ fs.chmodSync(tempPath, 0o600);
20
+ } catch {}
21
+ fs.renameSync(tempPath, filePath);
22
+ try {
23
+ fs.chmodSync(filePath, 0o600);
24
+ } catch {}
25
+ }
26
+
27
+ function readJson(filePath, fallback) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
30
+ } catch (error) {
31
+ if (error.code !== 'ENOENT') {
32
+ process.stderr.write(`herdr-remote: invalid state at ${filePath}: ${error.message}\n`);
33
+ }
34
+ return fallback;
35
+ }
36
+ }
37
+
38
+ function randomToken(bytes = 32) {
39
+ return crypto.randomBytes(bytes).toString('base64url');
40
+ }
41
+
42
+ module.exports = {
43
+ ensureDir,
44
+ writeJsonAtomic,
45
+ readJson,
46
+ randomToken,
47
+ };
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { spawn } = require('node:child_process');
5
+ const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
6
+ const { ensureDir, readJson, writeJsonAtomic } = require('./state');
7
+ const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
8
+ const { EXIT_REPLACED } = require('./exit-codes');
9
+
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
+ /**
17
+ * Runs the relay and host connector as managed children and restarts them when
18
+ * they die.
19
+ *
20
+ * This is the process a service manager supervises: `herdr-remote run` stays in
21
+ * the foreground so systemd/launchd can track it, and the same class backs the
22
+ * fallback daemon on systems where neither is available.
23
+ */
24
+ class Supervisor {
25
+ constructor({ config = loadConfig(), state = ensureRuntime(), logToFiles = false, onEvent = null } = {}) {
26
+ this.config = config;
27
+ this.state = state;
28
+ this.logToFiles = logToFiles;
29
+ this.onEvent = onEvent;
30
+ this.children = new Map();
31
+ this.stopping = false;
32
+ }
33
+
34
+ emit(event) {
35
+ if (this.onEvent) this.onEvent(event);
36
+ const line = `[${new Date().toISOString()}] herdr-remote supervisor: ${event.message}\n`;
37
+ process.stdout.write(line);
38
+ }
39
+
40
+ /**
41
+ * Stop anything a previous, unmanaged start left behind.
42
+ *
43
+ * Taking over without this is what produced the runaway: a detached relay
44
+ * from `herdr-remote start` kept port 8787, so every relay this supervisor
45
+ * spawned died with EADDRINUSE and retried forever, while the orphaned host
46
+ * connector and ours both claimed the same host id on the relay and kicked
47
+ * each other off — which is what browsers saw as an endless reconnect.
48
+ */
49
+ async reclaimStrays({ timeoutMs = 5000 } = {}) {
50
+ const strays = managedPids().filter((entry) => entry.pid !== process.pid);
51
+ if (strays.length === 0) return strays;
52
+
53
+ for (const { pid, name } of strays) {
54
+ this.emit({ type: 'reclaim', name, pid, message: `stopping stray ${name} from an earlier start (pid ${pid})` });
55
+ try { process.kill(pid, 'SIGTERM'); } catch {}
56
+ }
57
+
58
+ // Wait for them to actually go: the relay only releases its port on exit,
59
+ // and spawning ours before then just reproduces the EADDRINUSE loop.
60
+ const deadline = Date.now() + timeoutMs;
61
+ while (Date.now() < deadline) {
62
+ if (strays.every(({ pid }) => !pidAlive(pid))) return strays;
63
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
64
+ }
65
+ for (const { pid, name } of strays) {
66
+ if (!pidAlive(pid)) continue;
67
+ this.emit({ type: 'reclaim', name, pid, message: `stray ${name} (pid ${pid}) ignored SIGTERM, killing` });
68
+ try { process.kill(pid, 'SIGKILL'); } catch {}
69
+ }
70
+ return strays;
71
+ }
72
+
73
+ async start() {
74
+ this.stopping = false;
75
+ ensureDir(stateDir());
76
+ await this.reclaimStrays();
77
+ if (this.stopping) return;
78
+ for (const spec of serviceSpecs(this.config, this.state)) {
79
+ this.children.set(spec.name, { spec, child: null, pid: null, restarts: 0, backoffMs: MIN_BACKOFF_MS, timer: null });
80
+ this.spawnChild(spec.name);
81
+ }
82
+ this.persistPids();
83
+ }
84
+
85
+ spawnChild(name) {
86
+ if (this.stopping) return;
87
+ const entry = this.children.get(name);
88
+ if (!entry || entry.child) return;
89
+
90
+ let stdio = ['ignore', 'inherit', 'inherit'];
91
+ let fd = null;
92
+ if (this.logToFiles) {
93
+ fd = fs.openSync(logPath(name), 'a');
94
+ stdio = ['ignore', fd, fd];
95
+ }
96
+
97
+ let child;
98
+ try {
99
+ child = spawn(entry.spec.command, entry.spec.args, {
100
+ cwd: PACKAGE_ROOT,
101
+ env: { ...baseEnvironment(), ...entry.spec.env },
102
+ stdio,
103
+ });
104
+ } catch (error) {
105
+ this.emit({ type: 'spawn_failed', name, message: `could not start ${name}: ${error.message}` });
106
+ this.scheduleRestart(name);
107
+ return;
108
+ } finally {
109
+ // The child inherited the descriptor during spawn, so this copy is done.
110
+ if (fd !== null) {
111
+ try { fs.closeSync(fd); } catch {}
112
+ }
113
+ }
114
+
115
+ entry.child = child;
116
+ entry.pid = child.pid;
117
+ entry.startedAt = Date.now();
118
+ this.emit({ type: 'started', name, pid: child.pid, message: `${name} started (pid ${child.pid})` });
119
+ this.persistPids();
120
+
121
+ child.on('exit', (code, signal) => {
122
+ const uptimeMs = Date.now() - entry.startedAt;
123
+ entry.child = null;
124
+ entry.pid = null;
125
+ this.persistPids();
126
+ if (this.stopping) return;
127
+ if (code === EXIT_REPLACED) {
128
+ this.emit({
129
+ type: 'replaced',
130
+ name,
131
+ message: `${name} stood down: another instance owns this workstation. Not restarting it.`,
132
+ });
133
+ return;
134
+ }
135
+ if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
136
+ this.emit({
137
+ type: 'exited',
138
+ name,
139
+ code,
140
+ signal,
141
+ message: `${name} exited (${signal || `code ${code}`}) after ${Math.round(uptimeMs / 1000)}s, restarting in ${entry.backoffMs}ms`,
142
+ });
143
+ this.scheduleRestart(name);
144
+ });
145
+
146
+ child.on('error', (error) => {
147
+ this.emit({ type: 'error', name, message: `${name} error: ${error.message}` });
148
+ });
149
+ }
150
+
151
+ scheduleRestart(name) {
152
+ if (this.stopping) return;
153
+ const entry = this.children.get(name);
154
+ if (!entry || entry.timer) return;
155
+ const delay = entry.backoffMs;
156
+ entry.restarts += 1;
157
+ entry.backoffMs = Math.min(MAX_BACKOFF_MS, Math.round(entry.backoffMs * 2));
158
+ entry.timer = setTimeout(() => {
159
+ entry.timer = null;
160
+ this.spawnChild(name);
161
+ }, delay);
162
+ entry.timer.unref?.();
163
+ }
164
+
165
+ persistPids() {
166
+ try {
167
+ const current = readJson(runtimeStatePath(), {});
168
+ current.supervisorPid = process.pid;
169
+ current.relayPid = this.children.get('relay')?.pid || null;
170
+ current.hostPid = this.children.get('host')?.pid || null;
171
+ current.startedAt = current.startedAt || new Date().toISOString();
172
+ current.mode = this.config.relay.mode;
173
+ // Merge into the ledger rather than replacing it, so a pid recorded by
174
+ // another writer is never dropped and left running with nobody tracking
175
+ // it.
176
+ recordManagedPid(current, 'supervisor', process.pid);
177
+ for (const [name, entry] of this.children) {
178
+ if (entry.pid) recordManagedPid(current, name, entry.pid);
179
+ }
180
+ writeJsonAtomic(runtimeStatePath(), current);
181
+ } catch (error) {
182
+ process.stderr.write(`herdr-remote supervisor: could not persist pids: ${error.message}\n`);
183
+ }
184
+ }
185
+
186
+ async stop({ graceMs = 5000 } = {}) {
187
+ this.stopping = true;
188
+ const pending = [];
189
+ for (const entry of this.children.values()) {
190
+ if (entry.timer) {
191
+ clearTimeout(entry.timer);
192
+ entry.timer = null;
193
+ }
194
+ const child = entry.child;
195
+ if (!child) continue;
196
+ pending.push(new Promise((resolve) => {
197
+ const killTimer = setTimeout(() => {
198
+ try { child.kill('SIGKILL'); } catch {}
199
+ resolve();
200
+ }, graceMs);
201
+ killTimer.unref?.();
202
+ child.once('exit', () => {
203
+ clearTimeout(killTimer);
204
+ resolve();
205
+ });
206
+ try { child.kill('SIGTERM'); } catch { resolve(); }
207
+ }));
208
+ }
209
+ await Promise.all(pending);
210
+ try {
211
+ const current = readJson(runtimeStatePath(), {});
212
+ current.supervisorPid = null;
213
+ current.relayPid = null;
214
+ current.hostPid = null;
215
+ current.startedAt = null;
216
+ current.managedPids = (current.managedPids || []).filter((entry) => entry && pidAlive(entry.pid));
217
+ writeJsonAtomic(runtimeStatePath(), current);
218
+ } catch {}
219
+ }
220
+ }
221
+
222
+ /** Entry point for `herdr-remote run`: supervise in the foreground until told to stop. */
223
+ async function runForeground({ logToFiles = false } = {}) {
224
+ const supervisor = new Supervisor({ logToFiles });
225
+ await supervisor.start();
226
+
227
+ return new Promise((resolve) => {
228
+ let shuttingDown = false;
229
+ const shutdown = async (signal) => {
230
+ if (shuttingDown) return;
231
+ shuttingDown = true;
232
+ supervisor.emit({ type: 'shutdown', message: `${signal} received, stopping services` });
233
+ await supervisor.stop();
234
+ resolve(0);
235
+ };
236
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
237
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
238
+ process.on('SIGHUP', () => { shutdown('SIGHUP'); });
239
+ });
240
+ }
241
+
242
+ module.exports = { Supervisor, runForeground, MIN_BACKOFF_MS, MAX_BACKOFF_MS, HEALTHY_UPTIME_MS };