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,61 @@
1
+ id = "herdr.remote.web"
2
+ name = "Herdr Remote Web"
3
+ version = "0.2.0"
4
+ min_herdr_version = "0.8.2"
5
+ description = "Mobile-first remote access to the native Herdr TUI through a local or self-hosted relay"
6
+ platforms = ["linux", "macos"]
7
+
8
+ # No [[build]] steps: this package is installed from npm with its TUI bundle and
9
+ # web assets already built. Herdr should never compile anything on the user's
10
+ # machine to enable the plugin.
11
+
12
+ [[startup]]
13
+ command = ["node", "bin/herdr-remote.js", "start"]
14
+
15
+ [[panes]]
16
+ id = "config"
17
+ title = "Herdr Remote"
18
+ placement = "zoomed"
19
+ command = ["node", "bin/herdr-remote.js"]
20
+
21
+ [[actions]]
22
+ id = "configure"
23
+ title = "Configure Herdr Remote"
24
+ contexts = ["workspace", "pane"]
25
+ command = ["herdr", "plugin", "pane", "open", "--plugin", "herdr.remote.web", "--entrypoint", "config", "--placement", "zoomed", "--focus"]
26
+
27
+ [[actions]]
28
+ id = "start"
29
+ title = "Start Herdr Remote"
30
+ contexts = ["workspace", "pane"]
31
+ command = ["node", "bin/herdr-remote.js", "start"]
32
+
33
+ [[actions]]
34
+ id = "stop"
35
+ title = "Stop Herdr Remote"
36
+ contexts = ["workspace", "pane"]
37
+ command = ["node", "bin/herdr-remote.js", "stop"]
38
+
39
+ [[actions]]
40
+ id = "status"
41
+ title = "Show Herdr Remote status"
42
+ contexts = ["workspace", "pane"]
43
+ command = ["node", "bin/herdr-remote.js", "status"]
44
+
45
+ [[actions]]
46
+ id = "pair"
47
+ title = "Create a phone pairing code"
48
+ contexts = ["workspace", "pane"]
49
+ command = ["node", "bin/herdr-remote.js", "pair"]
50
+
51
+ [[actions]]
52
+ id = "url"
53
+ title = "Show the Herdr Remote URL"
54
+ contexts = ["workspace", "pane"]
55
+ command = ["node", "bin/herdr-remote.js", "url"]
56
+
57
+ [[actions]]
58
+ id = "keepalive"
59
+ title = "Show the Herdr Remote keep-alive status"
60
+ contexts = ["workspace", "pane"]
61
+ command = ["node", "bin/herdr-remote.js", "keepalive", "status"]
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "herdr-remote",
3
+ "version": "0.2.0",
4
+ "description": "Remote browser access to your Herdr terminal workspaces: Herdr plugin, host connector, and bilingual configuration TUI",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dibin666/herdr-remote.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/dibin666/herdr-remote#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/dibin666/herdr-remote/issues"
14
+ },
15
+ "keywords": [
16
+ "herdr",
17
+ "herdr-plugin",
18
+ "terminal",
19
+ "remote",
20
+ "tui",
21
+ "mobile"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.0.0"
25
+ },
26
+ "bin": {
27
+ "herdr-remote": "./bin/herdr-remote.js"
28
+ },
29
+ "main": "./src/service.js",
30
+ "files": [
31
+ "bin",
32
+ "dist",
33
+ "src",
34
+ "herdr-plugin.toml",
35
+ "config.example.json"
36
+ ],
37
+ "scripts": {
38
+ "build": "node scripts/build-tui.mjs",
39
+ "prepack": "npm run build",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "node --test tests/*.test.js",
42
+ "start": "node bin/herdr-remote.js"
43
+ },
44
+ "dependencies": {
45
+ "herdr-remote-relay": "^0.2.0",
46
+ "ink": "^7.1.1",
47
+ "node-pty": "^1.1.0",
48
+ "qrcode": "^1.5.4",
49
+ "react": "^19.2.0",
50
+ "ws": "^8.18.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.13.5",
54
+ "@types/qrcode": "^1.5.6",
55
+ "@types/react": "^19.2.0",
56
+ "esbuild": "^0.25.0",
57
+ "ink-testing-library": "^4.0.0",
58
+ "typescript": "~5.7.3"
59
+ }
60
+ }
package/src/config.js ADDED
@@ -0,0 +1,411 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ /**
8
+ * Locate the package directory by walking up to our own package.json.
9
+ *
10
+ * `path.resolve(__dirname, '..')` was wrong as soon as this module started
11
+ * being bundled into dist/tui.js, because the bundle sits at a different depth
12
+ * than src/. Everything that matters — the plugin manifest, the host connector
13
+ * entry point, the systemd unit's ExecStart — is resolved from this value, so
14
+ * it has to be independent of where the code happens to be loaded from.
15
+ */
16
+ function findPackageRoot(start) {
17
+ let directory = start;
18
+ for (let depth = 0; depth < 8; depth += 1) {
19
+ const candidate = path.join(directory, 'package.json');
20
+ try {
21
+ if (JSON.parse(fs.readFileSync(candidate, 'utf8')).name === 'herdr-remote') return directory;
22
+ } catch {
23
+ // Keep climbing: a missing or unrelated package.json is expected.
24
+ }
25
+ const parent = path.dirname(directory);
26
+ if (parent === directory) break;
27
+ directory = parent;
28
+ }
29
+ return path.resolve(start, '..');
30
+ }
31
+
32
+ const PACKAGE_ROOT = findPackageRoot(__dirname);
33
+
34
+ // Access modes
35
+ // local — a relay runs on this machine, reachable only from this machine.
36
+ // lan — a relay runs on this machine, bound to every interface so other
37
+ // devices on the LAN (or on a Tailscale/WireGuard overlay) reach it.
38
+ // remote — no local relay; the host connector dials an operator-run relay,
39
+ // which is the only way in from outside the local network.
40
+ const ACCESS_MODES = ['local', 'lan', 'remote'];
41
+ const LANGUAGES = ['auto', 'zh', 'en'];
42
+ const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
43
+
44
+ const DEFAULTS = {
45
+ ui: {
46
+ language: 'auto',
47
+ },
48
+ relay: {
49
+ mode: 'local',
50
+ port: 8787,
51
+ // Address advertised in pairing URLs when mode is "lan". Empty means "pick
52
+ // the first non-internal IPv4 automatically".
53
+ lanHost: '',
54
+ // Manual override for the URL browsers open. Empty means "derive it".
55
+ publicUrl: '',
56
+ // Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
57
+ remoteUrl: '',
58
+ maxPayloadBytes: 1024 * 1024,
59
+ maxClientsPerHost: 16,
60
+ allowedOrigins: [],
61
+ },
62
+ herdr: {
63
+ socketPath: null,
64
+ args: [],
65
+ cwd: os.homedir(),
66
+ },
67
+ auth: {
68
+ pairingTtlMs: 10 * 60 * 1000,
69
+ deviceTtlMs: 30 * 24 * 60 * 60 * 1000,
70
+ maxDevices: 32,
71
+ },
72
+ cleanup: {
73
+ intervalMs: 60 * 1000,
74
+ heartbeatIntervalMs: 30 * 1000,
75
+ staleAfterMs: 90 * 1000,
76
+ },
77
+ keepalive: {
78
+ manager: 'auto',
79
+ },
80
+ };
81
+
82
+ function clone(value) {
83
+ return JSON.parse(JSON.stringify(value));
84
+ }
85
+
86
+ /**
87
+ * The canonical configuration directory.
88
+ *
89
+ * Deliberately NOT $HERDR_PLUGIN_CONFIG_DIR: Herdr sets that variable only when
90
+ * it launches the plugin itself, so honouring it would give "herdr-remote" run
91
+ * from a shell and the same tool run from a Herdr pane two different config
92
+ * files. One path, one config; `migrateLegacyConfig()` imports the old one.
93
+ */
94
+ function configDir() {
95
+ return process.env.HERDR_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.config', 'herdr-remote');
96
+ }
97
+
98
+ function stateDir() {
99
+ return process.env.HERDR_REMOTE_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'herdr-remote');
100
+ }
101
+
102
+ function configPath() {
103
+ return path.join(configDir(), 'config.json');
104
+ }
105
+
106
+ function runtimeStatePath() {
107
+ return path.join(stateDir(), 'runtime.json');
108
+ }
109
+
110
+ function legacyConfigPath() {
111
+ return process.env.HERDR_PLUGIN_CONFIG_DIR
112
+ ? path.join(process.env.HERDR_PLUGIN_CONFIG_DIR, 'config.json')
113
+ : null;
114
+ }
115
+
116
+ function readJson(filePath) {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
119
+ } catch (error) {
120
+ if (error.code !== 'ENOENT') {
121
+ process.stderr.write(`herdr-remote: ignoring invalid JSON at ${filePath}: ${error.message}\n`);
122
+ }
123
+ return {};
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Copy a config written by an older, plugin-scoped install into the canonical
129
+ * location. Runs at most once: it never overwrites an existing config.
130
+ */
131
+ function migrateLegacyConfig() {
132
+ const target = configPath();
133
+ if (fs.existsSync(target)) return { migrated: false, reason: 'config already exists' };
134
+ const legacy = legacyConfigPath();
135
+ if (!legacy || !fs.existsSync(legacy)) return { migrated: false, reason: 'no legacy config' };
136
+ try {
137
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
138
+ fs.copyFileSync(legacy, target);
139
+ return { migrated: true, from: legacy, to: target };
140
+ } catch (error) {
141
+ return { migrated: false, reason: error.message };
142
+ }
143
+ }
144
+
145
+ function mergeConfig(fileConfig) {
146
+ const config = clone(DEFAULTS);
147
+ for (const section of Object.keys(config)) {
148
+ if (fileConfig && fileConfig[section] && typeof fileConfig[section] === 'object') {
149
+ Object.assign(config[section], fileConfig[section]);
150
+ }
151
+ }
152
+ return config;
153
+ }
154
+
155
+ /**
156
+ * Accept configs written before access modes existed. The 0.1 schema used
157
+ * `relay.local` (boolean), `relay.host` (bind address) and `relay.url`.
158
+ */
159
+ function normalizeLegacyFields(config, fileConfig) {
160
+ const legacy = (fileConfig && fileConfig.relay) || {};
161
+ // The absence of an explicit `mode` — not an invalid one — is what marks a
162
+ // file as pre-0.2, since merging defaults always leaves a valid mode behind.
163
+ const hasExplicitMode = ACCESS_MODES.includes(legacy.mode);
164
+ if (!hasExplicitMode) {
165
+ if (legacy.local === false && typeof legacy.url === 'string' && legacy.url) {
166
+ config.relay.mode = 'remote';
167
+ if (!config.relay.remoteUrl) config.relay.remoteUrl = legacy.url;
168
+ } else if (typeof legacy.host === 'string' && legacy.host && !isLoopbackHost(legacy.host)) {
169
+ config.relay.mode = 'lan';
170
+ } else {
171
+ config.relay.mode = 'local';
172
+ }
173
+ }
174
+ if (config.relay.mode === 'lan' && !config.relay.lanHost && typeof legacy.host === 'string' && !isLoopbackHost(legacy.host) && legacy.host !== '0.0.0.0') {
175
+ config.relay.lanHost = legacy.host;
176
+ }
177
+ // Superseded keys copied in by the merge would otherwise reappear in the
178
+ // in-memory config and confuse anything reading it.
179
+ delete config.relay.local;
180
+ delete config.relay.host;
181
+ delete config.relay.url;
182
+ // `publicUrl` used to be mandatory and defaulted to the loopback URL; treat
183
+ // that default as "no override" so derivation can take over.
184
+ if (typeof config.relay.publicUrl === 'string' && /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/?$/.test(config.relay.publicUrl)) {
185
+ config.relay.publicUrl = '';
186
+ }
187
+ }
188
+
189
+ function parseInteger(value, fallback, min, max) {
190
+ const numeric = Number(value);
191
+ if (!Number.isInteger(numeric) || numeric < min || numeric > max) return fallback;
192
+ return numeric;
193
+ }
194
+
195
+ function isLoopbackHost(value) {
196
+ return value === '127.0.0.1' || value === 'localhost' || value === '::1' || value === '[::1]';
197
+ }
198
+
199
+ function isUnspecifiedAddress(value) {
200
+ const address = typeof value === 'string' ? value.trim().toLowerCase() : '';
201
+ return address === '0.0.0.0' || address === '::' || address === '[::]';
202
+ }
203
+
204
+ /** An unspecified host is valid for a server bind, but not for a browser URL. */
205
+ function isUnspecifiedHost(value) {
206
+ try {
207
+ const hostname = new URL(value).hostname.toLowerCase();
208
+ return hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]';
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ function normalizeUrl(value) {
215
+ return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
216
+ }
217
+
218
+ function validate(config) {
219
+ if (!ACCESS_MODES.includes(config.relay.mode)) config.relay.mode = DEFAULTS.relay.mode;
220
+ if (!LANGUAGES.includes(config.ui.language)) config.ui.language = DEFAULTS.ui.language;
221
+ if (!KEEPALIVE_MANAGERS.includes(config.keepalive.manager)) config.keepalive.manager = DEFAULTS.keepalive.manager;
222
+
223
+ config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
224
+ config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
225
+ config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
226
+ config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
227
+ config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
228
+ config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
229
+ config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
230
+ config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
231
+ config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
232
+
233
+ config.relay.publicUrl = normalizeUrl(config.relay.publicUrl);
234
+ config.relay.remoteUrl = normalizeUrl(config.relay.remoteUrl);
235
+ config.relay.lanHost = typeof config.relay.lanHost === 'string' ? config.relay.lanHost.trim() : '';
236
+ // A LAN relay listens on every interface, but a loopback or wildcard value
237
+ // cannot be opened by another device. Clear stale values from older TUI
238
+ // versions so the advertised URL falls back to a real interface address.
239
+ if (config.relay.mode === 'lan'
240
+ && (isLoopbackHost(config.relay.lanHost) || isUnspecifiedAddress(config.relay.lanHost))) {
241
+ config.relay.lanHost = '';
242
+ }
243
+ // 0.0.0.0 is a server-side bind wildcard, never a destination a browser can
244
+ // open. Treat it like an empty override so LAN mode derives the real address.
245
+ if (isUnspecifiedHost(config.relay.publicUrl)) config.relay.publicUrl = '';
246
+ if (!Array.isArray(config.relay.allowedOrigins)) config.relay.allowedOrigins = [];
247
+
248
+ if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === 'string')) {
249
+ config.herdr.args = [];
250
+ }
251
+ if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
252
+ config.herdr.socketPath = null;
253
+ }
254
+ if (typeof config.herdr.cwd !== 'string' || config.herdr.cwd.length === 0) {
255
+ config.herdr.cwd = os.homedir();
256
+ }
257
+
258
+ // A "remote" config without a relay URL cannot reach anything; fall back to
259
+ // local rather than silently starting a host connector that dials nowhere.
260
+ if (config.relay.mode === 'remote' && !config.relay.remoteUrl) {
261
+ config.relay.mode = 'local';
262
+ }
263
+ return config;
264
+ }
265
+
266
+ function applyEnvironment(config) {
267
+ const env = process.env;
268
+ if (env.HERDR_REMOTE_LANG && LANGUAGES.includes(env.HERDR_REMOTE_LANG)) config.ui.language = env.HERDR_REMOTE_LANG;
269
+ if (env.HERDR_REMOTE_MODE && ACCESS_MODES.includes(env.HERDR_REMOTE_MODE)) config.relay.mode = env.HERDR_REMOTE_MODE;
270
+ if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
271
+ if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
272
+ if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
273
+ if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
274
+ if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
275
+ if (env.HERDR_ARGS_JSON) {
276
+ try {
277
+ const args = JSON.parse(env.HERDR_ARGS_JSON);
278
+ if (Array.isArray(args)) config.herdr.args = args;
279
+ } catch (error) {
280
+ process.stderr.write(`herdr-remote: invalid HERDR_ARGS_JSON: ${error.message}\n`);
281
+ }
282
+ }
283
+ }
284
+
285
+ function loadConfig() {
286
+ const fileConfig = readJson(configPath());
287
+ const config = mergeConfig(fileConfig);
288
+ normalizeLegacyFields(config, fileConfig);
289
+ applyEnvironment(config);
290
+ validate(config);
291
+ return config;
292
+ }
293
+
294
+ function configExists() {
295
+ return fs.existsSync(configPath());
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Derived values
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /** Whether this machine runs its own relay process. */
303
+ function runsLocalRelay(config) {
304
+ return config.relay.mode !== 'remote';
305
+ }
306
+
307
+ /** Address the local relay binds to for the configured mode. */
308
+ function bindAddress(config) {
309
+ return config.relay.mode === 'lan' ? '0.0.0.0' : '127.0.0.1';
310
+ }
311
+
312
+ /**
313
+ * Host name to put into pairing URLs. For "lan" this is the interface address a
314
+ * phone can actually reach; `lanHost` is the user's pick from the TUI, and the
315
+ * fallback keeps things working if that interface disappeared.
316
+ */
317
+ function advertisedHost(config, fallbackLanHost = null) {
318
+ if (config.relay.mode === 'lan') {
319
+ const configured = config.relay.lanHost;
320
+ if (configured && !isLoopbackHost(configured) && !isUnspecifiedAddress(configured)) return configured;
321
+ if (fallbackLanHost && !isLoopbackHost(fallbackLanHost) && !isUnspecifiedAddress(fallbackLanHost)) {
322
+ return fallbackLanHost;
323
+ }
324
+ return '127.0.0.1';
325
+ }
326
+ return '127.0.0.1';
327
+ }
328
+
329
+ /** The URL a browser opens. */
330
+ function resolvePublicUrl(config, fallbackLanHost = null) {
331
+ if (config.relay.publicUrl) return config.relay.publicUrl;
332
+ if (config.relay.mode === 'remote') return httpOrigin(config.relay.remoteUrl);
333
+ return `http://${advertisedHost(config, fallbackLanHost)}:${config.relay.port}`;
334
+ }
335
+
336
+ /** Base HTTP origin used for relay admin calls (pairing, health). */
337
+ function resolveAdminOrigin(config) {
338
+ if (config.relay.mode === 'remote') return httpOrigin(config.relay.remoteUrl);
339
+ return `http://127.0.0.1:${config.relay.port}`;
340
+ }
341
+
342
+ /** WebSocket URL the host connector dials. */
343
+ function resolveHostRelayUrl(config) {
344
+ const base = config.relay.mode === 'remote'
345
+ ? config.relay.remoteUrl
346
+ : `ws://127.0.0.1:${config.relay.port}`;
347
+ return hostWebSocketUrl(base);
348
+ }
349
+
350
+ function httpOrigin(value) {
351
+ try {
352
+ const url = new URL(value);
353
+ if (url.protocol === 'ws:') url.protocol = 'http:';
354
+ if (url.protocol === 'wss:') url.protocol = 'https:';
355
+ const pathname = url.pathname.replace(/\/+$/, '');
356
+ return `${url.origin}${pathname}`;
357
+ } catch {
358
+ return normalizeUrl(value);
359
+ }
360
+ }
361
+
362
+ function hostWebSocketUrl(base) {
363
+ const url = new URL(base);
364
+ if (url.protocol === 'http:') url.protocol = 'ws:';
365
+ if (url.protocol === 'https:') url.protocol = 'wss:';
366
+ const pathname = url.pathname.replace(/\/+$/, '');
367
+ if (!pathname || pathname === '/') {
368
+ url.pathname = '/ws/host';
369
+ } else if (!pathname.endsWith('/ws/host')) {
370
+ url.pathname = `${pathname}/ws/host`;
371
+ }
372
+ return url.toString();
373
+ }
374
+
375
+ function clientWebSocketUrl(locationLike) {
376
+ const url = new URL(locationLike);
377
+ if (url.protocol === 'http:') url.protocol = 'ws:';
378
+ if (url.protocol === 'https:') url.protocol = 'wss:';
379
+ const pathname = url.pathname.replace(/\/+$/, '');
380
+ url.pathname = !pathname || pathname === '/' ? '/ws/client' : `${pathname}/ws/client`;
381
+ return url.toString();
382
+ }
383
+
384
+ module.exports = {
385
+ PACKAGE_ROOT,
386
+ DEFAULTS,
387
+ ACCESS_MODES,
388
+ LANGUAGES,
389
+ KEEPALIVE_MANAGERS,
390
+ configDir,
391
+ configPath,
392
+ configExists,
393
+ stateDir,
394
+ runtimeStatePath,
395
+ legacyConfigPath,
396
+ migrateLegacyConfig,
397
+ loadConfig,
398
+ validate,
399
+ runsLocalRelay,
400
+ bindAddress,
401
+ advertisedHost,
402
+ resolvePublicUrl,
403
+ resolveAdminOrigin,
404
+ resolveHostRelayUrl,
405
+ hostWebSocketUrl,
406
+ clientWebSocketUrl,
407
+ httpOrigin,
408
+ isLoopbackHost,
409
+ isUnspecifiedAddress,
410
+ isUnspecifiedHost,
411
+ };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Exit codes the supervisor interprets.
5
+ *
6
+ * `REPLACED` means another host connector has taken over this workstation on
7
+ * the relay. Restarting after that would make the two instances kick each other
8
+ * off in turn, and every browser attached to the relay is disconnected on each
9
+ * swap — the endless "Connection closed (1012). Retrying…" loop. So it is the
10
+ * one exit the supervisor must respect rather than recover from.
11
+ */
12
+ const EXIT_REPLACED = 12;
13
+
14
+ module.exports = { EXIT_REPLACED };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ // How a spawned Herdr is located.
4
+ //
5
+ // `HERDR_BIN_PATH` is what the service layer hands to the host connector, and
6
+ // it is also the escape hatch for an install that is not on `PATH`.
7
+
8
+ function resolveHerdrCommand() {
9
+ return process.env.HERDR_BIN_PATH || 'herdr';
10
+ }
11
+
12
+ module.exports = { resolveHerdrCommand };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ // Registration with the Herdr CLI.
4
+ //
5
+ // Installing from npm puts this package somewhere under a global node_modules
6
+ // tree; Herdr learns about it with `herdr plugin link <path>`. Doing that from
7
+ // here means the user never has to find the install directory themselves.
8
+
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const { spawnSync } = require('node:child_process');
12
+ const { PACKAGE_ROOT } = require('./config');
13
+
14
+ const PLUGIN_ID = 'herdr.remote.web';
15
+ const MANIFEST_NAME = 'herdr-plugin.toml';
16
+
17
+ function manifestPath() {
18
+ return path.join(PACKAGE_ROOT, MANIFEST_NAME);
19
+ }
20
+
21
+ function herdrAvailable() {
22
+ const result = spawnSync('herdr', ['--version'], { stdio: 'ignore' });
23
+ return result.status === 0 || result.status === 1;
24
+ }
25
+
26
+ function runHerdr(args, { timeout = 15_000 } = {}) {
27
+ const result = spawnSync('herdr', args, { encoding: 'utf8', timeout });
28
+ if (result.error && result.error.code === 'ENOENT') {
29
+ const error = new Error('herdr command not found');
30
+ error.code = 'HERDR_NOT_FOUND';
31
+ throw error;
32
+ }
33
+ return result;
34
+ }
35
+
36
+ /**
37
+ * Parse `herdr plugin list`. Lines look like:
38
+ * - herdr.remote.web (Herdr Remote Web) enabled [local:/path/to/package]
39
+ *
40
+ * Herdr appends diagnostics inside the brackets when something is wrong, e.g.
41
+ * `[local:/path; 1 warning(s)]`, so everything from the first `;` is dropped
42
+ * before the path is read.
43
+ */
44
+ function parsePluginList(output) {
45
+ const plugins = [];
46
+ for (const line of String(output || '').split('\n')) {
47
+ const match = /^-\s+(\S+)\s+\((.*?)\)\s+(\S+)(?:\s+\[(.*)\])?/.exec(line.trim());
48
+ if (!match) continue;
49
+ const [, id, name, state, rawSource] = match;
50
+ const source = rawSource ? rawSource.split(';')[0].trim() : null;
51
+ plugins.push({
52
+ id,
53
+ name,
54
+ enabled: state === 'enabled',
55
+ source,
56
+ warnings: rawSource && rawSource.includes(';') ? rawSource.slice(rawSource.indexOf(';') + 1).trim() : null,
57
+ localPath: source && source.startsWith('local:') ? source.slice('local:'.length) : null,
58
+ });
59
+ }
60
+ return plugins;
61
+ }
62
+
63
+ function registrationStatus() {
64
+ if (!fs.existsSync(manifestPath())) {
65
+ return { available: false, registered: false, reason: 'manifest missing' };
66
+ }
67
+ let result;
68
+ try {
69
+ result = runHerdr(['plugin', 'list']);
70
+ } catch (error) {
71
+ if (error.code === 'HERDR_NOT_FOUND') return { available: false, registered: false, reason: 'herdr not found' };
72
+ throw error;
73
+ }
74
+ if (result.status !== 0) {
75
+ return { available: true, registered: false, reason: String(result.stderr || '').trim() };
76
+ }
77
+ const plugins = parsePluginList(result.stdout);
78
+ const entry = plugins.find((plugin) => plugin.id === PLUGIN_ID);
79
+ if (!entry) return { available: true, registered: false, packageRoot: PACKAGE_ROOT };
80
+ return {
81
+ available: true,
82
+ registered: true,
83
+ enabled: entry.enabled,
84
+ linkedPath: entry.localPath,
85
+ // A stale link pointing at an old checkout is the main failure mode after
86
+ // switching from a source install to npm.
87
+ stale: Boolean(entry.localPath) && path.resolve(entry.localPath) !== path.resolve(PACKAGE_ROOT),
88
+ packageRoot: PACKAGE_ROOT,
89
+ };
90
+ }
91
+
92
+ function register() {
93
+ const current = registrationStatus();
94
+ if (current.registered && current.stale) {
95
+ // Herdr refuses to link a second plugin with the same id, so drop the old
96
+ // link before pointing it at this install.
97
+ runHerdr(['plugin', 'unlink', PLUGIN_ID]);
98
+ }
99
+ const result = runHerdr(['plugin', 'link', PACKAGE_ROOT]);
100
+ if (result.status !== 0) {
101
+ throw new Error(String(result.stderr || result.stdout || '').trim() || 'herdr plugin link failed');
102
+ }
103
+ return { ok: true, path: PACKAGE_ROOT, output: String(result.stdout || '').trim() };
104
+ }
105
+
106
+ function unregister() {
107
+ const result = runHerdr(['plugin', 'unlink', PLUGIN_ID]);
108
+ if (result.status !== 0) {
109
+ throw new Error(String(result.stderr || result.stdout || '').trim() || 'herdr plugin unlink failed');
110
+ }
111
+ return { ok: true, output: String(result.stdout || '').trim() };
112
+ }
113
+
114
+ module.exports = {
115
+ PLUGIN_ID,
116
+ manifestPath,
117
+ herdrAvailable,
118
+ parsePluginList,
119
+ registrationStatus,
120
+ register,
121
+ unregister,
122
+ };