herdr-remote 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,74 @@
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
+ import { FieldRow, Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
6
+ import { TextField } from '../components/TextField.js';
7
+ import { MIN_HERDR_VERSION, getField, getFieldPlaceholder, herdrVersion, saveDraft, setField, } from '../api.js';
8
+ export function HerdrScreen({ ctx }) {
9
+ const { t, draft, editingId } = ctx;
10
+ const [selected, setSelected] = useState('socketPath');
11
+ const [installed, setInstalled] = useState(null);
12
+ useEffect(() => {
13
+ setInstalled(herdrVersion());
14
+ }, []);
15
+ const entries = [
16
+ { id: 'socketPath', kind: 'field', label: t('herdr.socketPath') },
17
+ { id: 'herdrArgs', kind: 'field', label: t('herdr.args') },
18
+ { id: 'herdrAutoStart', kind: 'toggle', label: t('field.herdrAutoStart') },
19
+ { id: 'save', kind: 'action', label: t('common.save') },
20
+ ];
21
+ const applyField = (id, value) => {
22
+ const result = setField(draft, id, value);
23
+ if (result.errorKey) {
24
+ ctx.notify(t(result.errorKey), 'error');
25
+ return false;
26
+ }
27
+ ctx.updateDraft(result.draft);
28
+ return true;
29
+ };
30
+ const save = () => {
31
+ try {
32
+ const result = saveDraft(draft);
33
+ ctx.reloadConfig();
34
+ ctx.notify(t('common.saved', { path: result.path }), 'success');
35
+ }
36
+ catch (error) {
37
+ ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
38
+ }
39
+ };
40
+ const activate = (id) => {
41
+ if (id === 'socketPath' || id === 'herdrArgs') {
42
+ ctx.setEditing(id);
43
+ return;
44
+ }
45
+ if (id === 'herdrAutoStart') {
46
+ applyField(id, draft.herdr.autoStart ? 'off' : 'on');
47
+ return;
48
+ }
49
+ if (id === 'save')
50
+ save();
51
+ };
52
+ useInput((input, key) => {
53
+ const index = entries.findIndex((entry) => entry.id === selected);
54
+ const safeIndex = index >= 0 ? index : 0;
55
+ if (key.upArrow)
56
+ setSelected(entries[(safeIndex - 1 + entries.length) % entries.length].id);
57
+ else if (key.downArrow)
58
+ setSelected(entries[(safeIndex + 1) % entries.length].id);
59
+ else if (key.return)
60
+ activate(selected);
61
+ else if (input === 's')
62
+ save();
63
+ }, { isActive: editingId === null });
64
+ return (_jsxs(Panel, { title: t('herdr.title'), children: [entries.map((entry) => entry.kind === 'toggle' ? (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => {
65
+ setSelected(entry.id);
66
+ activate(entry.id);
67
+ }, onHover: () => setSelected(entry.id), children: _jsx(Text, { color: draft.herdr.autoStart ? theme.ok : theme.muted, children: draft.herdr.autoStart ? `[×] ${t('common.on')}` : `[ ] ${t('common.off')}` }) }, entry.id)) : entry.kind === 'field' ? (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => activate(entry.id), onHover: () => setSelected(entry.id), children: _jsx(TextField, { value: getField(draft, entry.id), placeholder: ctx.t(getFieldPlaceholder(draft, entry.id)), active: editingId === entry.id, onSubmit: (value) => {
68
+ if (applyField(entry.id, value))
69
+ ctx.setEditing(null);
70
+ }, onCancel: () => ctx.setEditing(null) }) }, entry.id)) : (_jsx(Box, { marginTop: 1, children: _jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
71
+ setSelected(entry.id);
72
+ activate(entry.id);
73
+ }, onHover: () => setSelected(entry.id), children: entry.label }) }, entry.id))), selected === 'herdrAutoStart' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('herdr.autoStartHint') }) })) : null, ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.unsavedChanges') }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [installed?.version ? (_jsx(Row, { label: t('herdr.version'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: installed.supported ? 'ok' : 'warn' }), _jsx(Text, { children: ` ${installed.version}` })] }) })) : null, installed?.version && !installed.supported ? (_jsx(Text, { color: theme.warn, children: t('herdr.versionOutdated', { version: installed.version, minimum: MIN_HERDR_VERSION }) })) : null, installed && !installed.ok ? (_jsx(Text, { color: theme.warn, children: t('herdr.cliMissing') })) : null] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
74
+ }
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { theme } from '../theme.js';
5
+ import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
6
+ import { ChoiceList } from './Relay.js';
7
+ import { keepalive, saveDraft, setField } from '../api.js';
8
+ const MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
9
+ export function Keepalive({ ctx }) {
10
+ const { t, status, draft, editingId } = ctx;
11
+ const [selected, setSelected] = useState('install');
12
+ const current = status?.keepalive;
13
+ const entries = [
14
+ { id: 'install', label: t('keepalive.install') },
15
+ { id: 'restart', label: t('keepalive.restart') },
16
+ { id: 'uninstall', label: t('keepalive.uninstall') },
17
+ { id: 'manager', label: `${t('keepalive.manager')}: ${draft.keepalive.manager}` },
18
+ ...(process.platform === 'linux' && current?.manager === 'systemd' && !current?.linger
19
+ ? [{ id: 'linger', label: t('keepalive.enableLinger') }]
20
+ : []),
21
+ ];
22
+ const activate = (id) => {
23
+ if (id === 'manager') {
24
+ ctx.setEditing('keepaliveManager');
25
+ return;
26
+ }
27
+ ctx.run(() => {
28
+ if (id === 'install') {
29
+ const result = keepalive.install(ctx.config);
30
+ ctx.notify(t('keepalive.installed', { manager: result.manager }), 'success');
31
+ return;
32
+ }
33
+ if (id === 'uninstall') {
34
+ keepalive.uninstall(ctx.config);
35
+ ctx.notify(t('keepalive.uninstalled'), 'success');
36
+ return;
37
+ }
38
+ if (id === 'restart') {
39
+ keepalive.restart(ctx.config);
40
+ ctx.notify(t('keepalive.restarted'), 'success');
41
+ return;
42
+ }
43
+ if (id === 'linger') {
44
+ const result = keepalive.enableLinger();
45
+ ctx.notify(t('keepalive.lingerDone', { username: result.username }), 'success');
46
+ }
47
+ });
48
+ };
49
+ useInput((_input, key) => {
50
+ const index = entries.findIndex((entry) => entry.id === selected);
51
+ if (key.upArrow)
52
+ setSelected(entries[(index - 1 + entries.length) % entries.length].id);
53
+ else if (key.downArrow)
54
+ setSelected(entries[(index + 1) % entries.length].id);
55
+ else if (key.return)
56
+ activate(selected);
57
+ }, { isActive: editingId === null });
58
+ if (editingId === 'keepaliveManager') {
59
+ return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options: MANAGERS.map((manager) => ({ id: manager, label: manager })), current: draft.keepalive.manager, onPick: (manager) => {
60
+ const result = setField(draft, 'keepaliveManager', manager);
61
+ if (result.errorKey) {
62
+ ctx.notify(t(result.errorKey), 'error');
63
+ return;
64
+ }
65
+ ctx.updateDraft(result.draft);
66
+ try {
67
+ saveDraft(result.draft);
68
+ ctx.reloadConfig();
69
+ }
70
+ catch (error) {
71
+ ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
72
+ }
73
+ ctx.setEditing(null);
74
+ }, onCancel: () => ctx.setEditing(null) }) }));
75
+ }
76
+ return (_jsxs(Panel, { title: t('keepalive.title'), children: [_jsx(Row, { label: t('keepalive.manager'), children: _jsx(Text, { children: current?.manager ?? t('common.unknown') }) }), _jsx(Row, { label: t('keepalive.state'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: current?.active ? 'ok' : current?.installed ? 'warn' : 'idle' }), _jsxs(Text, { children: [' ', current?.active
77
+ ? t('common.running')
78
+ : current?.installed
79
+ ? t('common.installed')
80
+ : t('common.notInstalled')] })] }) }), current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
81
+ setSelected(entry.id);
82
+ activate(entry.id);
83
+ }, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
84
+ }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { theme } from '../theme.js';
4
+ import { Message, Panel, Row, StatusDot } from '../components/common.js';
5
+ import { formatUptime } from '../api.js';
6
+ function level(alive) {
7
+ if (alive === null || alive === undefined)
8
+ return 'idle';
9
+ return alive ? 'ok' : 'bad';
10
+ }
11
+ export function Overview({ ctx }) {
12
+ const { t, status, config } = ctx;
13
+ if (!status) {
14
+ return (_jsx(Panel, { title: t('overview.title'), children: _jsx(Text, { color: theme.muted, children: t('common.loading') }) }));
15
+ }
16
+ const health = status.relay.health || {};
17
+ const relayReachable = health.ok !== false;
18
+ const relayLevel = status.relay.local
19
+ ? level(status.relay.alive)
20
+ : relayReachable
21
+ ? 'ok'
22
+ : 'bad';
23
+ const keepaliveLevel = status.keepalive.active
24
+ ? 'ok'
25
+ : status.keepalive.installed
26
+ ? 'warn'
27
+ : 'idle';
28
+ const nothingRunning = !status.host.alive && (status.relay.local ? !status.relay.alive : !relayReachable);
29
+ return (_jsxs(Panel, { title: t('overview.title'), children: [_jsx(Row, { label: t('overview.mode'), children: _jsx(Text, { children: t(`mode.${status.mode}`) }) }), _jsx(Row, { label: t('overview.relay'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: relayLevel }), _jsxs(Text, { children: [' ', status.relay.local
30
+ ? t('overview.relayLocal', { bind: status.relay.bind ?? '', port: status.relay.port })
31
+ : t('overview.relayRemote', { url: status.relay.remoteUrl ?? '' })] }), status.relay.local && status.relay.pid ? (_jsx(Text, { color: theme.muted, children: ` ${t('common.pid', { pid: status.relay.pid })}` })) : null] }) }), _jsx(Row, { label: t('overview.host'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: level(status.host.alive) }), _jsx(Text, { children: ` ${status.host.alive ? t('common.running') : t('common.stopped')}` }), status.host.pid ? (_jsx(Text, { color: theme.muted, children: ` ${t('common.pid', { pid: status.host.pid })}` })) : null] }) }), _jsx(Row, { label: t('overview.socket'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: status.host.socketExists ? 'ok' : 'warn' }), _jsx(Text, { color: theme.muted, children: ` ${status.host.socketPath}` }), status.host.socketExists ? null : (_jsx(Text, { color: theme.warn, children: ` ${t('overview.socketMissing')}` }))] }) }), _jsx(Row, { label: t('overview.webUrl'), children: _jsx(Text, { color: theme.accent, children: status.publicUrl }) }), _jsx(Row, { label: t('overview.keepalive'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: keepaliveLevel }), _jsxs(Text, { children: [` ${status.keepalive.manager} — `, status.keepalive.active
32
+ ? t('common.running')
33
+ : status.keepalive.installed
34
+ ? t('common.installed')
35
+ : t('common.notInstalled')] })] }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: relayReachable ? (_jsxs(_Fragment, { children: [_jsx(Row, { label: t('overview.hosts'), children: _jsx(Text, { children: health.hosts ?? 0 }) }), _jsx(Row, { label: t('overview.devices'), children: _jsx(Text, { children: health.clients ?? 0 }) }), _jsx(Row, { label: t('overview.uptime'), children: _jsx(Text, { color: theme.muted, children: formatUptime(health.uptimeSeconds, t) }) })] })) : (_jsx(Text, { color: theme.warn, children: t('overview.unreachable', { message: health.message ?? '' }) })) }), nothingRunning ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('overview.notStarted') }) })) : null, config.relay.mode === 'local' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('mode.local.description') }) })) : null, _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
36
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useInput, useWindowSize } from 'ink';
4
+ import QRCode from 'qrcode';
5
+ import { theme } from '../theme.js';
6
+ import { Menu, Message, Panel } from '../components/common.js';
7
+ import { extractPairingCode, pair } from '../api.js';
8
+ // A QR block needs room for the symbol plus its quiet zone; below this the
9
+ // output wraps into noise, so the URL alone is the honest thing to show.
10
+ const MIN_QR_COLUMNS = 44;
11
+ export function PairScreen({ ctx }) {
12
+ const { t, run } = ctx;
13
+ const { columns } = useWindowSize();
14
+ const [pairing, setPairing] = useState(null);
15
+ const [qr, setQr] = useState(null);
16
+ const [remainingMs, setRemainingMs] = useState(0);
17
+ const [selected, setSelected] = useState('generate');
18
+ useEffect(() => {
19
+ if (!pairing)
20
+ return undefined;
21
+ const tick = () => setRemainingMs(Math.max(0, pairing.expiresAt - Date.now()));
22
+ tick();
23
+ const timer = setInterval(tick, 1000);
24
+ return () => clearInterval(timer);
25
+ }, [pairing]);
26
+ useEffect(() => {
27
+ if (!pairing || columns < MIN_QR_COLUMNS) {
28
+ setQr(null);
29
+ return;
30
+ }
31
+ let cancelled = false;
32
+ QRCode.toString(pairing.pairUrl, { type: 'terminal', small: true, errorCorrectionLevel: 'L' })
33
+ .then((value) => {
34
+ if (!cancelled)
35
+ setQr(value.replace(/\n$/, ''));
36
+ })
37
+ .catch(() => {
38
+ if (!cancelled)
39
+ setQr(null);
40
+ });
41
+ return () => {
42
+ cancelled = true;
43
+ };
44
+ }, [pairing, columns]);
45
+ const generate = () => run(async () => {
46
+ ctx.notify(t('pair.working'));
47
+ try {
48
+ const result = await pair();
49
+ const code = extractPairingCode(result);
50
+ setPairing({ ...result, code });
51
+ ctx.notify('', 'info');
52
+ }
53
+ catch (error) {
54
+ const message = error.message;
55
+ setPairing(null);
56
+ throw new Error(/host_offline|no Herdr host|did not register/i.test(message)
57
+ ? t('pair.hostOffline')
58
+ : t('pair.failed', { message }));
59
+ }
60
+ });
61
+ useInput((_input, key) => {
62
+ if (key.return)
63
+ generate();
64
+ }, { isActive: ctx.editingId === null });
65
+ const expired = pairing !== null && remainingMs <= 0;
66
+ const minutes = Math.floor(remainingMs / 60000);
67
+ const seconds = Math.floor((remainingMs % 60000) / 1000);
68
+ return (_jsxs(Panel, { title: t('pair.title'), children: [_jsx(Menu, { items: [{ id: 'generate', label: pairing ? t('pair.regenerate') : t('pair.generate') }], selectedId: selected, onChange: setSelected, onSelect: generate }), pairing ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: `${t('pair.code')} ` }), _jsx(Text, { bold: true, color: expired ? theme.muted : theme.accent, children: pairing.code.split('').join(' ') })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: `${t('pair.url')} ` }), _jsx(Text, { children: pairing.pairUrl })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: expired ? theme.bad : theme.muted, children: expired
69
+ ? t('pair.expired')
70
+ : t('pair.expires', {
71
+ minutes: `${minutes}:${String(seconds).padStart(2, '0')}`,
72
+ time: new Date(pairing.expiresAt).toLocaleTimeString(),
73
+ }) }) }), !expired && qr ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: qr }), _jsx(Text, { color: theme.muted, children: t('pair.qrHint') })] })) : null, !expired && !qr && columns < MIN_QR_COLUMNS ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('pair.qrUnavailable') }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('pair.instructions') }) })] })) : null, _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
74
+ }
@@ -0,0 +1,242 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { theme } from '../theme.js';
5
+ import { FieldRow, Menu, Message, Panel, Row, Selectable } from '../components/common.js';
6
+ import { TextField } from '../components/TextField.js';
7
+ import { bindAddress, fieldsForMode, getField, getFieldPlaceholder, isOfficialRelay, listReachableAddresses, probeRelay, regenerateHostIdentity, relayStartCommand, saveDraft, selectedMode, setField, setRelayPassword, } from '../api.js';
8
+ export function RelayScreen({ ctx }) {
9
+ const { t, draft, editingId } = ctx;
10
+ const [selected, setSelected] = useState('mode');
11
+ const [revealTokens, setRevealTokens] = useState(false);
12
+ const [showEnv, setShowEnv] = useState(false);
13
+ const [password, setPassword] = useState(ctx.runtime.relayPassword || '');
14
+ // The official relay is ours: its address is fixed and it takes no password.
15
+ // Both are therefore facts to *show*, not fields to fill in — an editable box
16
+ // holding a value that must not change is an invitation to break the setup.
17
+ const official = isOfficialRelay(draft);
18
+ const fields = useMemo(() => fieldsForMode(draft.relay.mode).filter((field) => ['mode', 'port', 'lanHost', 'remoteUrl', 'publicUrl'].includes(field.id)), [draft.relay.mode]);
19
+ const entries = [
20
+ ...fields.map((field) => ({
21
+ id: field.id,
22
+ // The official relay's address is ours and its browser URL follows from
23
+ // it, so neither is offered for editing: an editable box holding a value
24
+ // that must not change is an invitation to break the setup.
25
+ kind: (official && (field.id === 'remoteUrl' || field.id === 'publicUrl')
26
+ ? 'readonly'
27
+ : 'field'),
28
+ fieldKind: field.kind,
29
+ label: t(field.labelKey),
30
+ })),
31
+ // The password only matters when talking to a relay somebody else started;
32
+ // a local relay is configured with our own token automatically, and the
33
+ // official one authenticates workstations by their own host token.
34
+ ...(draft.relay.mode === 'remote' && !official
35
+ ? [{ id: 'password', kind: 'password', label: t('relay.password') }]
36
+ : []),
37
+ // Saving needs a row of its own. Every field here only edits a draft, and
38
+ // the services restart from what is on disk — with `s` as the sole way to
39
+ // commit, a change could look applied, survive a restart, and never take
40
+ // effect. It is also the only way to save with the mouse.
41
+ { id: 'save', kind: 'action', label: t('common.save') },
42
+ { id: 'test', kind: 'action', label: t('relay.test') },
43
+ ...(draft.relay.mode === 'remote' && !official
44
+ ? [
45
+ {
46
+ id: 'reveal',
47
+ kind: 'action',
48
+ label: revealTokens ? t('relay.hidePassword') : t('relay.showPassword'),
49
+ },
50
+ { id: 'env', kind: 'action', label: t('relay.envSnippet') },
51
+ ]
52
+ : []),
53
+ { id: 'regenerate', kind: 'action', label: t('relay.regenerate') },
54
+ ];
55
+ const addresses = useMemo(() => listReachableAddresses({ includeLoopback: false }), []);
56
+ /**
57
+ * Apply several fields as one edit.
58
+ *
59
+ * Each `setField` has to build on the previous result rather than on `draft`,
60
+ * which is the value captured when this render began. Calling a single-field
61
+ * helper twice in a row silently dropped the first change: the second call
62
+ * started from the stale draft and committed it back over the first.
63
+ */
64
+ const applyFields = (updates) => {
65
+ let next = draft;
66
+ for (const [id, value] of updates) {
67
+ const result = setField(next, id, value);
68
+ if (result.errorKey) {
69
+ ctx.notify(t(result.errorKey), 'error');
70
+ return false;
71
+ }
72
+ next = result.draft;
73
+ }
74
+ ctx.updateDraft(next);
75
+ ctx.notify('', 'info');
76
+ return true;
77
+ };
78
+ const applyField = (id, value) => applyFields([[id, value]]);
79
+ const save = () => {
80
+ try {
81
+ const result = saveDraft(draft);
82
+ ctx.reloadConfig();
83
+ ctx.notify(t('common.saved', { path: result.path }), 'success');
84
+ }
85
+ catch (error) {
86
+ const problems = error.problems;
87
+ ctx.notify(problems
88
+ ? problems.map((key) => t(key)).join(' ')
89
+ : t('error.saveFailed', { message: error.message }), 'error');
90
+ }
91
+ };
92
+ const activate = (id) => {
93
+ const entry = entries.find((candidate) => candidate.id === id);
94
+ if (!entry)
95
+ return;
96
+ if (entry.kind === 'readonly') {
97
+ ctx.notify(t('relay.officialFixed'), 'info');
98
+ return;
99
+ }
100
+ if (entry.kind === 'field' || entry.kind === 'password') {
101
+ ctx.setEditing(id);
102
+ return;
103
+ }
104
+ if (id === 'save') {
105
+ save();
106
+ return;
107
+ }
108
+ if (id === 'reveal') {
109
+ setRevealTokens((current) => !current);
110
+ return;
111
+ }
112
+ if (id === 'env') {
113
+ setShowEnv((current) => !current);
114
+ return;
115
+ }
116
+ if (id === 'regenerate') {
117
+ ctx.run(() => {
118
+ regenerateHostIdentity();
119
+ ctx.reloadConfig();
120
+ ctx.notify(t('relay.regenerated'), 'success');
121
+ });
122
+ return;
123
+ }
124
+ if (id === 'test') {
125
+ ctx.run(async () => {
126
+ const result = await probeRelay(ctx.config);
127
+ ctx.notify(result.ok
128
+ ? t('relay.testOk', { version: result.version ?? '?', hosts: result.hosts ?? 0 })
129
+ : t('relay.testFailed', { message: result.message ?? '' }), result.ok ? 'success' : 'error');
130
+ });
131
+ }
132
+ };
133
+ useInput((input, key) => {
134
+ const index = entries.findIndex((entry) => entry.id === selected);
135
+ if (key.upArrow)
136
+ setSelected(entries[(index - 1 + entries.length) % entries.length].id);
137
+ else if (key.downArrow)
138
+ setSelected(entries[(index + 1) % entries.length].id);
139
+ else if (key.return)
140
+ activate(selected);
141
+ else if (input === 's')
142
+ save();
143
+ }, { isActive: editingId === null });
144
+ // Choice editors take over the panel so the option list is unambiguous.
145
+ if (editingId === 'mode') {
146
+ return (_jsx(Panel, { title: t('field.mode'), children: _jsx(ChoiceList, { options: [
147
+ ...['local', 'lan'].map((mode) => ({
148
+ id: mode,
149
+ label: t(`mode.${mode}`),
150
+ description: t(`mode.${mode}.description`),
151
+ })),
152
+ {
153
+ id: 'official',
154
+ label: t('mode.official'),
155
+ description: t('mode.official.description'),
156
+ },
157
+ { id: 'remote', label: t('mode.remote'), description: t('mode.remote.description') },
158
+ ],
159
+ // Reflects the official relay as its own selection rather than as an
160
+ // indistinguishable "self-hosted" row that happens to hold our URL.
161
+ current: selectedMode(draft), onPick: (choice) => {
162
+ // `official` is a mode the settings model understands: it sets the
163
+ // address along with the mode, and choosing the self-hosted relay
164
+ // clears it again. The screen does not have to keep the two fields
165
+ // in step by hand, which is what let the picker land on "official"
166
+ // while every row below still described a self-hosted relay.
167
+ if (!applyField('mode', choice))
168
+ return;
169
+ if (choice === 'official') {
170
+ // A password typed for somebody else's relay is not a credential
171
+ // the official one wants, and leaving it behind would send it
172
+ // there on the next connection.
173
+ setPassword('');
174
+ setRelayPassword('');
175
+ }
176
+ ctx.setEditing(null);
177
+ }, onCancel: () => ctx.setEditing(null) }) }));
178
+ }
179
+ if (editingId === 'lanHost') {
180
+ const options = addresses.map((address) => ({
181
+ id: address.address,
182
+ label: `${address.address} (${address.name}, ${t(addressKindKey(address.kind))})`,
183
+ }));
184
+ return (_jsx(Panel, { title: t('relay.selectAddress'), children: options.length === 0 ? (_jsx(Text, { color: theme.warn, children: t('relay.noAddresses') })) : (_jsx(ChoiceList, { options: options, current: draft.relay.lanHost || options[0].id, onPick: (address) => {
185
+ applyField('lanHost', address);
186
+ ctx.setEditing(null);
187
+ }, onCancel: () => ctx.setEditing(null) })) }));
188
+ }
189
+ return (_jsxs(Panel, { title: t('relay.title'), children: [draft.relay.mode !== 'remote' ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Row, { label: t('relay.listenAddress'), children: _jsx(Text, { color: theme.muted, children: `${bindAddress(draft)}:${draft.relay.port}` }) }), _jsx(Text, { color: theme.muted, children: t('relay.listenAddressHint') })] })) : null, entries.map((entry) => entry.kind === 'action' ? (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
190
+ setSelected(entry.id);
191
+ activate(entry.id);
192
+ }, onHover: () => setSelected(entry.id), children: entry.label }, entry.id)) : (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => activate(entry.id), onHover: () => setSelected(entry.id), children: entry.kind === 'password' ? (_jsx(TextField, { value: password, placeholder: t('relay.passwordEmpty'), active: editingId === 'password', mask: !revealTokens, onSubmit: (value) => {
193
+ setPassword(value);
194
+ setRelayPassword(value);
195
+ ctx.setEditing(null);
196
+ ctx.notify(t('relay.passwordSaved'), 'success');
197
+ }, onCancel: () => ctx.setEditing(null) })) : entry.kind === 'readonly' ? (
198
+ // Fixed by us, so it is printed rather than offered for editing.
199
+ _jsx(Text, { color: theme.muted, children: `${getField(draft, entry.id) || placeholderText(ctx, entry.id)} (${t('relay.locked')})` })) : entry.fieldKind === 'choice' ? (
200
+ // Choice fields are picked from a list, never typed, so they
201
+ // show the translated label rather than the stored id — and the
202
+ // official relay is named as itself, not as "self-hosted".
203
+ _jsx(Text, { children: t(`mode.${selectedMode(draft)}`) })) : (_jsx(TextField, { value: getField(draft, entry.id), placeholder: placeholderText(ctx, entry.id), active: editingId === entry.id, onSubmit: (value) => {
204
+ if (applyField(entry.id, value))
205
+ ctx.setEditing(null);
206
+ }, onCancel: () => ctx.setEditing(null) })) }, entry.id))), ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.unsavedChanges') }) })) : null, draft.relay.mode === 'remote' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: official ? t('relay.officialHint') : t('relay.passwordHint') }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Row, { label: t('relay.identity'), children: _jsx(Text, { color: theme.muted, children: ctx.runtime.hostId }) }) }), showEnv ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsx(Text, { color: theme.muted, children: t('relay.envHint') }), _jsx(Text, { children: relayStartCommand(ctx.config, password) })] })) : null, official ? null : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('relay.docsHint') }) })), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
207
+ }
208
+ function placeholderText(ctx, id) {
209
+ const raw = getFieldPlaceholder(ctx.draft, id);
210
+ // Placeholders that name a translation key are resolved here so the model
211
+ // stays free of language concerns.
212
+ return raw.startsWith('placeholder.') ? ctx.t(raw) : raw;
213
+ }
214
+ function addressKindKey(kind) {
215
+ return `relay.address${kind.charAt(0).toUpperCase()}${kind.slice(1)}`;
216
+ }
217
+ /**
218
+ * A modal list of options driven by both arrow keys and the mouse.
219
+ *
220
+ * It owns the cursor so the highlighted row and the keyboard agree; the parent
221
+ * only learns which option was chosen.
222
+ */
223
+ export function ChoiceList({ options, current, onPick, onCancel, }) {
224
+ const [cursor, setCursor] = useState(() => {
225
+ const index = options.findIndex((option) => option.id === current);
226
+ return index >= 0 ? index : 0;
227
+ });
228
+ const active = options[Math.min(cursor, options.length - 1)];
229
+ useInput((_input, key) => {
230
+ if (key.escape) {
231
+ onCancel();
232
+ return;
233
+ }
234
+ if (key.upArrow)
235
+ setCursor((value) => (value - 1 + options.length) % options.length);
236
+ else if (key.downArrow)
237
+ setCursor((value) => (value + 1) % options.length);
238
+ else if (key.return && active)
239
+ onPick(active.id);
240
+ });
241
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { items: options.map((option) => ({ id: option.id, label: option.label })), selectedId: active?.id ?? '', onChange: (id) => setCursor(options.findIndex((option) => option.id === id)), onSelect: onPick }), active?.description ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: active.description }) })) : null] }));
242
+ }
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { theme } from '../theme.js';
5
+ import { Menu, Message, Panel } from '../components/common.js';
6
+ import { readLogTail, restartAll, startAll, stopAll } from '../api.js';
7
+ const LOG_LINES = 8;
8
+ export function Services({ ctx }) {
9
+ const { t, run, status } = ctx;
10
+ const [selected, setSelected] = useState('start');
11
+ const items = [
12
+ { id: 'start', label: t('services.start') },
13
+ { id: 'stop', label: t('services.stop') },
14
+ { id: 'restart', label: t('services.restart') },
15
+ ];
16
+ const activate = (id) => {
17
+ // Starting or restarting reads the configuration from disk, so doing it on
18
+ // top of an unsaved draft silently applies the old settings — which reads
19
+ // as "I changed the relay and it never took effect". Stopping is unaffected.
20
+ if (ctx.dirty && id !== 'stop') {
21
+ ctx.notify(t('services.unsavedBlocked'), 'error');
22
+ return;
23
+ }
24
+ return run(() => {
25
+ const config = ctx.config;
26
+ if (id === 'start') {
27
+ const result = startAll(config);
28
+ ctx.notify(result.managed
29
+ ? t('services.managedNotice', { manager: result.manager ?? '' })
30
+ : t('services.started'), 'success');
31
+ return;
32
+ }
33
+ if (id === 'stop') {
34
+ const result = stopAll(config);
35
+ ctx.notify(result.managed
36
+ ? t('services.managedNotice', { manager: result.manager ?? '' })
37
+ : t('services.stopped'), 'success');
38
+ return;
39
+ }
40
+ const result = restartAll(config);
41
+ ctx.notify(result.managed
42
+ ? t('services.managedNotice', { manager: result.manager ?? '' })
43
+ : t('services.restarted'), 'success');
44
+ });
45
+ };
46
+ useInput((_input, key) => {
47
+ const index = items.findIndex((item) => item.id === selected);
48
+ if (key.upArrow)
49
+ setSelected(items[(index - 1 + items.length) % items.length].id);
50
+ else if (key.downArrow)
51
+ setSelected(items[(index + 1) % items.length].id);
52
+ else if (key.return)
53
+ activate(selected);
54
+ }, { isActive: ctx.editingId === null });
55
+ const relayLog = readLogTail('relay', LOG_LINES);
56
+ const hostLog = readLogTail('host', LOG_LINES);
57
+ return (_jsxs(Panel, { title: t('services.title'), children: [_jsx(Menu, { items: items, selectedId: selected, onChange: setSelected, onSelect: activate }), ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('services.unsavedBlocked') }) })) : null, status?.keepalive.installed ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('services.managedNotice', { manager: status.keepalive.manager }) }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.muted, children: t('services.logs') }), _jsx(LogBlock, { title: t('services.logRelay'), lines: relayLog, emptyText: t('services.logEmpty') }), _jsx(LogBlock, { title: t('services.logHost'), lines: hostLog, emptyText: t('services.logEmpty') })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
58
+ }
59
+ function LogBlock({ title, lines, emptyText, }) {
60
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.muted, children: title }), lines.length === 0 ? (_jsx(Text, { color: theme.muted, children: emptyText })) : (lines.map((line, index) => (
61
+ // biome-ignore lint/suspicious/noArrayIndexKey: log lines are positional and may repeat
62
+ _jsx(Text, { color: theme.muted, wrap: "truncate-end", children: line }, index))))] }));
63
+ }