herdr-remote 0.2.20 → 0.2.22
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/README.md +12 -2
- package/README.zh-CN.md +11 -1
- package/dist/cli.js +23 -4
- package/dist/config.js +8 -1
- package/dist/herdr-api.js +5 -3
- package/dist/herdr-command.js +32 -15
- package/dist/herdr-plugin.js +5 -1
- package/dist/herdr-server.js +3 -2
- package/dist/i18n/en.js +20 -0
- package/dist/i18n/zh.js +20 -0
- package/dist/keepalive/detached.js +13 -3
- package/dist/keepalive/index.js +16 -8
- package/dist/keepalive/launchd.js +4 -4
- package/dist/keepalive/systemd.js +8 -4
- package/dist/keepalive/windows-service-setup.js +129 -0
- package/dist/keepalive/windows-service.js +272 -0
- package/dist/lifecycle.js +15 -1
- package/dist/net-interfaces.js +15 -6
- package/dist/paths.js +16 -4
- package/dist/pty-session.js +10 -2
- package/dist/relay-updater.js +81 -0
- package/dist/service.js +2 -0
- package/dist/socket-discovery.js +15 -3
- package/dist/supervisor.js +1 -0
- package/dist/terminal-font/deps.js +11 -1
- package/dist/terminal-font/files.js +13 -10
- package/dist/terminal-font/readers.js +60 -3
- package/dist/terminal-font/terminals.js +3 -0
- package/dist/terminal-palette.js +78 -18
- package/dist/tui/App.js +34 -8
- package/dist/tui/api.js +6 -4
- package/dist/tui/screens/About.js +30 -15
- package/dist/tui/screens/Herdr.js +5 -4
- package/dist/tui/screens/Keepalive.js +25 -14
- package/dist/tui/screens/Pair.js +1 -1
- package/dist/tui/screens/Relay.js +9 -8
- package/dist/tui/screens/Services.js +4 -4
- package/dist/tui/screens/Wizard.js +11 -5
- package/dist/tui/screens/useRelayUpdate.js +122 -0
- package/dist/updater/npm.js +62 -0
- package/dist/updater.js +8 -37
- package/herdr-plugin.toml +2 -2
- package/package.json +2 -1
- package/vendor/winsw/LICENSE +21 -0
- package/vendor/winsw/README.md +9 -0
- package/vendor/winsw/WinSW.NET461.exe +0 -0
|
@@ -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
|
-
|
|
24
|
-
|
|
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) => {
|
|
@@ -4,8 +4,7 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
5
|
import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
|
|
6
6
|
import { ChoiceList } from './Relay.js';
|
|
7
|
-
import { keepalive, saveDraft, setField } from '../api.js';
|
|
8
|
-
const MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
|
|
7
|
+
import { KEEPALIVE_MANAGERS, keepalive, saveDraft, setField } from '../api.js';
|
|
9
8
|
export function Keepalive({ ctx }) {
|
|
10
9
|
const { t, status, draft, editingId } = ctx;
|
|
11
10
|
const [selected, setSelected] = useState('install');
|
|
@@ -24,25 +23,36 @@ export function Keepalive({ ctx }) {
|
|
|
24
23
|
ctx.setEditing('keepaliveManager');
|
|
25
24
|
return;
|
|
26
25
|
}
|
|
27
|
-
ctx.run(() => {
|
|
26
|
+
ctx.run(async () => {
|
|
28
27
|
if (id === 'install') {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
if (keepalive.detectManager(ctx.config.keepalive.manager) === 'windows-service')
|
|
29
|
+
ctx.notify((t) => t('keepalive.serviceWindowHint'), 'info');
|
|
30
|
+
try {
|
|
31
|
+
const result = await keepalive.install(ctx.config);
|
|
32
|
+
ctx.notify((t) => t('keepalive.installed', { manager: result.manager }), 'success');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ELEVATION_CANCELLED') {
|
|
36
|
+
ctx.notify((t) => t('keepalive.elevationCancelled'), 'info');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
31
41
|
return;
|
|
32
42
|
}
|
|
33
43
|
if (id === 'uninstall') {
|
|
34
|
-
keepalive.uninstall(ctx.config);
|
|
35
|
-
ctx.notify(t('keepalive.uninstalled'), 'success');
|
|
44
|
+
await keepalive.uninstall(ctx.config);
|
|
45
|
+
ctx.notify((t) => t('keepalive.uninstalled'), 'success');
|
|
36
46
|
return;
|
|
37
47
|
}
|
|
38
48
|
if (id === 'restart') {
|
|
39
49
|
keepalive.restart(ctx.config);
|
|
40
|
-
ctx.notify(t('keepalive.restarted'), 'success');
|
|
50
|
+
ctx.notify((t) => t('keepalive.restarted'), 'success');
|
|
41
51
|
return;
|
|
42
52
|
}
|
|
43
53
|
if (id === 'linger') {
|
|
44
54
|
const result = keepalive.enableLinger();
|
|
45
|
-
ctx.notify(t('keepalive.lingerDone', { username: result.username }), 'success');
|
|
55
|
+
ctx.notify((t) => t('keepalive.lingerDone', { username: result.username }), 'success');
|
|
46
56
|
}
|
|
47
57
|
});
|
|
48
58
|
};
|
|
@@ -56,10 +66,11 @@ export function Keepalive({ ctx }) {
|
|
|
56
66
|
activate(selected);
|
|
57
67
|
}, { isActive: editingId === null });
|
|
58
68
|
if (editingId === 'keepaliveManager') {
|
|
59
|
-
return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options:
|
|
69
|
+
return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options: KEEPALIVE_MANAGERS.map((manager) => ({ id: manager, label: manager })), current: draft.keepalive.manager, onPick: (manager) => {
|
|
60
70
|
const result = setField(draft, 'keepaliveManager', manager);
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const { errorKey } = result;
|
|
72
|
+
if (errorKey) {
|
|
73
|
+
ctx.notify((t) => t(errorKey), 'error');
|
|
63
74
|
return;
|
|
64
75
|
}
|
|
65
76
|
ctx.updateDraft(result.draft);
|
|
@@ -68,7 +79,7 @@ export function Keepalive({ ctx }) {
|
|
|
68
79
|
ctx.reloadConfig();
|
|
69
80
|
}
|
|
70
81
|
catch (error) {
|
|
71
|
-
ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
|
|
82
|
+
ctx.notify((t) => t('error.saveFailed', { message: error.message }), 'error');
|
|
72
83
|
}
|
|
73
84
|
ctx.setEditing(null);
|
|
74
85
|
}, onCancel: () => ctx.setEditing(null) }) }));
|
|
@@ -80,5 +91,5 @@ export function Keepalive({ ctx }) {
|
|
|
80
91
|
: 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
92
|
setSelected(entry.id);
|
|
82
93
|
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' })] }));
|
|
94
|
+
}, 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, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : 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
95
|
}
|
package/dist/tui/screens/Pair.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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;
|
|
@@ -96,13 +97,18 @@ export function Wizard({ ctx, onDone }) {
|
|
|
96
97
|
let keepaliveInstalled = false;
|
|
97
98
|
if (installKeepalive) {
|
|
98
99
|
try {
|
|
99
|
-
keepalive.
|
|
100
|
+
if (keepalive.detectManager(finalConfig.keepalive.manager) === 'windows-service')
|
|
101
|
+
ctx.notify((t) => t('keepalive.serviceWindowHint'), 'info');
|
|
102
|
+
await keepalive.install(finalConfig);
|
|
100
103
|
keepaliveInstalled = true;
|
|
101
104
|
}
|
|
102
105
|
catch (error) {
|
|
103
106
|
// Keep-alive is a convenience; a container without systemd should not
|
|
104
107
|
// block a working setup.
|
|
105
|
-
|
|
108
|
+
if (error.code === 'ELEVATION_CANCELLED')
|
|
109
|
+
ctx.notify((t) => t('keepalive.elevationCancelled'), 'info');
|
|
110
|
+
else
|
|
111
|
+
ctx.notify((t) => t('keepalive.failed', { message: error.message }), 'error');
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
if (startNow && !keepaliveInstalled)
|
|
@@ -173,7 +179,7 @@ export function Wizard({ ctx, onDone }) {
|
|
|
173
179
|
if (step === 'relayUrl') {
|
|
174
180
|
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
181
|
if (!value) {
|
|
176
|
-
ctx.notify(t('error.remoteUrlRequired'), 'error');
|
|
182
|
+
ctx.notify((t) => t('error.remoteUrlRequired'), 'error');
|
|
177
183
|
return;
|
|
178
184
|
}
|
|
179
185
|
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, whileServicesStopped, } 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 whileServicesStopped(ctx.config, () => 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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* After the CVE-2024-27980 fix, spawning `.cmd` without a shell returns EINVAL;
|
|
5
|
+
* npm-cli.js lets the current Node process run npm without a shell. `npm.cmd`
|
|
6
|
+
* requires cmd.exe, whose quote rules cannot stop `%` expansion in registry URLs.
|
|
7
|
+
*/
|
|
8
|
+
export function npmInvocation({ args, platform = process.platform, env = process.env, execPath = process.execPath, exists = fs.existsSync, }) {
|
|
9
|
+
if (platform !== 'win32')
|
|
10
|
+
return { command: 'npm', args };
|
|
11
|
+
const npmExecPath = env.npm_execpath;
|
|
12
|
+
if (npmExecPath && /npm-cli\.js$/i.test(npmExecPath) && exists(npmExecPath)) {
|
|
13
|
+
return { command: execPath, args: [npmExecPath, ...args] };
|
|
14
|
+
}
|
|
15
|
+
const adjacentNpmCli = path.join(path.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
16
|
+
if (exists(adjacentNpmCli))
|
|
17
|
+
return { command: execPath, args: [adjacentNpmCli, ...args] };
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function runNpm(spawnImpl, args, timeoutMs, invocationOptions = {}) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let child;
|
|
23
|
+
try {
|
|
24
|
+
const invocation = npmInvocation({ args, ...invocationOptions });
|
|
25
|
+
if (!invocation) {
|
|
26
|
+
resolve({
|
|
27
|
+
ok: false,
|
|
28
|
+
spawnFailed: true,
|
|
29
|
+
output: 'npm-cli.js was not found next to this Node.js; run npm install -g herdr-remote yourself.',
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
child = spawnImpl(invocation.command, invocation.args, {
|
|
34
|
+
timeout: timeoutMs,
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
resolve({
|
|
40
|
+
ok: false,
|
|
41
|
+
spawnFailed: true,
|
|
42
|
+
output: String(error?.message || error),
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let output = '';
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = (result) => {
|
|
49
|
+
if (!settled) {
|
|
50
|
+
settled = true;
|
|
51
|
+
resolve(result);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const collect = (chunk) => {
|
|
55
|
+
output += String(chunk);
|
|
56
|
+
};
|
|
57
|
+
child.stdout?.on('data', collect);
|
|
58
|
+
child.stderr?.on('data', collect);
|
|
59
|
+
child.on('error', (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
|
|
60
|
+
child.on('close', (code) => finish({ ok: code === 0, output: output.trim() }));
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/updater.js
CHANGED
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { PACKAGE_ROOT } from './paths.js';
|
|
12
|
+
import { runNpm } from './updater/npm.js';
|
|
12
13
|
const PACKAGE_NAME = 'herdr-remote';
|
|
13
14
|
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
14
15
|
/**
|
|
@@ -165,13 +166,13 @@ async function fetchJson(url, { timeoutMs, fetchImpl }) {
|
|
|
165
166
|
* format, which npmjs refuses on that path with a 406, so every check quietly
|
|
166
167
|
* fell through to a mirror that had not synced the release yet.
|
|
167
168
|
*/
|
|
168
|
-
async function askRegistry(registry, { timeoutMs, fetchImpl }) {
|
|
169
|
+
async function askRegistry(registry, { timeoutMs, fetchImpl, packageName, }) {
|
|
169
170
|
const endpoints = [
|
|
170
171
|
{
|
|
171
|
-
url: `${registry}/-/package/${
|
|
172
|
+
url: `${registry}/-/package/${packageName}/dist-tags`,
|
|
172
173
|
read: (body) => body?.latest,
|
|
173
174
|
},
|
|
174
|
-
{ url: `${registry}/${
|
|
175
|
+
{ url: `${registry}/${packageName}/latest`, read: (body) => body?.version },
|
|
175
176
|
];
|
|
176
177
|
let message = null;
|
|
177
178
|
for (const endpoint of endpoints) {
|
|
@@ -199,11 +200,12 @@ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
|
|
|
199
200
|
* newest version, preferred order first, which is where an install should come
|
|
200
201
|
* from; `behind` lists the ones that do not, so the screen can say why.
|
|
201
202
|
*/
|
|
202
|
-
async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(),
|
|
203
|
-
|
|
203
|
+
async function checkForUpdate({ timeoutMs = 6000, fetchImpl = globalThis.fetch, registries = registryCandidates(),
|
|
204
|
+
// Another package this one installs, and the version of it on disk: the relay.
|
|
205
|
+
packageName = PACKAGE_NAME, current = currentVersion(), } = {}) {
|
|
204
206
|
const attempts = await Promise.all(registries.map(async (registry) => ({
|
|
205
207
|
registry,
|
|
206
|
-
...(await askRegistry(registry, { timeoutMs, fetchImpl })),
|
|
208
|
+
...(await askRegistry(registry, { timeoutMs, fetchImpl, packageName })),
|
|
207
209
|
})));
|
|
208
210
|
const answers = attempts.filter((attempt) => attempt.ok);
|
|
209
211
|
if (answers.length === 0) {
|
|
@@ -267,37 +269,6 @@ function installedVersionOnDisk() {
|
|
|
267
269
|
return null;
|
|
268
270
|
}
|
|
269
271
|
}
|
|
270
|
-
function runNpm(spawnImpl, args, timeoutMs) {
|
|
271
|
-
return new Promise((resolve) => {
|
|
272
|
-
let child;
|
|
273
|
-
try {
|
|
274
|
-
child = spawnImpl('npm', args, { timeout: timeoutMs });
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
resolve({
|
|
278
|
-
ok: false,
|
|
279
|
-
spawnFailed: true,
|
|
280
|
-
output: String(error?.message || error),
|
|
281
|
-
});
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
let output = '';
|
|
285
|
-
let settled = false;
|
|
286
|
-
const finish = (result) => {
|
|
287
|
-
if (!settled) {
|
|
288
|
-
settled = true;
|
|
289
|
-
resolve(result);
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
const collect = (chunk) => {
|
|
293
|
-
output += String(chunk);
|
|
294
|
-
};
|
|
295
|
-
child.stdout?.on('data', collect);
|
|
296
|
-
child.stderr?.on('data', collect);
|
|
297
|
-
child.on('error', (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
|
|
298
|
-
child.on('close', (code) => finish({ ok: code === 0, output: output.trim() }));
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
272
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
302
273
|
/**
|
|
303
274
|
* Install a release over this one.
|
package/herdr-plugin.toml
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
id = "herdr.remote.web"
|
|
2
2
|
name = "Herdr Remote Web"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.22"
|
|
4
4
|
min_herdr_version = "0.9.1"
|
|
5
5
|
description = "Browser access to Herdr workspaces via local or self-hosted relay"
|
|
6
|
-
platforms = ["linux", "macos"]
|
|
6
|
+
platforms = ["linux", "macos", "windows"]
|
|
7
7
|
|
|
8
8
|
# No [[build]] steps: this package is installed from npm with its TUI bundle and
|
|
9
9
|
# web assets already built. Herdr should never compile anything on the user's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-remote",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"bin",
|
|
33
33
|
"dist",
|
|
34
|
+
"vendor",
|
|
34
35
|
"herdr-plugin.toml",
|
|
35
36
|
"config.example.json",
|
|
36
37
|
"README.zh-CN.md"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees, Inc., Oleg Nenashev and other contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# WinSW
|
|
2
|
+
|
|
3
|
+
- Source: https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe
|
|
4
|
+
- Version: 2.12.0 (`WinSW.NET461.exe`)
|
|
5
|
+
- SHA-256: `b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f`
|
|
6
|
+
- License: MIT; see [LICENSE](./LICENSE).
|
|
7
|
+
- Purpose: hosts `herdr-remote run --daemon` as a Windows service.
|
|
8
|
+
|
|
9
|
+
To upgrade, replace the executable as a whole and update its SHA-256 here.
|
|
Binary file
|