herdr-remote 0.2.18 → 0.2.20
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.
- package/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
package/dist/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// herdr-remote command line: argument parsing and the subcommands.
|
|
2
|
+
//
|
|
3
|
+
// With no arguments (and a real terminal) this opens the configuration TUI;
|
|
4
|
+
// the subcommands exist for scripting and for the actions declared in
|
|
5
|
+
// herdr-plugin.toml, which Herdr runs without a terminal attached.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { LANGUAGES, loadConfig, migrateLegacyConfig } from './config.js';
|
|
9
|
+
import { PACKAGE_ROOT, configPath, stateDir } from './paths.js';
|
|
10
|
+
import { extractPairingCode } from './relay-client.js';
|
|
11
|
+
import { resolvePublicUrl } from './relay-urls.js';
|
|
12
|
+
import { createTranslator, detectLocale } from './i18n/index.js';
|
|
13
|
+
import { preferredLanAddress } from './net-interfaces.js';
|
|
14
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
15
|
+
const USAGE = `herdr-remote ${VERSION} — browser access to Herdr workspaces
|
|
16
|
+
|
|
17
|
+
Usage: herdr-remote [command] [options]
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
(none) Open the configuration TUI
|
|
21
|
+
start Start relay and host connector
|
|
22
|
+
stop Stop services
|
|
23
|
+
restart Restart services
|
|
24
|
+
status [--json] Show service status
|
|
25
|
+
pair [--json] Create pairing code
|
|
26
|
+
url Print browser access URL
|
|
27
|
+
run [--daemon] Run services in foreground (keep-alive)
|
|
28
|
+
keepalive <action> install | uninstall | restart | status
|
|
29
|
+
plugin <action> link | unlink | status (Herdr plugin)
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--lang <zh|en> Interface language
|
|
33
|
+
--json JSON output
|
|
34
|
+
-h, --help Show help
|
|
35
|
+
-v, --version Show version
|
|
36
|
+
|
|
37
|
+
Self-hosting: docs/self-hosted-relay.md
|
|
38
|
+
`;
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const positional = [];
|
|
41
|
+
const flags = {};
|
|
42
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
43
|
+
const arg = argv[index];
|
|
44
|
+
if (arg === '--json') {
|
|
45
|
+
flags.json = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === '--daemon') {
|
|
49
|
+
flags.daemon = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg === '--help' || arg === '-h') {
|
|
53
|
+
flags.help = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--version' || arg === '-v') {
|
|
57
|
+
flags.version = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === '--lang') {
|
|
61
|
+
flags.lang = argv[index + 1];
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg.startsWith('--lang=')) {
|
|
66
|
+
flags.lang = arg.slice('--lang='.length);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith('-')) {
|
|
70
|
+
flags.unknown = arg;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
positional.push(arg);
|
|
74
|
+
}
|
|
75
|
+
return { positional, flags };
|
|
76
|
+
}
|
|
77
|
+
function printJson(value) {
|
|
78
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
79
|
+
}
|
|
80
|
+
function describeStatus(status, t) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`${t('overview.mode')}: ${t(`mode.${status.mode}`)}`);
|
|
83
|
+
if (status.relay.local) {
|
|
84
|
+
lines.push(`${t('overview.relay')}: ${status.relay.alive ? t('common.running') : t('common.stopped')} (${status.relay.bind}:${status.relay.port})`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
lines.push(`${t('overview.relay')}: ${status.relay.remoteUrl}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push(`${t('overview.host')}: ${status.host.alive ? t('common.running') : t('common.stopped')}`);
|
|
90
|
+
lines.push(`${t('overview.socket')}: ${status.host.socketPath}${status.host.socketExists ? '' : ` (${t('overview.socketMissing')})`}`);
|
|
91
|
+
lines.push(`${t('overview.webUrl')}: ${status.publicUrl}`);
|
|
92
|
+
if (status.keepalive) {
|
|
93
|
+
const state = status.keepalive.active
|
|
94
|
+
? t('common.running')
|
|
95
|
+
: status.keepalive.installed
|
|
96
|
+
? t('common.installed')
|
|
97
|
+
: t('common.notInstalled');
|
|
98
|
+
lines.push(`${t('overview.keepalive')}: ${status.keepalive.manager} — ${state}`);
|
|
99
|
+
}
|
|
100
|
+
if (status.relay.health && status.relay.health.ok === false) {
|
|
101
|
+
lines.push(`${t('overview.relay')}: ${t('overview.unreachable', { message: status.relay.health.message ?? '' })}`);
|
|
102
|
+
}
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
async function runTui(options) {
|
|
106
|
+
const { startTui } = await import('./tui/index.js');
|
|
107
|
+
await startTui(options);
|
|
108
|
+
}
|
|
109
|
+
async function main(argv = process.argv.slice(2)) {
|
|
110
|
+
const { positional, flags } = parseArgs(argv);
|
|
111
|
+
const command = positional[0] || null;
|
|
112
|
+
// Answered before anything reads the config or queries the terminal.
|
|
113
|
+
if (flags.help) {
|
|
114
|
+
process.stdout.write(USAGE);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (flags.version) {
|
|
118
|
+
process.stdout.write(`${VERSION}\n`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (flags.unknown) {
|
|
122
|
+
process.stderr.write(`herdr-remote: unknown option "${flags.unknown}"\n\n${USAGE}`);
|
|
123
|
+
process.exitCode = 2;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
migrateLegacyConfig();
|
|
127
|
+
// Ask the terminal for its colors while it is still a plain terminal: the
|
|
128
|
+
// TUI takes the screen a few lines below, and the services this command
|
|
129
|
+
// starts usually run detached with nothing to ask. Everything downstream
|
|
130
|
+
// inherits the answer through the environment.
|
|
131
|
+
(await import('./terminal-palette.js')).captureTerminalPalette();
|
|
132
|
+
// The font, likewise, is read while this process still knows which terminal
|
|
133
|
+
// it runs in: its variables and its parent process are gone once a service
|
|
134
|
+
// manager or Herdr's detached server starts the connector.
|
|
135
|
+
(await import('./terminal-font/index.js')).captureTerminalFont();
|
|
136
|
+
const config = loadConfig();
|
|
137
|
+
const preference = flags.lang && LANGUAGES.includes(flags.lang)
|
|
138
|
+
? flags.lang
|
|
139
|
+
: config.ui.language;
|
|
140
|
+
const t = createTranslator(detectLocale({ preference }));
|
|
141
|
+
// Loaded lazily so that the TUI does not pay for loading the
|
|
142
|
+
// service layer (which pulls in the relay package and node-pty).
|
|
143
|
+
const lifecycle = await import('./lifecycle.js');
|
|
144
|
+
const service = await import('./service.js');
|
|
145
|
+
switch (command) {
|
|
146
|
+
case null: {
|
|
147
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
148
|
+
await runTui({ language: flags.lang || null });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Herdr runs plugin panes and actions without a TTY; emit status instead
|
|
152
|
+
// of trying to paint an interface into a pipe.
|
|
153
|
+
printJson(await lifecycle.fullStatus(config));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
case 'start': {
|
|
157
|
+
const result = lifecycle.startAll(config);
|
|
158
|
+
if (flags.json)
|
|
159
|
+
printJson(result);
|
|
160
|
+
else
|
|
161
|
+
process.stdout.write(`${t('services.started')}\n${describeStatus(await lifecycle.fullStatus(config), t)}\n`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case 'stop': {
|
|
165
|
+
const result = lifecycle.stopAll(config);
|
|
166
|
+
if (flags.json)
|
|
167
|
+
printJson(result);
|
|
168
|
+
else
|
|
169
|
+
process.stdout.write(`${t('services.stopped')}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
case 'restart': {
|
|
173
|
+
const result = lifecycle.restartAll(config);
|
|
174
|
+
if (flags.json)
|
|
175
|
+
printJson(result);
|
|
176
|
+
else
|
|
177
|
+
process.stdout.write(`${t('services.restarted')}\n`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case 'status': {
|
|
181
|
+
const status = await lifecycle.fullStatus(config);
|
|
182
|
+
if (flags.json)
|
|
183
|
+
printJson(status);
|
|
184
|
+
else
|
|
185
|
+
process.stdout.write(`${describeStatus(status, t)}\n`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case 'pair': {
|
|
189
|
+
const pairing = await service.pair();
|
|
190
|
+
if (flags.json) {
|
|
191
|
+
printJson(pairing);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const code = extractPairingCode(pairing);
|
|
195
|
+
const url = pairing.pairUrl ||
|
|
196
|
+
`${resolvePublicUrl(config, preferredLanAddress())}/?pairCode=${encodeURIComponent(code)}`;
|
|
197
|
+
const minutes = `${Math.round((config.auth.pairingTtlMs || 600000) / 60000)}m`;
|
|
198
|
+
process.stdout.write(`${t('pair.code')}: ${code}\n${t('pair.url')}: ${url}\n${t('pair.expires', { minutes, time: new Date(pairing.expiresAt).toLocaleTimeString() })}\n`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
case 'url': {
|
|
202
|
+
process.stdout.write(`${resolvePublicUrl(config, preferredLanAddress())}\n`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case 'run': {
|
|
206
|
+
const { runForeground } = await import('./supervisor.js');
|
|
207
|
+
await runForeground({ logToFiles: Boolean(flags.daemon) });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case 'keepalive': {
|
|
211
|
+
const keepalive = await import('./keepalive/index.js');
|
|
212
|
+
const action = positional[1] || 'status';
|
|
213
|
+
if (action === 'install')
|
|
214
|
+
printJson(keepalive.install(config));
|
|
215
|
+
else if (action === 'uninstall')
|
|
216
|
+
printJson(keepalive.uninstall(config));
|
|
217
|
+
else if (action === 'restart')
|
|
218
|
+
printJson(keepalive.restart(config));
|
|
219
|
+
else if (action === 'status')
|
|
220
|
+
printJson(keepalive.status(config));
|
|
221
|
+
else
|
|
222
|
+
throw new Error(`unknown keepalive action: ${action}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case 'plugin': {
|
|
226
|
+
const plugin = await import('./herdr-plugin.js');
|
|
227
|
+
const action = positional[1] || 'status';
|
|
228
|
+
if (action === 'link')
|
|
229
|
+
printJson(plugin.register());
|
|
230
|
+
else if (action === 'unlink')
|
|
231
|
+
printJson(plugin.unregister());
|
|
232
|
+
else if (action === 'status')
|
|
233
|
+
printJson(plugin.registrationStatus());
|
|
234
|
+
else
|
|
235
|
+
throw new Error(`unknown plugin action: ${action}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case 'config': {
|
|
239
|
+
// Handy when reporting a problem: where does this install read from?
|
|
240
|
+
printJson({ configPath: configPath(), stateDir: stateDir(), config });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
default:
|
|
244
|
+
process.stderr.write(`herdr-remote: unknown command "${command}"\n\n${USAGE}`);
|
|
245
|
+
process.exitCode = 2;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Runs the command line and reports a failure as the exit code. */
|
|
249
|
+
function run() {
|
|
250
|
+
main().catch((error) => {
|
|
251
|
+
process.stderr.write(`herdr-remote: ${error.message}\n`);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
export { run, parseArgs, describeStatus };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The configuration file: its shape, its defaults, and how a file on disk,
|
|
2
|
+
// older schemas and the environment become one validated Config.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { DEFAULTS as RELAY_DEFAULTS } from 'herdr-remote-relay/config';
|
|
7
|
+
import { configPath, legacyConfigPath } from './paths.js';
|
|
8
|
+
import { isLoopbackHost, isUnspecifiedAddress, isUnspecifiedHost, normalizeUrl, } from './relay-urls.js';
|
|
9
|
+
// Access modes
|
|
10
|
+
// local — a relay runs on this machine, reachable only from this machine.
|
|
11
|
+
// lan — a relay runs on this machine, bound to every interface so other
|
|
12
|
+
// devices on the LAN (or on a Tailscale/WireGuard overlay) reach it.
|
|
13
|
+
// remote — no local relay; the host connector dials an operator-run relay,
|
|
14
|
+
// which is the only way in from outside the local network.
|
|
15
|
+
const ACCESS_MODES = ['local', 'lan', 'remote'];
|
|
16
|
+
// The relay this project runs for people who do not want to host one. Offered
|
|
17
|
+
// as a one-keystroke choice during setup; it is never selected automatically,
|
|
18
|
+
// because routing a terminal through someone else's server has to be a
|
|
19
|
+
// deliberate decision. It is a plain public relay: no join password, and each
|
|
20
|
+
// workstation stays reachable only through its own host token.
|
|
21
|
+
const OFFICIAL_RELAY_URL = 'wss://herdr-remote.564616.xyz';
|
|
22
|
+
const LANGUAGES = ['auto', 'zh', 'en'];
|
|
23
|
+
const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
|
|
24
|
+
function isOneOf(list, value) {
|
|
25
|
+
return list.includes(value);
|
|
26
|
+
}
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
ui: {
|
|
29
|
+
language: 'auto',
|
|
30
|
+
},
|
|
31
|
+
relay: {
|
|
32
|
+
mode: 'local',
|
|
33
|
+
port: 8787,
|
|
34
|
+
// Address advertised in pairing URLs when mode is "lan". Empty means "pick
|
|
35
|
+
// the first non-internal IPv4 automatically".
|
|
36
|
+
lanHost: '',
|
|
37
|
+
// Manual override for the URL browsers open. Empty means "derive it".
|
|
38
|
+
publicUrl: '',
|
|
39
|
+
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
40
|
+
remoteUrl: '',
|
|
41
|
+
// The CLI starts a relay of its own, so its limits are the relay
|
|
42
|
+
// package's: a smaller ceiling here would reject an upload the hosted
|
|
43
|
+
// relay accepts.
|
|
44
|
+
maxPayloadBytes: RELAY_DEFAULTS.relay.maxPayloadBytes,
|
|
45
|
+
maxClientsPerHost: RELAY_DEFAULTS.relay.maxClientsPerHost,
|
|
46
|
+
maxHosts: RELAY_DEFAULTS.relay.maxHosts,
|
|
47
|
+
maxPendingHandshakes: RELAY_DEFAULTS.relay.maxPendingHandshakes,
|
|
48
|
+
maxBufferedBytesPerClient: RELAY_DEFAULTS.relay.maxBufferedBytesPerClient,
|
|
49
|
+
hostReconnectGraceMs: RELAY_DEFAULTS.relay.hostReconnectGraceMs,
|
|
50
|
+
allowedOrigins: [],
|
|
51
|
+
},
|
|
52
|
+
herdr: {
|
|
53
|
+
socketPath: null,
|
|
54
|
+
args: [],
|
|
55
|
+
cwd: os.homedir(),
|
|
56
|
+
// Start Herdr's server when herdr-remote starts (at boot, under keep-alive).
|
|
57
|
+
// Off: Herdr starts when the user runs it, or confirms it from the WebUI.
|
|
58
|
+
autoStart: false,
|
|
59
|
+
},
|
|
60
|
+
auth: {
|
|
61
|
+
pairingTtlMs: RELAY_DEFAULTS.auth.pairingTtlMs,
|
|
62
|
+
deviceTtlMs: RELAY_DEFAULTS.auth.deviceTtlMs,
|
|
63
|
+
maxDevices: RELAY_DEFAULTS.auth.maxDevices,
|
|
64
|
+
},
|
|
65
|
+
cleanup: { ...RELAY_DEFAULTS.cleanup },
|
|
66
|
+
keepalive: {
|
|
67
|
+
manager: 'auto',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
function clone(value) {
|
|
71
|
+
return JSON.parse(JSON.stringify(value));
|
|
72
|
+
}
|
|
73
|
+
function readJson(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const { code, message } = error;
|
|
79
|
+
if (code !== 'ENOENT') {
|
|
80
|
+
process.stderr.write(`herdr-remote: ignoring invalid JSON at ${filePath}: ${message}\n`);
|
|
81
|
+
}
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Copy a config written by an older, plugin-scoped install into the canonical
|
|
87
|
+
* location. Runs at most once: it never overwrites an existing config.
|
|
88
|
+
*/
|
|
89
|
+
function migrateLegacyConfig() {
|
|
90
|
+
const target = configPath();
|
|
91
|
+
if (fs.existsSync(target))
|
|
92
|
+
return { migrated: false, reason: 'config already exists' };
|
|
93
|
+
const legacy = legacyConfigPath();
|
|
94
|
+
if (!legacy || !fs.existsSync(legacy))
|
|
95
|
+
return { migrated: false, reason: 'no legacy config' };
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
98
|
+
fs.copyFileSync(legacy, target);
|
|
99
|
+
return { migrated: true, from: legacy, to: target };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { migrated: false, reason: error.message };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function mergeConfig(fileConfig) {
|
|
106
|
+
const config = clone(DEFAULTS);
|
|
107
|
+
const file = (fileConfig ?? {});
|
|
108
|
+
for (const section of Object.keys(config)) {
|
|
109
|
+
const values = file[section];
|
|
110
|
+
if (values && typeof values === 'object')
|
|
111
|
+
Object.assign(config[section], values);
|
|
112
|
+
}
|
|
113
|
+
return config;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Accept configs written before access modes existed. The 0.1 schema used
|
|
117
|
+
* `relay.local` (boolean), `relay.host` (bind address) and `relay.url`.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeLegacyFields(config, fileConfig) {
|
|
120
|
+
const legacy = fileConfig?.relay || {};
|
|
121
|
+
// The absence of an explicit `mode` — not an invalid one — is what marks a
|
|
122
|
+
// file as pre-0.2, since merging defaults always leaves a valid mode behind.
|
|
123
|
+
const hasExplicitMode = isOneOf(ACCESS_MODES, legacy.mode);
|
|
124
|
+
if (!hasExplicitMode) {
|
|
125
|
+
if (legacy.local === false && typeof legacy.url === 'string' && legacy.url) {
|
|
126
|
+
config.relay.mode = 'remote';
|
|
127
|
+
if (!config.relay.remoteUrl)
|
|
128
|
+
config.relay.remoteUrl = legacy.url;
|
|
129
|
+
}
|
|
130
|
+
else if (typeof legacy.host === 'string' && legacy.host && !isLoopbackHost(legacy.host)) {
|
|
131
|
+
config.relay.mode = 'lan';
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
config.relay.mode = 'local';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (config.relay.mode === 'lan' &&
|
|
138
|
+
!config.relay.lanHost &&
|
|
139
|
+
typeof legacy.host === 'string' &&
|
|
140
|
+
!isLoopbackHost(legacy.host) &&
|
|
141
|
+
legacy.host !== '0.0.0.0') {
|
|
142
|
+
config.relay.lanHost = legacy.host;
|
|
143
|
+
}
|
|
144
|
+
// Superseded keys copied in by the merge would otherwise reappear in the
|
|
145
|
+
// in-memory config and confuse anything reading it.
|
|
146
|
+
delete config.relay.local;
|
|
147
|
+
delete config.relay.host;
|
|
148
|
+
delete config.relay.url;
|
|
149
|
+
// `publicUrl` used to be mandatory and defaulted to the loopback URL; treat
|
|
150
|
+
// that default as "no override" so derivation can take over.
|
|
151
|
+
if (typeof config.relay.publicUrl === 'string' &&
|
|
152
|
+
/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/?$/.test(config.relay.publicUrl)) {
|
|
153
|
+
config.relay.publicUrl = '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function parseInteger(value, fallback, min, max) {
|
|
157
|
+
const numeric = Number(value);
|
|
158
|
+
if (!Number.isInteger(numeric) || numeric < min || numeric > max)
|
|
159
|
+
return fallback;
|
|
160
|
+
return numeric;
|
|
161
|
+
}
|
|
162
|
+
function validate(config) {
|
|
163
|
+
if (!isOneOf(ACCESS_MODES, config.relay.mode))
|
|
164
|
+
config.relay.mode = DEFAULTS.relay.mode;
|
|
165
|
+
if (!isOneOf(LANGUAGES, config.ui.language))
|
|
166
|
+
config.ui.language = DEFAULTS.ui.language;
|
|
167
|
+
if (!isOneOf(KEEPALIVE_MANAGERS, config.keepalive.manager))
|
|
168
|
+
config.keepalive.manager = DEFAULTS.keepalive.manager;
|
|
169
|
+
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
|
|
170
|
+
config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
|
|
171
|
+
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
172
|
+
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
|
|
173
|
+
config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
|
|
174
|
+
config.relay.maxBufferedBytesPerClient = parseInteger(config.relay.maxBufferedBytesPerClient, DEFAULTS.relay.maxBufferedBytesPerClient, 64 * 1024, 256 * 1024 * 1024);
|
|
175
|
+
config.relay.hostReconnectGraceMs = parseInteger(config.relay.hostReconnectGraceMs, DEFAULTS.relay.hostReconnectGraceMs, 1000, 24 * 60 * 60 * 1000);
|
|
176
|
+
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
177
|
+
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
178
|
+
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|
|
179
|
+
config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
|
|
180
|
+
config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
|
|
181
|
+
config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
|
|
182
|
+
config.relay.publicUrl = normalizeUrl(config.relay.publicUrl);
|
|
183
|
+
config.relay.remoteUrl = normalizeUrl(config.relay.remoteUrl);
|
|
184
|
+
config.relay.lanHost =
|
|
185
|
+
typeof config.relay.lanHost === 'string' ? config.relay.lanHost.trim() : '';
|
|
186
|
+
// A LAN relay listens on every interface, but a loopback or wildcard value
|
|
187
|
+
// cannot be opened by another device. Clear stale values from older TUI
|
|
188
|
+
// versions so the advertised URL falls back to a real interface address.
|
|
189
|
+
if (config.relay.mode === 'lan' &&
|
|
190
|
+
(isLoopbackHost(config.relay.lanHost) || isUnspecifiedAddress(config.relay.lanHost))) {
|
|
191
|
+
config.relay.lanHost = '';
|
|
192
|
+
}
|
|
193
|
+
// 0.0.0.0 is a server-side bind wildcard, never a destination a browser can
|
|
194
|
+
// open. Treat it like an empty override so LAN mode derives the real address.
|
|
195
|
+
if (isUnspecifiedHost(config.relay.publicUrl))
|
|
196
|
+
config.relay.publicUrl = '';
|
|
197
|
+
if (!Array.isArray(config.relay.allowedOrigins))
|
|
198
|
+
config.relay.allowedOrigins = [];
|
|
199
|
+
if (!Array.isArray(config.herdr.args) ||
|
|
200
|
+
!config.herdr.args.every((arg) => typeof arg === 'string')) {
|
|
201
|
+
config.herdr.args = [];
|
|
202
|
+
}
|
|
203
|
+
const args = config.herdr.args;
|
|
204
|
+
// Herdr 0.9.0 removed --no-session; retaining it in saved configs causes
|
|
205
|
+
// session creation to fail. Filter it out if present.
|
|
206
|
+
if (args.includes('--no-session')) {
|
|
207
|
+
config.herdr.args = args.filter((arg) => arg !== '--no-session');
|
|
208
|
+
process.stderr.write('herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n');
|
|
209
|
+
}
|
|
210
|
+
if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
|
|
211
|
+
config.herdr.socketPath = null;
|
|
212
|
+
}
|
|
213
|
+
if (typeof config.herdr.cwd !== 'string' || config.herdr.cwd.length === 0) {
|
|
214
|
+
config.herdr.cwd = os.homedir();
|
|
215
|
+
}
|
|
216
|
+
config.herdr.autoStart = config.herdr.autoStart === true;
|
|
217
|
+
// A "remote" config without a relay URL cannot reach anything; fall back to
|
|
218
|
+
// local rather than silently starting a host connector that dials nowhere.
|
|
219
|
+
if (config.relay.mode === 'remote' && !config.relay.remoteUrl) {
|
|
220
|
+
config.relay.mode = 'local';
|
|
221
|
+
}
|
|
222
|
+
// Every field `Config` promises has been normalised above.
|
|
223
|
+
return config;
|
|
224
|
+
}
|
|
225
|
+
function applyEnvironment(config) {
|
|
226
|
+
const env = process.env;
|
|
227
|
+
if (env.HERDR_REMOTE_LANG && isOneOf(LANGUAGES, env.HERDR_REMOTE_LANG))
|
|
228
|
+
config.ui.language = env.HERDR_REMOTE_LANG;
|
|
229
|
+
if (env.HERDR_REMOTE_MODE && isOneOf(ACCESS_MODES, env.HERDR_REMOTE_MODE))
|
|
230
|
+
config.relay.mode = env.HERDR_REMOTE_MODE;
|
|
231
|
+
if (env.RELAY_PORT)
|
|
232
|
+
config.relay.port = env.RELAY_PORT;
|
|
233
|
+
if (env.RELAY_PUBLIC_URL)
|
|
234
|
+
config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
235
|
+
if (env.RELAY_REMOTE_URL)
|
|
236
|
+
config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
237
|
+
if (env.RELAY_MAX_HOSTS)
|
|
238
|
+
config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
239
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES)
|
|
240
|
+
config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
241
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT)
|
|
242
|
+
config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
243
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS)
|
|
244
|
+
config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
245
|
+
if (env.HERDR_SOCKET_PATH)
|
|
246
|
+
config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
247
|
+
if (env.HERDR_CWD)
|
|
248
|
+
config.herdr.cwd = env.HERDR_CWD;
|
|
249
|
+
if (env.HERDR_ARGS_JSON) {
|
|
250
|
+
try {
|
|
251
|
+
const args = JSON.parse(env.HERDR_ARGS_JSON);
|
|
252
|
+
if (Array.isArray(args))
|
|
253
|
+
config.herdr.args = args;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
process.stderr.write(`herdr-remote: invalid HERDR_ARGS_JSON: ${error.message}\n`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function loadConfig() {
|
|
261
|
+
const fileConfig = readJson(configPath());
|
|
262
|
+
const draft = mergeConfig(fileConfig);
|
|
263
|
+
normalizeLegacyFields(draft, fileConfig);
|
|
264
|
+
applyEnvironment(draft);
|
|
265
|
+
return validate(draft);
|
|
266
|
+
}
|
|
267
|
+
function configExists() {
|
|
268
|
+
return fs.existsSync(configPath());
|
|
269
|
+
}
|
|
270
|
+
export { DEFAULTS, ACCESS_MODES, OFFICIAL_RELAY_URL, LANGUAGES, KEEPALIVE_MANAGERS, configExists, migrateLegacyConfig, loadConfig, validate, };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Watching what the agents on this workstation are doing, for the status bar
|
|
2
|
+
// of every window attached to it.
|
|
3
|
+
import { sameSummary, summarizeAgents, } from '../agent-status.js';
|
|
4
|
+
import { requestHerdr, subscribeHerdr, } from '../herdr-api.js';
|
|
5
|
+
/** How often the agent summary is re-read while a browser is watching. */
|
|
6
|
+
const AGENT_STATUS_POLL_MS = 5_000;
|
|
7
|
+
const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
|
|
8
|
+
const AGENT_STATUS_SUBSCRIPTIONS = [
|
|
9
|
+
{ type: 'pane.focused' },
|
|
10
|
+
{ type: 'tab.focused' },
|
|
11
|
+
{ type: 'workspace.focused' },
|
|
12
|
+
{ type: 'pane.agent_detected' },
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* A side channel, on its own socket, alive only while a browser is attached.
|
|
16
|
+
* Herdr's server can be restarted underneath a running herdr-remote — the
|
|
17
|
+
* PTYs survive it — so every failure here is a retry, never anything a
|
|
18
|
+
* terminal session notices.
|
|
19
|
+
*
|
|
20
|
+
* A long-lived subscription provides fast refreshes on pane and workspace
|
|
21
|
+
* focus changes; the five-second poll still repairs missed events and a
|
|
22
|
+
* restarted Herdr server. Both paths only read the side-channel snapshot.
|
|
23
|
+
*/
|
|
24
|
+
export class AgentWatch {
|
|
25
|
+
socketPath;
|
|
26
|
+
request;
|
|
27
|
+
subscribe;
|
|
28
|
+
send;
|
|
29
|
+
pollTimer = null;
|
|
30
|
+
debounceTimer = null;
|
|
31
|
+
subscription = null;
|
|
32
|
+
pending = false;
|
|
33
|
+
lastSummary = null;
|
|
34
|
+
constructor(options, send) {
|
|
35
|
+
this.socketPath = options.socketPath;
|
|
36
|
+
this.request = options.requestHerdr || requestHerdr;
|
|
37
|
+
this.subscribe = options.subscribeHerdr || subscribeHerdr;
|
|
38
|
+
this.send = send;
|
|
39
|
+
}
|
|
40
|
+
start() {
|
|
41
|
+
if (this.pollTimer)
|
|
42
|
+
return;
|
|
43
|
+
this.pollTimer = setInterval(() => this.read(), AGENT_STATUS_POLL_MS);
|
|
44
|
+
this.pollTimer.unref?.();
|
|
45
|
+
try {
|
|
46
|
+
this.subscription = this.subscribe(this.socketPath, AGENT_STATUS_SUBSCRIPTIONS, () => this.scheduleRead());
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A missing or restarting Herdr server leaves the regular poll in place.
|
|
50
|
+
}
|
|
51
|
+
this.read();
|
|
52
|
+
}
|
|
53
|
+
stop() {
|
|
54
|
+
if (this.pollTimer) {
|
|
55
|
+
clearInterval(this.pollTimer);
|
|
56
|
+
this.pollTimer = null;
|
|
57
|
+
}
|
|
58
|
+
if (this.debounceTimer) {
|
|
59
|
+
clearTimeout(this.debounceTimer);
|
|
60
|
+
this.debounceTimer = null;
|
|
61
|
+
}
|
|
62
|
+
if (this.subscription) {
|
|
63
|
+
this.subscription.close();
|
|
64
|
+
this.subscription = null;
|
|
65
|
+
}
|
|
66
|
+
this.lastSummary = null;
|
|
67
|
+
}
|
|
68
|
+
scheduleRead() {
|
|
69
|
+
if (!this.pollTimer)
|
|
70
|
+
return;
|
|
71
|
+
if (this.debounceTimer)
|
|
72
|
+
clearTimeout(this.debounceTimer);
|
|
73
|
+
this.debounceTimer = setTimeout(() => {
|
|
74
|
+
this.debounceTimer = null;
|
|
75
|
+
this.read();
|
|
76
|
+
}, AGENT_STATUS_EVENT_DEBOUNCE_MS);
|
|
77
|
+
this.debounceTimer.unref?.();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read the summary and forward it if it changed.
|
|
81
|
+
*
|
|
82
|
+
* A slow answer must not stack up behind the next tick, and a failed one
|
|
83
|
+
* leaves the last summary standing rather than blanking the browser's chip:
|
|
84
|
+
* Herdr's server not running is an ordinary state, not news.
|
|
85
|
+
*/
|
|
86
|
+
async read() {
|
|
87
|
+
if (this.pending || !this.pollTimer)
|
|
88
|
+
return;
|
|
89
|
+
this.pending = true;
|
|
90
|
+
let snapshot;
|
|
91
|
+
try {
|
|
92
|
+
const result = await this.request(this.socketPath, 'session.snapshot', {});
|
|
93
|
+
snapshot = result?.snapshot || result;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this.pending = false;
|
|
100
|
+
}
|
|
101
|
+
// Stopped while the answer was in flight; the browser it was for is gone.
|
|
102
|
+
if (!this.pollTimer)
|
|
103
|
+
return;
|
|
104
|
+
const summary = summarizeAgents(snapshot);
|
|
105
|
+
if (sameSummary(this.lastSummary, summary))
|
|
106
|
+
return;
|
|
107
|
+
this.lastSummary = summary;
|
|
108
|
+
this.send({ type: 'agent_status', ...summary });
|
|
109
|
+
}
|
|
110
|
+
}
|