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.
Files changed (95) hide show
  1. package/bin/herdr-remote.js +3 -225
  2. package/dist/agent-status.js +130 -0
  3. package/dist/binary-plist.js +157 -0
  4. package/dist/cli.js +255 -0
  5. package/dist/config.js +270 -0
  6. package/dist/connector/agent-watch.js +110 -0
  7. package/dist/connector/font-server.js +165 -0
  8. package/dist/connector/host-connector.js +430 -0
  9. package/dist/connector/lock.js +66 -0
  10. package/dist/connector/main.js +18 -0
  11. package/dist/connector/sessions.js +366 -0
  12. package/dist/connector/update-report.js +67 -0
  13. package/dist/connector/wire.js +42 -0
  14. package/{src → dist}/exit-codes.js +1 -4
  15. package/dist/font-subset.js +110 -0
  16. package/dist/herdr-api.js +246 -0
  17. package/dist/herdr-command.js +223 -0
  18. package/dist/herdr-plugin.js +116 -0
  19. package/dist/herdr-server.js +189 -0
  20. package/dist/i18n/en.js +223 -0
  21. package/dist/i18n/index.js +48 -0
  22. package/dist/i18n/zh.js +222 -0
  23. package/dist/keepalive/detached.js +78 -0
  24. package/dist/keepalive/environment.js +44 -0
  25. package/dist/keepalive/index.js +110 -0
  26. package/dist/keepalive/launchd.js +117 -0
  27. package/dist/keepalive/systemd.js +108 -0
  28. package/dist/keepalive/types.js +1 -0
  29. package/dist/lib/process.js +12 -0
  30. package/dist/lifecycle.js +59 -0
  31. package/dist/net-interfaces.js +73 -0
  32. package/dist/pasted-files.js +115 -0
  33. package/dist/paths.js +62 -0
  34. package/dist/pty-session.js +96 -0
  35. package/dist/relay-client.js +106 -0
  36. package/dist/relay-urls.js +99 -0
  37. package/dist/runtime.js +106 -0
  38. package/dist/service.js +260 -0
  39. package/dist/settings-model.js +298 -0
  40. package/dist/socket-discovery.js +48 -0
  41. package/dist/supervisor.js +319 -0
  42. package/dist/terminal-font/deps.js +73 -0
  43. package/dist/terminal-font/files.js +354 -0
  44. package/dist/terminal-font/formats.js +335 -0
  45. package/dist/terminal-font/index.js +187 -0
  46. package/dist/terminal-font/readers.js +316 -0
  47. package/dist/terminal-font/terminals.js +128 -0
  48. package/dist/terminal-palette.js +339 -0
  49. package/dist/tui/App.js +195 -0
  50. package/dist/tui/api.js +79 -0
  51. package/dist/tui/components/TextField.js +50 -0
  52. package/dist/tui/components/common.js +80 -0
  53. package/dist/tui/index.js +36 -0
  54. package/dist/tui/mouse/index.js +164 -0
  55. package/dist/tui/mouse/protocol.js +96 -0
  56. package/dist/tui/mouse/source.js +74 -0
  57. package/dist/tui/screens/About.js +166 -0
  58. package/dist/tui/screens/Herdr.js +74 -0
  59. package/dist/tui/screens/Keepalive.js +84 -0
  60. package/dist/tui/screens/Overview.js +36 -0
  61. package/dist/tui/screens/Pair.js +74 -0
  62. package/dist/tui/screens/Relay.js +242 -0
  63. package/dist/tui/screens/Services.js +63 -0
  64. package/dist/tui/screens/Wizard.js +235 -0
  65. package/dist/tui/terminal.js +39 -0
  66. package/dist/tui/theme.js +33 -0
  67. package/dist/updater.js +382 -0
  68. package/herdr-plugin.toml +1 -1
  69. package/package.json +8 -8
  70. package/dist/tui.mjs +0 -5775
  71. package/src/agent-status.js +0 -139
  72. package/src/binary-plist.js +0 -146
  73. package/src/config.js +0 -454
  74. package/src/font-subset.js +0 -110
  75. package/src/herdr-api.js +0 -244
  76. package/src/herdr-command.js +0 -231
  77. package/src/herdr-plugin.js +0 -130
  78. package/src/herdr-server.js +0 -201
  79. package/src/host-connector.js +0 -1069
  80. package/src/i18n/en.js +0 -240
  81. package/src/i18n/index.js +0 -56
  82. package/src/i18n/zh.js +0 -239
  83. package/src/keepalive.js +0 -460
  84. package/src/lifecycle.js +0 -62
  85. package/src/net-interfaces.js +0 -76
  86. package/src/pasted-files.js +0 -176
  87. package/src/pty-session.js +0 -92
  88. package/src/service.js +0 -530
  89. package/src/settings-model.js +0 -323
  90. package/src/socket-discovery.js +0 -60
  91. package/src/state.js +0 -47
  92. package/src/supervisor.js +0 -250
  93. package/src/terminal-font.js +0 -1204
  94. package/src/terminal-palette.js +0 -325
  95. package/src/updater.js +0 -394
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Reads the colors of the terminal the workstation is actually looking at.
3
+ *
4
+ * A browser cannot know what a Herdr session looks like on the host: the PTY
5
+ * only carries color *indices*, and whoever renders them decides what "red"
6
+ * or "the default background" means. So the host asks its own terminal, once,
7
+ * with the standard OSC color queries every mainstream emulator answers:
8
+ *
9
+ * OSC 10 ; ? — default foreground
10
+ * OSC 11 ; ? — default background
11
+ * OSC 12 ; ? — cursor
12
+ * OSC 4 ; n ; ? — ANSI slot n (0-15)
13
+ *
14
+ * The answers travel to the browser through `host_hello`, and xterm renders
15
+ * with them. Nothing here guesses: without a terminal to ask, the result is
16
+ * `null` and the browser keeps xterm's own defaults.
17
+ */
18
+ import fs from 'node:fs';
19
+ import { spawnSync } from 'node:child_process';
20
+ // One definition of what a palette may contain, shared with the relay so the
21
+ // host cannot report a shape the wire rejects.
22
+ import { ANSI_PALETTE_KEYS, sanitizeTerminalPalette, } from 'herdr-remote-relay/protocol';
23
+ import { readRuntime, updateRuntime } from './runtime.js';
24
+ const ANSI_SLOTS = ANSI_PALETTE_KEYS.length;
25
+ /**
26
+ * VTIME in tenths of a second: the terminal driver returns from a read after
27
+ * this long even with nothing to read, which is what bounds a silent terminal
28
+ * without burning CPU in a poll loop.
29
+ */
30
+ const READ_TIMEOUT_TENTHS = '3';
31
+ /**
32
+ * Two empty reads in a row mean this query will not be answered.
33
+ *
34
+ * The resulting ~600ms per query is not padding: with a multiplexer between
35
+ * this process and the emulator, the emulator's answer measurably arrives
36
+ * later than 400ms, and giving up sooner makes the multiplexer answer from its
37
+ * own configuration instead — a palette that is not the one on screen.
38
+ */
39
+ const MAX_IDLE_READS = 2;
40
+ /**
41
+ * The whole probe is bounded: a start must never wait on a terminal that
42
+ * answers slowly, or not at all. Sized for the nineteen queries a fully
43
+ * answering terminal replies to in milliseconds, with room for the mixed case
44
+ * where the ANSI ramp is reported but the default colors are not.
45
+ */
46
+ const TOTAL_TIMEOUT_MS = 3000;
47
+ const ANSI_KEYS = ANSI_PALETTE_KEYS;
48
+ /**
49
+ * `rgb:RRRR/GGGG/BBBB` (and the 1/2/3-digit variants) to `#rrggbb`.
50
+ * Terminals answer in 16-bit-per-channel notation; the top byte is the color.
51
+ */
52
+ function parseXColor(value) {
53
+ if (typeof value !== 'string')
54
+ return null;
55
+ const match = /^rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(value.trim());
56
+ if (!match)
57
+ return null;
58
+ const channels = match.slice(1, 4).map((raw) => {
59
+ const width = raw.length;
60
+ if (width === 0 || width > 4)
61
+ return null;
62
+ const scaled = Math.round((parseInt(raw, 16) / (16 ** width - 1)) * 255);
63
+ return Math.max(0, Math.min(255, scaled));
64
+ });
65
+ if (channels.some((channel) => channel === null))
66
+ return null;
67
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
68
+ }
69
+ /**
70
+ * Locates one complete OSC reply, e.g. `ESC ] 11 ; rgb:2222/2222/2626 ESC \`.
71
+ *
72
+ * A reply is only complete once its terminator arrives, so a half-read answer
73
+ * waits for the rest of the bytes instead of being parsed out of a fragment.
74
+ */
75
+ function findOscColorReply(text, expectedPrefix) {
76
+ if (typeof text !== 'string')
77
+ return null;
78
+ const start = text.indexOf(`${expectedPrefix};`);
79
+ if (start === -1)
80
+ return null;
81
+ const stringTerminator = text.indexOf('\x1b\\', start);
82
+ const bell = text.indexOf('\x07', start);
83
+ let bodyEnd = -1;
84
+ let end = -1;
85
+ if (stringTerminator !== -1 && (bell === -1 || stringTerminator < bell)) {
86
+ bodyEnd = stringTerminator;
87
+ end = stringTerminator + 2;
88
+ }
89
+ else if (bell !== -1) {
90
+ bodyEnd = bell;
91
+ end = bell + 1;
92
+ }
93
+ else {
94
+ return null;
95
+ }
96
+ const color = parseXColor(text.slice(start + expectedPrefix.length + 1, bodyEnd).trim());
97
+ return color ? { color, start, end } : null;
98
+ }
99
+ /** Pulls the color out of one OSC reply. */
100
+ function parseOscColorReply(reply, expectedPrefix) {
101
+ return findOscColorReply(reply, expectedPrefix)?.color ?? null;
102
+ }
103
+ /** A palette is only usable if every field it claims to have is a real color. */
104
+ const sanitizePalette = sanitizeTerminalPalette;
105
+ /** Reads a palette a parent process already captured, so children never re-probe. */
106
+ function paletteFromEnvironment(env = process.env) {
107
+ const raw = env.HERDR_TERM_PALETTE_JSON;
108
+ if (!raw)
109
+ return null;
110
+ try {
111
+ return sanitizePalette(JSON.parse(raw));
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function runStty(args, ttyPath) {
118
+ return spawnSync('stty', [...args, '-F', ttyPath], { encoding: 'utf8', timeout: 1000 });
119
+ }
120
+ /**
121
+ * Takes one reply out of the buffered bytes, leaving the rest.
122
+ *
123
+ * One read can carry several answers, and a terminal is free to volunteer
124
+ * bytes of its own; dropping everything on a match would throw away replies
125
+ * the next query is still waiting for.
126
+ */
127
+ function takeOscColorReply(state, expectedPrefix) {
128
+ const found = findOscColorReply(state.pending, expectedPrefix);
129
+ if (!found)
130
+ return null;
131
+ state.pending = state.pending.slice(0, found.start) + state.pending.slice(found.end);
132
+ return found.color;
133
+ }
134
+ /**
135
+ * Runs the color conversation over an injected `ask(query, prefix)`.
136
+ *
137
+ * Order is not cosmetic here, and it was settled by measurement rather than by
138
+ * reasoning. Asking the default colors (OSC 10/11/12) first and the ANSI ramp
139
+ * afterwards makes a multiplexer between this process and the emulator pass
140
+ * the whole conversation through, and all nineteen answers then come from the
141
+ * emulator that is actually painting the screen — one coherent palette, in a
142
+ * few milliseconds. Asking the ramp first instead had the multiplexer answer
143
+ * OSC 4 from its own configuration while the default colors went unanswered:
144
+ * sixteen colors from one source, no background from the other.
145
+ *
146
+ * The ramp is asked for unconditionally, so a terminal that reports its colors
147
+ * but not its defaults (or the reverse) still contributes what it knows. A
148
+ * terminal that answers none of the first four questions is left alone rather
149
+ * than asked fifteen more times.
150
+ */
151
+ function collectPalette(ask) {
152
+ const palette = {};
153
+ const foreground = ask('\x1b]10;?\x1b\\', '\x1b]10');
154
+ if (foreground)
155
+ palette.foreground = foreground;
156
+ const background = ask('\x1b]11;?\x1b\\', '\x1b]11');
157
+ if (background)
158
+ palette.background = background;
159
+ const cursor = ask('\x1b]12;?\x1b\\', '\x1b]12');
160
+ if (cursor)
161
+ palette.cursor = cursor;
162
+ const ansi = {};
163
+ for (let slot = 0; slot < ANSI_SLOTS; slot += 1) {
164
+ const color = ask(`\x1b]4;${slot};?\x1b\\`, `\x1b]4;${slot}`);
165
+ if (!color)
166
+ break;
167
+ ansi[ANSI_KEYS[slot]] = color;
168
+ }
169
+ if (Object.keys(ansi).length === ANSI_SLOTS)
170
+ palette.ansi = ansi;
171
+ return sanitizePalette(palette);
172
+ }
173
+ /**
174
+ * Asks one question and waits for that answer, on an already-raw terminal.
175
+ * Anything the terminal volunteers in the meantime is kept in `pending` so a
176
+ * late reply is still matched to the query it belongs to.
177
+ */
178
+ function askTerminal(fd, query, expectedPrefix, state) {
179
+ fs.writeSync(fd, query);
180
+ const buffer = Buffer.alloc(256);
181
+ let idleReads = 0;
182
+ while (idleReads < MAX_IDLE_READS && Date.now() < state.overallDeadline) {
183
+ const matched = takeOscColorReply(state, expectedPrefix);
184
+ if (matched)
185
+ return matched;
186
+ let bytesRead = 0;
187
+ try {
188
+ // Blocking, but bounded by VTIME: it returns 0 when the terminal is quiet.
189
+ bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
190
+ }
191
+ catch (error) {
192
+ if (error.code === 'EAGAIN') {
193
+ idleReads += 1;
194
+ continue;
195
+ }
196
+ return null;
197
+ }
198
+ if (bytesRead > 0) {
199
+ state.pending += buffer.toString('latin1', 0, bytesRead);
200
+ idleReads = 0;
201
+ }
202
+ else {
203
+ idleReads += 1;
204
+ }
205
+ }
206
+ return takeOscColorReply(state, expectedPrefix);
207
+ }
208
+ /**
209
+ * Queries the controlling terminal for its colors.
210
+ *
211
+ * Returns `null` — never a guess — when there is no terminal, when the
212
+ * terminal stays silent, or when anything about the exchange goes wrong.
213
+ */
214
+ function probeTerminalPalette({ ttyPath = '/dev/tty', timeoutMs = TOTAL_TIMEOUT_MS, } = {}) {
215
+ if (process.platform === 'win32')
216
+ return null;
217
+ let fd = null;
218
+ let savedMode = null;
219
+ try {
220
+ fd = fs.openSync(ttyPath, 'r+');
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ try {
226
+ const saved = runStty(['-g'], ttyPath);
227
+ if (saved.status !== 0)
228
+ return null;
229
+ savedMode = saved.stdout.trim();
230
+ // The reply is written to the terminal, not echoed by a line discipline:
231
+ // raw mode is what lets this process read it instead of the user's shell.
232
+ if (runStty(['raw', '-echo', 'min', '0', 'time', READ_TIMEOUT_TENTHS], ttyPath).status !== 0)
233
+ return null;
234
+ const state = { pending: '', overallDeadline: Date.now() + timeoutMs };
235
+ const tty = fd;
236
+ return collectPalette((query, prefix) => askTerminal(tty, query, prefix, state));
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ finally {
242
+ if (savedMode)
243
+ runStty([savedMode], ttyPath);
244
+ if (fd !== null) {
245
+ try {
246
+ fs.closeSync(fd);
247
+ }
248
+ catch {
249
+ // The terminal state was already restored above; the descriptor is only closed.
250
+ }
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * Remembers a palette for the next start that has no terminal to ask.
256
+ *
257
+ * A service manager launches the services with no terminal at all, and the
258
+ * workstation has not changed color just because systemd, rather than a
259
+ * person, started it this time.
260
+ */
261
+ function rememberTerminalPalette(palette) {
262
+ const clean = sanitizePalette(palette);
263
+ if (!clean)
264
+ return null;
265
+ try {
266
+ updateRuntime((state) => {
267
+ state.terminalPalette = clean;
268
+ });
269
+ }
270
+ catch {
271
+ // A palette is a nicety; failing to remember it must not break a start.
272
+ }
273
+ return clean;
274
+ }
275
+ /** The palette a previous start captured from a terminal, if any. */
276
+ function rememberedTerminalPalette() {
277
+ try {
278
+ return sanitizePalette(readRuntime().terminalPalette);
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
284
+ /**
285
+ * The palette a service start should hand to its children: whatever a parent
286
+ * already captured, otherwise a fresh probe of this process's terminal.
287
+ */
288
+ function resolveHostPalette({ env = process.env, probe = probeTerminalPalette, } = {}) {
289
+ return paletteFromEnvironment(env) || probe() || null;
290
+ }
291
+ /**
292
+ * Capture the terminal's colors at the entry point, before anything else takes
293
+ * the screen.
294
+ *
295
+ * The configuration TUI owns the terminal once it starts, and a probe issued
296
+ * underneath it would race its input handling for the reply. Asking here, at
297
+ * the very start of the command, means every later caller in this process tree
298
+ * simply inherits the answer through the environment.
299
+ */
300
+ function captureTerminalPalette({ env = process.env, probe = probeTerminalPalette, } = {}) {
301
+ if (env.HERDR_TERM_PALETTE_JSON)
302
+ return paletteFromEnvironment(env);
303
+ // Without both ends on a terminal there is nobody to answer the query.
304
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
305
+ return null;
306
+ const palette = probe();
307
+ if (!palette)
308
+ return null;
309
+ env.HERDR_TERM_PALETTE_JSON = JSON.stringify(palette);
310
+ // The environment only reaches children of this process. A start handed to
311
+ // systemd or launchd runs outside that tree, so the answer is written down
312
+ // as well and picked up from there.
313
+ rememberTerminalPalette(palette);
314
+ return palette;
315
+ }
316
+ /**
317
+ * The colors of the terminal this workstation is looked at through.
318
+ *
319
+ * Asked once per process, from whichever start path has a real terminal, and
320
+ * remembered in the state file: a later start from a service manager has no
321
+ * terminal to ask, and the workstation's appearance has not changed just
322
+ * because systemd, and not a person, launched it this time.
323
+ */
324
+ let cachedHostPalette;
325
+ function hostTerminalPalette({ refresh = false } = {}) {
326
+ if (!refresh && cachedHostPalette !== undefined)
327
+ return cachedHostPalette;
328
+ const inherited = paletteFromEnvironment();
329
+ if (inherited) {
330
+ cachedHostPalette = inherited;
331
+ return cachedHostPalette;
332
+ }
333
+ const probed = probeTerminalPalette();
334
+ // Nothing to ask: fall back to what a start with a terminal wrote down,
335
+ // re-validated, because a state file is not a trusted wire either.
336
+ cachedHostPalette = probed ? rememberTerminalPalette(probed) : rememberedTerminalPalette();
337
+ return cachedHostPalette;
338
+ }
339
+ export { ANSI_KEYS, ANSI_SLOTS, parseXColor, parseOscColorReply, takeOscColorReply, collectPalette, sanitizePalette, paletteFromEnvironment, probeTerminalPalette, resolveHostPalette, hostTerminalPalette, captureTerminalPalette, rememberTerminalPalette, rememberedTerminalPalette, };
@@ -0,0 +1,195 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text, useApp, useInput } from 'ink';
4
+ import { useMouse, useMouseTarget } from './mouse/index.js';
5
+ import { theme } from './theme.js';
6
+ import { checkForUpdate, createDraft, createTranslator, detectLocale, ensureRuntime, fullStatus, loadConfig, requiresRestart, updateChecksEnabled, } from './api.js';
7
+ import { Overview } from './screens/Overview.js';
8
+ import { PairScreen } from './screens/Pair.js';
9
+ import { Services } from './screens/Services.js';
10
+ import { RelayScreen } from './screens/Relay.js';
11
+ import { Keepalive } from './screens/Keepalive.js';
12
+ import { HerdrScreen } from './screens/Herdr.js';
13
+ import { About } from './screens/About.js';
14
+ import { Wizard } from './screens/Wizard.js';
15
+ const STATUS_POLL_MS = 3000;
16
+ const TABS = [
17
+ { id: 'overview', labelKey: 'nav.overview' },
18
+ { id: 'pair', labelKey: 'nav.pair' },
19
+ { id: 'services', labelKey: 'nav.services' },
20
+ { id: 'relay', labelKey: 'nav.relay' },
21
+ { id: 'keepalive', labelKey: 'nav.keepalive' },
22
+ { id: 'herdr', labelKey: 'nav.herdr' },
23
+ { id: 'about', labelKey: 'nav.about' },
24
+ ];
25
+ function Tab({ label, active, onSelect, }) {
26
+ const ref = useRef(null);
27
+ const hovered = useMouseTarget(ref, { onClick: onSelect });
28
+ return (_jsx(Box, { ref: ref, marginRight: 2, children: _jsx(Text, { color: active || hovered ? theme.accent : theme.muted, bold: active, underline: active || hovered, children: label }) }));
29
+ }
30
+ function Footer({ hints }) {
31
+ return (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.muted, children: hints.join(' · ') }) }));
32
+ }
33
+ export function App({ initialLanguage, needsWizard, updateChecker = updateChecksEnabled() ? checkForUpdate : null, }) {
34
+ const { exit } = useApp();
35
+ const mouse = useMouse();
36
+ const [config, setConfig] = useState(() => loadConfig());
37
+ const [draft, setDraft] = useState(() => createDraft());
38
+ const [runtime, setRuntime] = useState(() => ensureRuntime());
39
+ const [status, setStatus] = useState(null);
40
+ const [tab, setTab] = useState('overview');
41
+ const [message, setMessage] = useState(null);
42
+ const [busy, setBusy] = useState(false);
43
+ const [editingId, setEditingId] = useState(null);
44
+ const [wizardDone, setWizardDone] = useState(!needsWizard);
45
+ const [updateCheck, setUpdateCheck] = useState(null);
46
+ // Every time the TUI opens: that is when someone is here to act on it.
47
+ // Failure says nothing; the About tab can still be asked by hand.
48
+ useEffect(() => {
49
+ if (!updateChecker)
50
+ return undefined;
51
+ let cancelled = false;
52
+ updateChecker()
53
+ .then((result) => {
54
+ if (!cancelled && result?.ok)
55
+ setUpdateCheck(result);
56
+ })
57
+ .catch(() => { });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [updateChecker]);
62
+ const locale = useMemo(() => detectLocale({ preference: initialLanguage ?? draft.ui.language }), [initialLanguage, draft.ui.language]);
63
+ const t = useMemo(() => createTranslator(locale), [locale]);
64
+ const dirty = useMemo(() => JSON.stringify(config) !== JSON.stringify(draft), [config, draft]);
65
+ const restartPending = useMemo(() => dirty && requiresRestart(config, draft), [config, draft, dirty]);
66
+ const notify = useCallback((text, level = 'info') => {
67
+ setMessage({ text, level });
68
+ }, []);
69
+ const refresh = useCallback(() => {
70
+ fullStatus(loadConfig())
71
+ .then((next) => setStatus(next))
72
+ .catch((error) => notify(error.message, 'error'));
73
+ }, [notify]);
74
+ const reloadConfig = useCallback(() => {
75
+ const next = loadConfig();
76
+ setConfig(next);
77
+ setDraft(createDraft(next));
78
+ setRuntime(ensureRuntime());
79
+ }, []);
80
+ /**
81
+ * Run an action with a busy flag and a single place to surface failures, so
82
+ * no screen can leave the interface spinning or swallow an error.
83
+ */
84
+ const run = useCallback((task) => {
85
+ setBusy(true);
86
+ Promise.resolve()
87
+ .then(task)
88
+ .catch((error) => notify(error.message, 'error'))
89
+ .finally(() => {
90
+ setBusy(false);
91
+ refresh();
92
+ });
93
+ }, [notify, refresh]);
94
+ useEffect(() => {
95
+ if (!wizardDone)
96
+ return undefined;
97
+ refresh();
98
+ const timer = setInterval(() => {
99
+ if (!editingId)
100
+ refresh();
101
+ }, STATUS_POLL_MS);
102
+ return () => clearInterval(timer);
103
+ }, [wizardDone, refresh, editingId]);
104
+ // Mouse tracking starts on its own where the terminal supports it, and can be
105
+ // switched off with `m` — while it is on, the terminal's own text selection is
106
+ // disabled, which is exactly what you want back when copying a pairing URL
107
+ // out of the pane. Once the user presses `m`, their choice is recorded and the
108
+ // automatic enable stops interfering.
109
+ const mouseChoice = useRef(null);
110
+ useEffect(() => {
111
+ if (mouseChoice.current !== null)
112
+ return;
113
+ if (mouse.supported && !mouse.enabled)
114
+ mouse.enable();
115
+ }, [mouse.supported, mouse.enabled, mouse]);
116
+ const context = {
117
+ t,
118
+ locale,
119
+ config,
120
+ draft,
121
+ status,
122
+ runtime,
123
+ busy,
124
+ dirty,
125
+ editingId,
126
+ updateDraft: setDraft,
127
+ reloadConfig,
128
+ setEditing: setEditingId,
129
+ notify,
130
+ run,
131
+ refresh,
132
+ message,
133
+ updateCheck,
134
+ setUpdateCheck,
135
+ };
136
+ useInput((input, key) => {
137
+ if (key.ctrl && input === 'c') {
138
+ exit();
139
+ return;
140
+ }
141
+ if (input === 'q') {
142
+ exit();
143
+ return;
144
+ }
145
+ if (input === 'r') {
146
+ setMessage(null);
147
+ refresh();
148
+ return;
149
+ }
150
+ if (input === 'm') {
151
+ if (!mouse.supported) {
152
+ notify(t('hint.mouseUnsupported'), 'info');
153
+ return;
154
+ }
155
+ mouseChoice.current = !mouse.enabled;
156
+ if (mouse.enabled)
157
+ mouse.disable();
158
+ else
159
+ mouse.enable();
160
+ return;
161
+ }
162
+ const index = TABS.findIndex((entry) => entry.id === tab);
163
+ if (key.rightArrow || (key.tab && !key.shift)) {
164
+ setTab(TABS[(index + 1) % TABS.length].id);
165
+ return;
166
+ }
167
+ if (key.leftArrow || (key.tab && key.shift)) {
168
+ setTab(TABS[(index - 1 + TABS.length) % TABS.length].id);
169
+ return;
170
+ }
171
+ const digit = Number.parseInt(input, 10);
172
+ if (Number.isInteger(digit) && digit >= 1 && digit <= TABS.length)
173
+ setTab(TABS[digit - 1].id);
174
+ }, { isActive: wizardDone && editingId === null });
175
+ if (!wizardDone) {
176
+ return (_jsx(Wizard, { ctx: context, onDone: () => {
177
+ reloadConfig();
178
+ setWizardDone(true);
179
+ refresh();
180
+ } }));
181
+ }
182
+ const hints = [
183
+ t('hint.navigate'),
184
+ t('hint.select'),
185
+ t('hint.tabs'),
186
+ dirty ? t('hint.save') : null,
187
+ mouse.supported
188
+ ? mouse.enabled
189
+ ? t('hint.mouseOn')
190
+ : t('hint.mouseOff')
191
+ : t('hint.mouseUnsupported'),
192
+ t('hint.quit'),
193
+ ].filter(Boolean);
194
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: t('app.name') }), _jsx(Text, { color: theme.muted, children: ` ${t('app.tagline')}` })] }), updateCheck?.updateAvailable ? (_jsx(Text, { color: theme.warn, children: t('update.banner', { latest: updateCheck.latest ?? '', current: updateCheck.current }) })) : null] }), _jsx(Box, { marginBottom: 1, children: TABS.map((entry, entryIndex) => (_jsx(Tab, { label: `${entryIndex + 1} ${t(entry.labelKey)}`, active: entry.id === tab, onSelect: () => setTab(entry.id) }, entry.id))) }), tab === 'overview' ? _jsx(Overview, { ctx: context }) : null, tab === 'pair' ? _jsx(PairScreen, { ctx: context }) : null, tab === 'services' ? _jsx(Services, { ctx: context }) : null, tab === 'relay' ? _jsx(RelayScreen, { ctx: context }) : null, tab === 'keepalive' ? _jsx(Keepalive, { ctx: context }) : null, tab === 'herdr' ? _jsx(HerdrScreen, { ctx: context }) : null, tab === 'about' ? _jsx(About, { ctx: context }) : null, restartPending ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.restartRequired') }) })) : null, _jsx(Footer, { hints: hints })] }));
195
+ }
@@ -0,0 +1,79 @@
1
+ // The only place the TUI reaches into the service layer.
2
+ //
3
+ // Everything below the interface — config, services, keep-alive, pairing — is
4
+ // shared with the CLI. Funnelling it through one module gives the screens a
5
+ // single surface to call.
6
+ import { OFFICIAL_RELAY_URL, configExists, loadConfig } from '../config.js';
7
+ import { configPath, stateDir } from '../paths.js';
8
+ import { bindAddress, resolveAdminOrigin } from '../relay-urls.js';
9
+ import { MIN_HERDR_VERSION, herdrVersion } from '../herdr-command.js';
10
+ import { createTranslator, detectLocale } from '../i18n/index.js';
11
+ import { listReachableAddresses } from '../net-interfaces.js';
12
+ import { createDraft, fieldsForMode, getField, getFieldPlaceholder, isOfficialRelay, requiresRestart, saveDraft, selectedMode, setField, } from '../settings-model.js';
13
+ import { extractPairingCode, requestJson } from '../relay-client.js';
14
+ import { ensureRuntime, regenerateHostIdentity, setRelayPassword } from '../runtime.js';
15
+ import { pair, readLogTail } from '../service.js';
16
+ import { fullStatus, restartAll, startAll, stopAll } from '../lifecycle.js';
17
+ import * as keepalive from '../keepalive/index.js';
18
+ import { canSelfUpdate, checkForUpdate, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
19
+ export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, currentVersion, updateChecksEnabled, installKind, performUpdate, bindAddress, configExists, configPath, createDraft, createTranslator, ensureRuntime, extractPairingCode, fieldsForMode, fullStatus, getField, getFieldPlaceholder, herdrVersion, keepalive, loadConfig, pair, readLogTail, regenerateHostIdentity, requiresRestart, restartAll, saveDraft, setField, setRelayPassword, startAll, stateDir, stopAll, };
20
+ /** The command that starts a matching relay, ready to paste on the server. */
21
+ export function relayStartCommand(config, password) {
22
+ const publicUrl = config.relay.remoteUrl
23
+ ? config.relay.remoteUrl.replace(/^ws/, 'http')
24
+ : 'https://relay.example.com';
25
+ const parts = ['herdr-remote-relay', `--public-url ${publicUrl}`, '--trust-proxy'];
26
+ if (password)
27
+ parts.push(`--password '${password.replace(/'/g, "'\\''")}'`);
28
+ return parts.join(' ');
29
+ }
30
+ /**
31
+ * Reachability probe used by the relay screen, for local and remote alike.
32
+ *
33
+ * Liveness and tenant status are deliberately separate: a relay must be able
34
+ * to report "I am reachable" before this workstation has enrolled, while the
35
+ * host count (when available) comes only from this workstation's scoped view.
36
+ */
37
+ export async function probeRelay(config) {
38
+ const origin = resolveAdminOrigin(config);
39
+ let health;
40
+ try {
41
+ health = await requestJson(`${origin}/healthz`, { timeout: 4000 });
42
+ }
43
+ catch (error) {
44
+ return { ok: false, message: error.message };
45
+ }
46
+ try {
47
+ const runtime = ensureRuntime();
48
+ const status = await requestJson(`${origin}/api/status`, {
49
+ timeout: 4000,
50
+ headers: {
51
+ 'X-Herdr-Host-Id': runtime.hostId,
52
+ 'X-Herdr-Host-Token': runtime.hostToken,
53
+ },
54
+ });
55
+ return {
56
+ ok: true,
57
+ version: String(health.version || status.version || ''),
58
+ hosts: Array.isArray(status.hosts) ? status.hosts.length : 0,
59
+ };
60
+ }
61
+ catch {
62
+ // The relay is healthy even when this machine is not enrolled or its host
63
+ // connector is offline. Do not turn a tenant-auth failure into a network
64
+ // failure in the configuration TUI.
65
+ return { ok: true, version: String(health.version || ''), hosts: undefined };
66
+ }
67
+ }
68
+ export function formatUptime(seconds, t) {
69
+ if (!Number.isFinite(seconds))
70
+ return t('common.unknown');
71
+ const total = Math.max(0, Math.floor(seconds));
72
+ const hours = Math.floor(total / 3600);
73
+ const minutes = Math.floor((total % 3600) / 60);
74
+ if (hours > 0)
75
+ return `${hours}h ${minutes}m`;
76
+ if (minutes > 0)
77
+ return `${minutes}m ${total % 60}s`;
78
+ return `${total}s`;
79
+ }
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { theme } from '../theme.js';
5
+ // Anything below 0x20, plus DEL: escape sequences and control keys that must
6
+ // never end up as literal text in a field.
7
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/;
8
+ /**
9
+ * A single-line editor.
10
+ *
11
+ * Small enough to own rather than depend on: the TUI needs exactly one editing
12
+ * affordance, and keeping it here means the escape/confirm contract is the same
13
+ * everywhere and stays consistent with `useInput({ isActive })` gating, so a
14
+ * keystroke never lands in both the editor and the surrounding menu.
15
+ */
16
+ export function TextField({ value, placeholder, active, mask = false, onSubmit, onCancel, }) {
17
+ const [buffer, setBuffer] = useState(value);
18
+ useEffect(() => {
19
+ if (active)
20
+ setBuffer(value);
21
+ }, [active, value]);
22
+ useInput((input, key) => {
23
+ if (key.return) {
24
+ onSubmit(buffer);
25
+ return;
26
+ }
27
+ if (key.escape) {
28
+ setBuffer(value);
29
+ onCancel();
30
+ return;
31
+ }
32
+ if (key.backspace || key.delete) {
33
+ setBuffer((current) => current.slice(0, -1));
34
+ return;
35
+ }
36
+ if (key.ctrl && input === 'u') {
37
+ setBuffer('');
38
+ return;
39
+ }
40
+ if (input && !key.ctrl && !key.meta && !CONTROL_CHARACTERS.test(input)) {
41
+ setBuffer((current) => current + input);
42
+ }
43
+ }, { isActive: active });
44
+ const display = active ? buffer : value;
45
+ const shown = mask && display ? '•'.repeat(Math.min(display.length, 32)) : display;
46
+ if (!active) {
47
+ return shown ? _jsx(Text, { children: shown }) : _jsx(Text, { color: theme.muted, children: placeholder ?? '' });
48
+ }
49
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: shown }), _jsx(Text, { color: theme.accent, inverse: true, children: ' ' })] }));
50
+ }