herdr-remote 0.2.19 → 0.2.21

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/dist/i18n/en.js CHANGED
@@ -219,5 +219,17 @@ const en = {
219
219
  'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
220
220
  'update.cannot.source': 'Source checkout — update with git',
221
221
  'update.cannot.linked': 'Linked package — managed by npm link',
222
+ 'relayUpdate.check': 'Relay {version}: check for updates',
223
+ 'relayUpdate.checking': 'Relay: checking npm…',
224
+ 'relayUpdate.upToDate': 'Relay up to date ({version})',
225
+ 'relayUpdate.available': 'Relay {latest} available, installed {installed} (press Enter to install)',
226
+ 'relayUpdate.needsCli': 'Relay {latest} needs a newer herdr-remote: update herdr-remote first',
227
+ 'relayUpdate.updating': 'Installing relay {version}…',
228
+ 'relayUpdate.updatingRetry': 'Installing relay {version}… (npm has not caught up yet, attempt {attempt})',
229
+ 'relayUpdate.restart': 'Relay {version} installed, {running} still running (press Enter to restart)',
230
+ 'relayUpdate.restarting': 'Restarting herdr-remote…',
231
+ 'relayUpdate.restarted': 'herdr-remote restarted; the relay now runs {version}.',
232
+ 'relayUpdate.errorNotApplied': 'npm finished, but relay {installed} is still installed',
233
+ 'relayUpdate.banner': 'Relay {latest} is out (installed {current}). Press 7 and Enter on the relay row to install.',
222
234
  };
223
235
  export default en;
package/dist/i18n/zh.js CHANGED
@@ -218,5 +218,17 @@ const zh = {
218
218
  'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
219
219
  'update.cannot.source': '源码目录运行,请使用 git 更新',
220
220
  'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
221
+ 'relayUpdate.check': 'Relay {version}:检查更新',
222
+ 'relayUpdate.checking': 'Relay:查询 npm 中…',
223
+ 'relayUpdate.upToDate': 'Relay 已是最新({version})',
224
+ 'relayUpdate.available': 'Relay 新版本 {latest} 可用,当前 {installed}(按 Enter 安装)',
225
+ 'relayUpdate.needsCli': 'Relay {latest} 需要更新 herdr-remote,请先更新上一行',
226
+ 'relayUpdate.updating': '安装 relay {version} 中…',
227
+ 'relayUpdate.updatingRetry': '安装 relay {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
228
+ 'relayUpdate.restart': 'Relay {version} 已安装,运行中的仍是 {running}(按 Enter 重启生效)',
229
+ 'relayUpdate.restarting': '正在重启 herdr-remote…',
230
+ 'relayUpdate.restarted': 'herdr-remote 已重启,relay 现在运行 {version}。',
231
+ 'relayUpdate.errorNotApplied': 'npm 已完成,但安装的仍是 relay {installed}',
232
+ 'relayUpdate.banner': 'Relay {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在 Relay 一行按 Enter 安装。',
221
233
  };
222
234
  export default zh;
@@ -0,0 +1,81 @@
1
+ // Updating the relay a local or LAN workstation runs, on its own.
2
+ //
3
+ // The relay is a package of its own that herdr-remote installs. A release of
4
+ // the relay alone leaves herdr-remote's version where it was, so the update
5
+ // check for herdr-remote says "up to date" while the relay on disk falls
6
+ // behind. This checks the relay by itself, and installs the newest relay that
7
+ // this herdr-remote's dependency range allows.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { PACKAGE_ROOT } from './paths.js';
12
+ import { checkForUpdate, compareVersions, currentVersion, performUpdate, } from './updater.js';
13
+ const RELAY_PACKAGE = 'herdr-remote-relay';
14
+ /** The relay installed under this herdr-remote, read from disk: an update moves it. */
15
+ export function installedRelayVersion() {
16
+ try {
17
+ const manifest = createRequire(import.meta.url).resolve(`${RELAY_PACKAGE}/package.json`);
18
+ return JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** The relay versions this herdr-remote declares it works with, e.g. `^0.3.9`. */
25
+ function relayRange() {
26
+ try {
27
+ const manifest = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
28
+ return manifest.dependencies?.[RELAY_PACKAGE] ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Whether `version` is inside a caret range, the only kind herdr-remote
36
+ * declares. Below 1.0 a caret holds the minor version (`^0.3.9` is `0.3.x`
37
+ * from `0.3.9`), which is exactly the line a relay must not cross alone.
38
+ */
39
+ export function withinCaretRange(version, range) {
40
+ const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range.trim());
41
+ if (!match)
42
+ return false;
43
+ const floor = `${match[1]}.${match[2]}.${match[3]}`;
44
+ const [major, minor] = version.split('.').map((part) => Number.parseInt(part, 10));
45
+ const [floorMajor, floorMinor] = [Number(match[1]), Number(match[2])];
46
+ if (compareVersions(version, floor) < 0 || major !== floorMajor)
47
+ return false;
48
+ return floorMajor > 0 || minor === floorMinor;
49
+ }
50
+ /** Whether a newer relay than the installed one is out, and whether this herdr-remote can take it. */
51
+ export async function checkForRelayUpdate(options = {}) {
52
+ const installed = installedRelayVersion();
53
+ const result = await checkForUpdate({
54
+ ...options,
55
+ packageName: RELAY_PACKAGE,
56
+ current: installed ?? '0.0.0',
57
+ });
58
+ if (!result.ok || !result.updateAvailable || !result.latest)
59
+ return result;
60
+ const range = relayRange();
61
+ if (range && !withinCaretRange(result.latest, range)) {
62
+ return { ...result, updateAvailable: false, needsNewerCli: true };
63
+ }
64
+ return result;
65
+ }
66
+ /**
67
+ * Install the newest relay this herdr-remote allows: the same herdr-remote,
68
+ * reinstalled, which npm resolves afresh, relay included. The installed
69
+ * herdr-remote does not change, and neither does anything it was tested with
70
+ * but the relay. Success is the relay on disk, not npm's exit code.
71
+ */
72
+ export async function performRelayUpdate({ relayVersion, readRelayVersion = installedRelayVersion, ...options }) {
73
+ const result = await performUpdate({ ...options, version: currentVersion() });
74
+ if (!result.ok)
75
+ return result;
76
+ const installed = readRelayVersion();
77
+ if (!installed || compareVersions(installed, relayVersion) < 0) {
78
+ return { ok: false, errorKey: 'relayUpdate.errorNotApplied', installed, output: result.output };
79
+ }
80
+ return { ...result, installed };
81
+ }
package/dist/tui/App.js CHANGED
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useApp, useInput } from 'ink';
4
4
  import { useMouse, useMouseTarget } from './mouse/index.js';
5
5
  import { theme } from './theme.js';
6
- import { checkForUpdate, createDraft, createTranslator, detectLocale, ensureRuntime, fullStatus, loadConfig, requiresRestart, updateChecksEnabled, } from './api.js';
6
+ import { checkForRelayUpdate, checkForUpdate, createDraft, createTranslator, detectLocale, ensureRuntime, fullStatus, loadConfig, requiresRestart, runsLocalRelay, updateChecksEnabled, } from './api.js';
7
7
  import { Overview } from './screens/Overview.js';
8
8
  import { PairScreen } from './screens/Pair.js';
9
9
  import { Services } from './screens/Services.js';
@@ -30,7 +30,7 @@ function Tab({ label, active, onSelect, }) {
30
30
  function Footer({ hints }) {
31
31
  return (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.muted, children: hints.join(' · ') }) }));
32
32
  }
33
- export function App({ initialLanguage, needsWizard, updateChecker = updateChecksEnabled() ? checkForUpdate : null, }) {
33
+ export function App({ initialLanguage, needsWizard, updateChecker = updateChecksEnabled() ? checkForUpdate : null, relayUpdateChecker = updateChecksEnabled() ? checkForRelayUpdate : null, }) {
34
34
  const { exit } = useApp();
35
35
  const mouse = useMouse();
36
36
  const [config, setConfig] = useState(() => loadConfig());
@@ -38,11 +38,13 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
38
38
  const [runtime, setRuntime] = useState(() => ensureRuntime());
39
39
  const [status, setStatus] = useState(null);
40
40
  const [tab, setTab] = useState('overview');
41
- const [message, setMessage] = useState(null);
41
+ const [notice, setNotice] = useState(null);
42
42
  const [busy, setBusy] = useState(false);
43
43
  const [editingId, setEditingId] = useState(null);
44
44
  const [wizardDone, setWizardDone] = useState(!needsWizard);
45
45
  const [updateCheck, setUpdateCheck] = useState(null);
46
+ const [relayUpdateCheck, setRelayUpdateCheck] = useState(null);
47
+ const localRelay = runsLocalRelay(config);
46
48
  // Every time the TUI opens: that is when someone is here to act on it.
47
49
  // Failure says nothing; the About tab can still be asked by hand.
48
50
  useEffect(() => {
@@ -59,13 +61,32 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
59
61
  cancelled = true;
60
62
  };
61
63
  }, [updateChecker]);
64
+ // A relay somewhere else is somebody else's to update.
65
+ useEffect(() => {
66
+ if (!relayUpdateChecker || !localRelay)
67
+ return undefined;
68
+ let cancelled = false;
69
+ relayUpdateChecker()
70
+ .then((result) => {
71
+ if (!cancelled && result?.ok)
72
+ setRelayUpdateCheck(result);
73
+ })
74
+ .catch(() => { });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [relayUpdateChecker, localRelay]);
62
79
  const locale = useMemo(() => detectLocale({ preference: initialLanguage ?? draft.ui.language }), [initialLanguage, draft.ui.language]);
63
80
  const t = useMemo(() => createTranslator(locale), [locale]);
64
81
  const dirty = useMemo(() => JSON.stringify(config) !== JSON.stringify(draft), [config, draft]);
65
82
  const restartPending = useMemo(() => dirty && requiresRestart(config, draft), [config, draft, dirty]);
66
- const notify = useCallback((text, level = 'info') => {
67
- setMessage({ text, level });
83
+ const notify = useCallback((next, level = 'info') => {
84
+ setNotice({ notice: next, level });
68
85
  }, []);
86
+ const message = notice && {
87
+ text: typeof notice.notice === 'string' ? notice.notice : notice.notice(t),
88
+ level: notice.level,
89
+ };
69
90
  const refresh = useCallback(() => {
70
91
  fullStatus(loadConfig())
71
92
  .then((next) => setStatus(next))
@@ -132,6 +153,8 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
132
153
  message,
133
154
  updateCheck,
134
155
  setUpdateCheck,
156
+ relayUpdateCheck,
157
+ setRelayUpdateCheck,
135
158
  };
136
159
  useInput((input, key) => {
137
160
  if (key.ctrl && input === 'c') {
@@ -143,13 +166,13 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
143
166
  return;
144
167
  }
145
168
  if (input === 'r') {
146
- setMessage(null);
169
+ setNotice(null);
147
170
  refresh();
148
171
  return;
149
172
  }
150
173
  if (input === 'm') {
151
174
  if (!mouse.supported) {
152
- notify(t('hint.mouseUnsupported'), 'info');
175
+ notify((t) => t('hint.mouseUnsupported'), 'info');
153
176
  return;
154
177
  }
155
178
  mouseChoice.current = !mouse.enabled;
@@ -191,5 +214,8 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
191
214
  : t('hint.mouseUnsupported'),
192
215
  t('hint.quit'),
193
216
  ].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 })] }));
217
+ 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, localRelay && relayUpdateCheck?.updateAvailable ? (_jsx(Text, { color: theme.warn, children: t('relayUpdate.banner', {
218
+ latest: relayUpdateCheck.latest ?? '',
219
+ current: relayUpdateCheck.current,
220
+ }) })) : 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
221
  }
package/dist/tui/api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // single surface to call.
6
6
  import { OFFICIAL_RELAY_URL, configExists, loadConfig } from '../config.js';
7
7
  import { configPath, stateDir } from '../paths.js';
8
- import { bindAddress, resolveAdminOrigin } from '../relay-urls.js';
8
+ import { bindAddress, resolveAdminOrigin, runsLocalRelay } from '../relay-urls.js';
9
9
  import { MIN_HERDR_VERSION, herdrVersion } from '../herdr-command.js';
10
10
  import { createTranslator, detectLocale } from '../i18n/index.js';
11
11
  import { listReachableAddresses } from '../net-interfaces.js';
@@ -15,8 +15,9 @@ import { ensureRuntime, regenerateHostIdentity, setRelayPassword } from '../runt
15
15
  import { pair, readLogTail } from '../service.js';
16
16
  import { fullStatus, restartAll, startAll, stopAll } from '../lifecycle.js';
17
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, };
18
+ import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
19
+ import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
20
+ export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, checkForRelayUpdate, installedRelayVersion, performRelayUpdate, runsLocalRelay, 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
21
  /** The command that starts a matching relay, ready to paste on the server. */
21
22
  export function relayStartCommand(config, password) {
22
23
  const publicUrl = config.relay.remoteUrl
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
+ import { useRelayUpdate } from './useRelayUpdate.js';
5
6
  import { Message, Panel, Row, Selectable } from '../components/common.js';
6
7
  import { canSelfUpdate, checkForUpdate, configPath, currentVersion, detectLocale, installKind, performUpdate, saveDraft, setField, stateDir, } from '../api.js';
7
8
  /** `https://registry.npmmirror.com` → `registry.npmmirror.com`, for one line of text. */
@@ -42,7 +43,12 @@ export function About({ ctx }) {
42
43
  return t('update.check');
43
44
  }
44
45
  })();
45
- const options = [...languageOptions, { id: 'update', label: updateLabel }];
46
+ const relay = useRelayUpdate(ctx);
47
+ const options = [
48
+ ...languageOptions,
49
+ { id: 'update', label: updateLabel },
50
+ ...(relay.visible ? [{ id: 'relay-update', label: relay.label }] : []),
51
+ ];
46
52
  /** Show a check's answer, whether this screen asked or the TUI did on opening. */
47
53
  const applyCheck = (result) => {
48
54
  checkRef.current = { registry: result.registry ?? '', sources: result.sources ?? [] };
@@ -50,12 +56,12 @@ export function About({ ctx }) {
50
56
  // answer. It is still asked, and said to be behind.
51
57
  const behind = result.behind ?? [];
52
58
  setSourceNote(result.updateAvailable && behind.length > 0 && result.registry
53
- ? t('update.mirrorBehind', {
59
+ ? {
54
60
  registries: behind
55
61
  .map((entry) => `${registryHost(entry.registry)} ${entry.version ?? ''}`.trim())
56
62
  .join(', '),
57
63
  source: registryHost(result.registry),
58
- })
64
+ }
59
65
  : null);
60
66
  setUpdate(result.updateAvailable
61
67
  ? { phase: 'available', latest: result.latest }
@@ -76,8 +82,9 @@ export function About({ ctx }) {
76
82
  // Which registries were tried, and what each of them said. Without this
77
83
  // the row reads "could not reach npm registry" on a machine where
78
84
  // `npm install` works perfectly, and there is nothing to act on.
79
- if (result.message)
80
- ctx.notify(t('update.errorNetworkDetail', { message: result.message }), 'error');
85
+ const { message } = result;
86
+ if (message)
87
+ ctx.notify((t) => t('update.errorNetworkDetail', { message }), 'error');
81
88
  return;
82
89
  }
83
90
  ctx.setUpdateCheck(result);
@@ -95,7 +102,7 @@ export function About({ ctx }) {
95
102
  const params = { version: latest, installed: result.installed ?? '' };
96
103
  setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorFailed', params });
97
104
  // npm's own words: "update failed" alone is what left this unfixable.
98
- ctx.notify(result.summary
105
+ ctx.notify((t) => result.summary
99
106
  ? t('update.errorFailedDetail', { message: result.summary })
100
107
  : t(result.errorKey ?? 'update.errorFailed', params), 'error');
101
108
  return;
@@ -104,7 +111,7 @@ export function About({ ctx }) {
104
111
  setSourceNote(null);
105
112
  // Installed: the line under the title has nothing left to announce.
106
113
  ctx.setUpdateCheck(null);
107
- ctx.notify(t('update.restartHint'), 'success');
114
+ ctx.notify((t) => t('update.restartHint'), 'success');
108
115
  };
109
116
  /**
110
117
  * One row, two meanings: check first, then install what the check found.
@@ -126,21 +133,26 @@ export function About({ ctx }) {
126
133
  };
127
134
  const choose = (language) => {
128
135
  const result = setField(draft, 'language', language);
129
- if (result.errorKey) {
130
- ctx.notify(t(result.errorKey), 'error');
136
+ const { errorKey } = result;
137
+ if (errorKey) {
138
+ ctx.notify((t) => t(errorKey), 'error');
131
139
  return;
132
140
  }
133
141
  ctx.updateDraft(result.draft);
134
142
  try {
135
143
  saveDraft(result.draft);
136
144
  ctx.reloadConfig();
137
- ctx.notify(t('common.saved', { path: configPath() }), 'success');
145
+ ctx.notify((t) => t('common.saved', { path: configPath() }), 'success');
138
146
  }
139
147
  catch (error) {
140
- ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
148
+ ctx.notify((t) => t('error.saveFailed', { message: error.message }), 'error');
141
149
  }
142
150
  };
143
151
  const activate = (id) => {
152
+ if (id === 'relay-update') {
153
+ relay.activate();
154
+ return;
155
+ }
144
156
  if (id === 'update') {
145
157
  activateUpdate();
146
158
  return;
@@ -162,5 +174,8 @@ export function About({ ctx }) {
162
174
  }, onHover: () => setSelected(option.id), children: `${option.label}${draft.ui.language === option.id ? ' ✓' : ''}` }, option.id))) }), _jsx(Text, { color: theme.muted, children: t('update.title') }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Selectable, { selected: selected === 'update', onSelect: () => {
163
175
  setSelected('update');
164
176
  activateUpdate();
165
- }, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? _jsx(Text, { color: theme.muted, children: ` ${sourceNote}` }) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
177
+ }, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? (_jsx(Text, { color: theme.muted, children: ` ${t('update.mirrorBehind', sourceNote)}` })) : null, relay.visible ? (_jsx(Selectable, { selected: selected === 'relay-update', onSelect: () => {
178
+ setSelected('relay-update');
179
+ relay.activate();
180
+ }, onHover: () => setSelected('relay-update'), children: relay.label })) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), relay.visible ? (_jsx(Row, { label: t('about.relayPackage'), children: _jsx(Text, { color: theme.muted, children: relay.installed ?? '?' }) })) : null, _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
166
181
  }
@@ -20,8 +20,9 @@ export function HerdrScreen({ ctx }) {
20
20
  ];
21
21
  const applyField = (id, value) => {
22
22
  const result = setField(draft, id, value);
23
- if (result.errorKey) {
24
- ctx.notify(t(result.errorKey), 'error');
23
+ const { errorKey } = result;
24
+ if (errorKey) {
25
+ ctx.notify((t) => t(errorKey), 'error');
25
26
  return false;
26
27
  }
27
28
  ctx.updateDraft(result.draft);
@@ -31,10 +32,10 @@ export function HerdrScreen({ ctx }) {
31
32
  try {
32
33
  const result = saveDraft(draft);
33
34
  ctx.reloadConfig();
34
- ctx.notify(t('common.saved', { path: result.path }), 'success');
35
+ ctx.notify((t) => t('common.saved', { path: result.path }), 'success');
35
36
  }
36
37
  catch (error) {
37
- ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
38
+ ctx.notify((t) => t('error.saveFailed', { message: error.message }), 'error');
38
39
  }
39
40
  };
40
41
  const activate = (id) => {
@@ -27,22 +27,22 @@ export function Keepalive({ ctx }) {
27
27
  ctx.run(() => {
28
28
  if (id === 'install') {
29
29
  const result = keepalive.install(ctx.config);
30
- ctx.notify(t('keepalive.installed', { manager: result.manager }), 'success');
30
+ ctx.notify((t) => t('keepalive.installed', { manager: result.manager }), 'success');
31
31
  return;
32
32
  }
33
33
  if (id === 'uninstall') {
34
34
  keepalive.uninstall(ctx.config);
35
- ctx.notify(t('keepalive.uninstalled'), 'success');
35
+ ctx.notify((t) => t('keepalive.uninstalled'), 'success');
36
36
  return;
37
37
  }
38
38
  if (id === 'restart') {
39
39
  keepalive.restart(ctx.config);
40
- ctx.notify(t('keepalive.restarted'), 'success');
40
+ ctx.notify((t) => t('keepalive.restarted'), 'success');
41
41
  return;
42
42
  }
43
43
  if (id === 'linger') {
44
44
  const result = keepalive.enableLinger();
45
- ctx.notify(t('keepalive.lingerDone', { username: result.username }), 'success');
45
+ ctx.notify((t) => t('keepalive.lingerDone', { username: result.username }), 'success');
46
46
  }
47
47
  });
48
48
  };
@@ -58,8 +58,9 @@ export function Keepalive({ ctx }) {
58
58
  if (editingId === 'keepaliveManager') {
59
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
60
  const result = setField(draft, 'keepaliveManager', manager);
61
- if (result.errorKey) {
62
- ctx.notify(t(result.errorKey), 'error');
61
+ const { errorKey } = result;
62
+ if (errorKey) {
63
+ ctx.notify((t) => t(errorKey), 'error');
63
64
  return;
64
65
  }
65
66
  ctx.updateDraft(result.draft);
@@ -68,7 +69,7 @@ export function Keepalive({ ctx }) {
68
69
  ctx.reloadConfig();
69
70
  }
70
71
  catch (error) {
71
- ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
72
+ ctx.notify((t) => t('error.saveFailed', { message: error.message }), 'error');
72
73
  }
73
74
  ctx.setEditing(null);
74
75
  }, onCancel: () => ctx.setEditing(null) }) }));
@@ -32,5 +32,5 @@ export function Overview({ ctx }) {
32
32
  ? t('common.running')
33
33
  : status.keepalive.installed
34
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' })] }));
35
+ : t('common.notInstalled')] })] }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: relayReachable ? (_jsxs(_Fragment, { children: [typeof health.hosts === 'number' ? (_jsx(Row, { label: t('overview.hosts'), children: _jsx(Text, { children: health.hosts }) })) : null, typeof health.clients === 'number' ? (_jsx(Row, { label: t('overview.devices'), children: _jsx(Text, { children: health.clients }) })) : null, _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
36
  }
@@ -43,7 +43,7 @@ export function PairScreen({ ctx }) {
43
43
  };
44
44
  }, [pairing, columns]);
45
45
  const generate = () => run(async () => {
46
- ctx.notify(t('pair.working'));
46
+ ctx.notify((t) => t('pair.working'));
47
47
  try {
48
48
  const result = await pair();
49
49
  const code = extractPairingCode(result);
@@ -65,8 +65,9 @@ export function RelayScreen({ ctx }) {
65
65
  let next = draft;
66
66
  for (const [id, value] of updates) {
67
67
  const result = setField(next, id, value);
68
- if (result.errorKey) {
69
- ctx.notify(t(result.errorKey), 'error');
68
+ const { errorKey } = result;
69
+ if (errorKey) {
70
+ ctx.notify((t) => t(errorKey), 'error');
70
71
  return false;
71
72
  }
72
73
  next = result.draft;
@@ -80,11 +81,11 @@ export function RelayScreen({ ctx }) {
80
81
  try {
81
82
  const result = saveDraft(draft);
82
83
  ctx.reloadConfig();
83
- ctx.notify(t('common.saved', { path: result.path }), 'success');
84
+ ctx.notify((t) => t('common.saved', { path: result.path }), 'success');
84
85
  }
85
86
  catch (error) {
86
87
  const problems = error.problems;
87
- ctx.notify(problems
88
+ ctx.notify((t) => problems
88
89
  ? problems.map((key) => t(key)).join(' ')
89
90
  : t('error.saveFailed', { message: error.message }), 'error');
90
91
  }
@@ -94,7 +95,7 @@ export function RelayScreen({ ctx }) {
94
95
  if (!entry)
95
96
  return;
96
97
  if (entry.kind === 'readonly') {
97
- ctx.notify(t('relay.officialFixed'), 'info');
98
+ ctx.notify((t) => t('relay.officialFixed'), 'info');
98
99
  return;
99
100
  }
100
101
  if (entry.kind === 'field' || entry.kind === 'password') {
@@ -117,14 +118,14 @@ export function RelayScreen({ ctx }) {
117
118
  ctx.run(() => {
118
119
  regenerateHostIdentity();
119
120
  ctx.reloadConfig();
120
- ctx.notify(t('relay.regenerated'), 'success');
121
+ ctx.notify((t) => t('relay.regenerated'), 'success');
121
122
  });
122
123
  return;
123
124
  }
124
125
  if (id === 'test') {
125
126
  ctx.run(async () => {
126
127
  const result = await probeRelay(ctx.config);
127
- ctx.notify(result.ok
128
+ ctx.notify((t) => result.ok
128
129
  ? t('relay.testOk', { version: result.version ?? '?', hosts: result.hosts ?? 0 })
129
130
  : t('relay.testFailed', { message: result.message ?? '' }), result.ok ? 'success' : 'error');
130
131
  });
@@ -193,7 +194,7 @@ export function RelayScreen({ ctx }) {
193
194
  setPassword(value);
194
195
  setRelayPassword(value);
195
196
  ctx.setEditing(null);
196
- ctx.notify(t('relay.passwordSaved'), 'success');
197
+ ctx.notify((t) => t('relay.passwordSaved'), 'success');
197
198
  }, onCancel: () => ctx.setEditing(null) })) : entry.kind === 'readonly' ? (
198
199
  // Fixed by us, so it is printed rather than offered for editing.
199
200
  _jsx(Text, { color: theme.muted, children: `${getField(draft, entry.id) || placeholderText(ctx, entry.id)} (${t('relay.locked')})` })) : entry.fieldKind === 'choice' ? (
@@ -18,27 +18,27 @@ export function Services({ ctx }) {
18
18
  // top of an unsaved draft silently applies the old settings — which reads
19
19
  // as "I changed the relay and it never took effect". Stopping is unaffected.
20
20
  if (ctx.dirty && id !== 'stop') {
21
- ctx.notify(t('services.unsavedBlocked'), 'error');
21
+ ctx.notify((t) => t('services.unsavedBlocked'), 'error');
22
22
  return;
23
23
  }
24
24
  return run(() => {
25
25
  const config = ctx.config;
26
26
  if (id === 'start') {
27
27
  const result = startAll(config);
28
- ctx.notify(result.managed
28
+ ctx.notify((t) => result.managed
29
29
  ? t('services.managedNotice', { manager: result.manager ?? '' })
30
30
  : t('services.started'), 'success');
31
31
  return;
32
32
  }
33
33
  if (id === 'stop') {
34
34
  const result = stopAll(config);
35
- ctx.notify(result.managed
35
+ ctx.notify((t) => result.managed
36
36
  ? t('services.managedNotice', { manager: result.manager ?? '' })
37
37
  : t('services.stopped'), 'success');
38
38
  return;
39
39
  }
40
40
  const result = restartAll(config);
41
- ctx.notify(result.managed
41
+ ctx.notify((t) => result.managed
42
42
  ? t('services.managedNotice', { manager: result.manager ?? '' })
43
43
  : t('services.restarted'), 'success');
44
44
  });
@@ -50,8 +50,9 @@ export function Wizard({ ctx, onDone }) {
50
50
  let next = draft;
51
51
  for (const [id, value] of updates) {
52
52
  const result = setField(next, id, value);
53
- if (result.errorKey) {
54
- ctx.notify(t(result.errorKey), 'error');
53
+ const { errorKey } = result;
54
+ if (errorKey) {
55
+ ctx.notify((t) => t(errorKey), 'error');
55
56
  return false;
56
57
  }
57
58
  next = result.draft;
@@ -102,7 +103,7 @@ export function Wizard({ ctx, onDone }) {
102
103
  catch (error) {
103
104
  // Keep-alive is a convenience; a container without systemd should not
104
105
  // block a working setup.
105
- ctx.notify(t('keepalive.failed', { message: error.message }), 'error');
106
+ ctx.notify((t) => t('keepalive.failed', { message: error.message }), 'error');
106
107
  }
107
108
  }
108
109
  if (startNow && !keepaliveInstalled)
@@ -173,7 +174,7 @@ export function Wizard({ ctx, onDone }) {
173
174
  if (step === 'relayUrl') {
174
175
  return (_jsxs(Panel, { title: t('wizard.relayTitle'), children: [header, _jsx(FieldRow, { label: t('wizard.relayUrlLabel'), selected: true, onSelect: () => { }, children: _jsx(TextField, { value: draft.relay.remoteUrl, placeholder: "wss://relay.example.com", active: true, onSubmit: (value) => {
175
176
  if (!value) {
176
- ctx.notify(t('error.remoteUrlRequired'), 'error');
177
+ ctx.notify((t) => t('error.remoteUrlRequired'), 'error');
177
178
  return;
178
179
  }
179
180
  if (apply('remoteUrl', value))
@@ -0,0 +1,122 @@
1
+ // The relay row on the About tab: check for, install and switch to a newer
2
+ // relay. Only a machine that runs its own relay has one to update.
3
+ import { useEffect, useState } from 'react';
4
+ import { canSelfUpdate, checkForRelayUpdate, compareVersions, currentVersion, installKind, installedRelayVersion, performRelayUpdate, restartAll, runsLocalRelay, } from '../api.js';
5
+ export function useRelayUpdate(ctx) {
6
+ const { t } = ctx;
7
+ const [state, setState] = useState({ phase: 'idle' });
8
+ const [source, setSource] = useState({
9
+ registry: '',
10
+ sources: [],
11
+ });
12
+ const visible = runsLocalRelay(ctx.config);
13
+ const installed = installedRelayVersion();
14
+ const health = ctx.status?.relay.health;
15
+ const running = typeof health?.version === 'string' ? health.version : null;
16
+ const busy = state.phase === 'checking' || state.phase === 'updating';
17
+ // Installed but not yet running: an update from here, or from npm by hand.
18
+ const restartNeeded = !busy &&
19
+ state.phase !== 'restarting' &&
20
+ !!installed &&
21
+ !!running &&
22
+ compareVersions(installed, running) > 0;
23
+ const applyCheck = (result) => {
24
+ setSource({ registry: result.registry ?? '', sources: result.sources ?? [] });
25
+ const latest = result.latest;
26
+ if (result.needsNewerCli)
27
+ setState({ phase: 'needsCli', latest });
28
+ else if (result.updateAvailable)
29
+ setState({ phase: 'available', latest });
30
+ else
31
+ setState({ phase: 'current', version: result.current });
32
+ };
33
+ // The TUI asked when it opened; start from that answer.
34
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only a new answer from the opening check matters, not each render's applyCheck or phase
35
+ useEffect(() => {
36
+ if (ctx.relayUpdateCheck?.ok && state.phase === 'idle')
37
+ applyCheck(ctx.relayUpdateCheck);
38
+ }, [ctx.relayUpdateCheck]);
39
+ const check = async () => {
40
+ setState({ phase: 'checking' });
41
+ const result = await checkForRelayUpdate();
42
+ if (!result.ok) {
43
+ setState({ phase: 'error', messageKey: result.errorKey ?? 'update.errorNetwork' });
44
+ const { message } = result;
45
+ if (message)
46
+ ctx.notify((t) => t('update.errorNetworkDetail', { message }), 'error');
47
+ return;
48
+ }
49
+ ctx.setRelayUpdateCheck(result);
50
+ applyCheck(result);
51
+ };
52
+ const install = async (latest) => {
53
+ setState({ phase: 'updating', latest, attempt: 1 });
54
+ const result = await performRelayUpdate({
55
+ ...source,
56
+ relayVersion: latest,
57
+ onAttempt: ({ attempt }) => setState({ phase: 'updating', latest, attempt }),
58
+ });
59
+ if (!result.ok) {
60
+ const params = { version: currentVersion(), installed: result.installed ?? '' };
61
+ const messageKey = result.errorKey ?? 'update.errorFailed';
62
+ setState({ phase: 'error', messageKey, params });
63
+ const { summary } = result;
64
+ ctx.notify((t) => summary ? t('update.errorFailedDetail', { message: summary }) : t(messageKey, params), 'error');
65
+ return;
66
+ }
67
+ ctx.setRelayUpdateCheck(null);
68
+ setState({ phase: 'current', version: latest });
69
+ };
70
+ const restart = () => {
71
+ setState({ phase: 'restarting' });
72
+ ctx.run(() => {
73
+ restartAll(ctx.config);
74
+ const version = installed ?? '';
75
+ setState({ phase: 'current', version });
76
+ ctx.notify((t) => t('relayUpdate.restarted', { version }), 'success');
77
+ });
78
+ };
79
+ /** One row: restart onto a relay already installed, install one found, or check. */
80
+ const activate = () => {
81
+ if (busy || state.phase === 'restarting')
82
+ return;
83
+ if (restartNeeded) {
84
+ restart();
85
+ return;
86
+ }
87
+ if (state.phase === 'available') {
88
+ if (!canSelfUpdate()) {
89
+ setState({ phase: 'error', messageKey: `update.cannot.${installKind()}` });
90
+ return;
91
+ }
92
+ void install(state.latest);
93
+ return;
94
+ }
95
+ void check();
96
+ };
97
+ const label = (() => {
98
+ if (restartNeeded)
99
+ return t('relayUpdate.restart', { version: installed ?? '', running: running ?? '' });
100
+ switch (state.phase) {
101
+ case 'checking':
102
+ return t('relayUpdate.checking');
103
+ case 'current':
104
+ return t('relayUpdate.upToDate', { version: state.version });
105
+ case 'available':
106
+ return t('relayUpdate.available', { latest: state.latest, installed: installed ?? '?' });
107
+ case 'needsCli':
108
+ return t('relayUpdate.needsCli', { latest: state.latest });
109
+ case 'updating':
110
+ return state.attempt > 1
111
+ ? t('relayUpdate.updatingRetry', { version: state.latest, attempt: state.attempt })
112
+ : t('relayUpdate.updating', { version: state.latest });
113
+ case 'restarting':
114
+ return t('relayUpdate.restarting');
115
+ case 'error':
116
+ return t(state.messageKey, state.params);
117
+ default:
118
+ return t('relayUpdate.check', { version: installed ?? '?' });
119
+ }
120
+ })();
121
+ return { visible, installed, label, activate };
122
+ }
package/dist/updater.js CHANGED
@@ -165,13 +165,13 @@ async function fetchJson(url, { timeoutMs, fetchImpl }) {
165
165
  * format, which npmjs refuses on that path with a 406, so every check quietly
166
166
  * fell through to a mirror that had not synced the release yet.
167
167
  */
168
- async function askRegistry(registry, { timeoutMs, fetchImpl }) {
168
+ async function askRegistry(registry, { timeoutMs, fetchImpl, packageName, }) {
169
169
  const endpoints = [
170
170
  {
171
- url: `${registry}/-/package/${PACKAGE_NAME}/dist-tags`,
171
+ url: `${registry}/-/package/${packageName}/dist-tags`,
172
172
  read: (body) => body?.latest,
173
173
  },
174
- { url: `${registry}/${PACKAGE_NAME}/latest`, read: (body) => body?.version },
174
+ { url: `${registry}/${packageName}/latest`, read: (body) => body?.version },
175
175
  ];
176
176
  let message = null;
177
177
  for (const endpoint of endpoints) {
@@ -199,11 +199,12 @@ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
199
199
  * newest version, preferred order first, which is where an install should come
200
200
  * from; `behind` lists the ones that do not, so the screen can say why.
201
201
  */
202
- async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(), } = {}) {
203
- const current = currentVersion();
202
+ async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(),
203
+ // Another package this one installs, and the version of it on disk: the relay.
204
+ packageName = PACKAGE_NAME, current = currentVersion(), } = {}) {
204
205
  const attempts = await Promise.all(registries.map(async (registry) => ({
205
206
  registry,
206
- ...(await askRegistry(registry, { timeoutMs, fetchImpl })),
207
+ ...(await askRegistry(registry, { timeoutMs, fetchImpl, packageName })),
207
208
  })));
208
209
  const answers = attempts.filter((attempt) => attempt.ok);
209
210
  if (answers.length === 0) {
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.19"
3
+ version = "0.2.21"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",