herdr-remote 0.2.2 → 0.2.4
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 +16 -121
- package/README.zh-CN.md +50 -0
- package/bin/herdr-remote.js +14 -15
- package/dist/tui.mjs +327 -199
- package/herdr-plugin.toml +7 -7
- package/package.json +4 -3
- package/src/i18n/en.js +76 -72
- package/src/i18n/zh.js +77 -73
- package/src/settings-model.js +44 -1
- package/src/updater.js +127 -23
package/src/settings-model.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
ACCESS_MODES,
|
|
12
12
|
KEEPALIVE_MANAGERS,
|
|
13
13
|
LANGUAGES,
|
|
14
|
+
OFFICIAL_RELAY_URL,
|
|
14
15
|
configDir,
|
|
15
16
|
configPath,
|
|
16
17
|
isLoopbackHost,
|
|
@@ -40,6 +41,30 @@ const FIELDS = [
|
|
|
40
41
|
|
|
41
42
|
const EMPTY = '';
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The access modes a *person* chooses between.
|
|
46
|
+
*
|
|
47
|
+
* The official relay is stored as `remote` with a known URL, because that is
|
|
48
|
+
* exactly what it is and nothing downstream should have to learn a fourth mode.
|
|
49
|
+
* It is still a separate answer to "how do I reach this workstation", though:
|
|
50
|
+
* one option needs no server and no credentials, the other needs both. Keeping
|
|
51
|
+
* that distinction here — rather than in whichever screen happens to draw it —
|
|
52
|
+
* is what stops the official relay from being displayed as "self-hosted relay"
|
|
53
|
+
* that merely happens to hold our address.
|
|
54
|
+
*/
|
|
55
|
+
const SELECTABLE_MODES = ['local', 'lan', 'official', 'remote'];
|
|
56
|
+
|
|
57
|
+
/** Which of `SELECTABLE_MODES` this draft represents. */
|
|
58
|
+
function selectedMode(draft) {
|
|
59
|
+
if (draft.relay.mode === 'remote' && draft.relay.remoteUrl === OFFICIAL_RELAY_URL) return 'official';
|
|
60
|
+
return draft.relay.mode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when the relay is the one we run, so its address and password are ours. */
|
|
64
|
+
function isOfficialRelay(draft) {
|
|
65
|
+
return selectedMode(draft) === 'official';
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function clone(value) {
|
|
44
69
|
return JSON.parse(JSON.stringify(value));
|
|
45
70
|
}
|
|
@@ -121,7 +146,22 @@ function setField(draft, id, rawValue) {
|
|
|
121
146
|
|
|
122
147
|
switch (id) {
|
|
123
148
|
case 'mode': {
|
|
124
|
-
if (!
|
|
149
|
+
if (!SELECTABLE_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
|
|
150
|
+
// Picking the official relay fills in its address in the same edit: a
|
|
151
|
+
// remote mode with no URL is not a valid state, and asking for the
|
|
152
|
+
// address we already know would be asking the user to do our filing.
|
|
153
|
+
if (value === 'official') {
|
|
154
|
+
next.relay.mode = 'remote';
|
|
155
|
+
next.relay.remoteUrl = OFFICIAL_RELAY_URL;
|
|
156
|
+
next.relay.publicUrl = EMPTY;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
// Leaving the official relay clears its address, so the self-hosted URL
|
|
160
|
+
// field is empty and asking to be filled in rather than pre-loaded with
|
|
161
|
+
// an address that belongs to somebody else's server.
|
|
162
|
+
if (value === 'remote' && next.relay.remoteUrl === OFFICIAL_RELAY_URL) {
|
|
163
|
+
next.relay.remoteUrl = EMPTY;
|
|
164
|
+
}
|
|
125
165
|
next.relay.mode = value;
|
|
126
166
|
if (value === 'lan'
|
|
127
167
|
&& (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
|
|
@@ -254,8 +294,11 @@ function requiresRestart(before, after) {
|
|
|
254
294
|
|
|
255
295
|
module.exports = {
|
|
256
296
|
FIELDS,
|
|
297
|
+
SELECTABLE_MODES,
|
|
257
298
|
createDraft,
|
|
258
299
|
fieldsForMode,
|
|
300
|
+
isOfficialRelay,
|
|
301
|
+
selectedMode,
|
|
259
302
|
getField,
|
|
260
303
|
getFieldPlaceholder,
|
|
261
304
|
setField,
|
package/src/updater.js
CHANGED
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
// checkout would replace the tree someone is working in.
|
|
9
9
|
|
|
10
10
|
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
11
12
|
const path = require('node:path');
|
|
12
13
|
const { spawn } = require('node:child_process');
|
|
13
14
|
const { PACKAGE_ROOT } = require('./config');
|
|
14
15
|
|
|
15
16
|
const PACKAGE_NAME = 'herdr-remote';
|
|
16
|
-
const
|
|
17
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A public mirror, tried last.
|
|
21
|
+
*
|
|
22
|
+
* `registry.npmjs.org` is not reachable from every network this runs on — in
|
|
23
|
+
* mainland China it usually is not — and "could not reach npm registry" on a
|
|
24
|
+
* machine that installs packages perfectly well is a bug report, not a
|
|
25
|
+
* diagnosis. The mirror is read-only and only ever asked for a version number.
|
|
26
|
+
*/
|
|
27
|
+
const MIRROR_REGISTRY = 'https://registry.npmmirror.com';
|
|
17
28
|
|
|
18
29
|
function currentVersion() {
|
|
19
30
|
try {
|
|
@@ -50,6 +61,59 @@ function canSelfUpdate() {
|
|
|
50
61
|
return installKind() === 'npm';
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
function normalizeRegistry(value) {
|
|
65
|
+
if (typeof value !== 'string') return null;
|
|
66
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
67
|
+
if (!/^https?:\/\//i.test(trimmed)) return null;
|
|
68
|
+
return trimmed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The `registry=` line from an npmrc file, if it has one. */
|
|
72
|
+
function registryFromNpmrc(filePath) {
|
|
73
|
+
let contents;
|
|
74
|
+
try {
|
|
75
|
+
contents = fs.readFileSync(filePath, 'utf8');
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
let found = null;
|
|
80
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
81
|
+
const line = rawLine.trim();
|
|
82
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
83
|
+
const match = /^registry\s*=\s*(.+)$/i.exec(line);
|
|
84
|
+
// The last assignment wins, as it does for npm itself.
|
|
85
|
+
if (match) found = normalizeRegistry(match[1].replace(/^["']|["']$/g, ''));
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where to ask, in the order to ask.
|
|
92
|
+
*
|
|
93
|
+
* Whatever npm itself is configured to use comes first: a machine behind a
|
|
94
|
+
* corporate proxy or on a mirror has already answered this question, and
|
|
95
|
+
* ignoring that answer is what made the check fail on a machine where
|
|
96
|
+
* `npm install` works. The public registry and then a public mirror follow, so
|
|
97
|
+
* a private registry that does not carry this package is not the end of it.
|
|
98
|
+
*/
|
|
99
|
+
function registryCandidates({ env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
100
|
+
const candidates = [];
|
|
101
|
+
const add = (value) => {
|
|
102
|
+
const normalized = normalizeRegistry(value);
|
|
103
|
+
if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// npm exports its whole config into the environment of anything it runs.
|
|
107
|
+
add(env.npm_config_registry);
|
|
108
|
+
add(env.NPM_CONFIG_REGISTRY);
|
|
109
|
+
add(env.HERDR_REMOTE_REGISTRY);
|
|
110
|
+
add(registryFromNpmrc(path.join(cwd, '.npmrc')));
|
|
111
|
+
add(registryFromNpmrc(path.join(home, '.npmrc')));
|
|
112
|
+
add(DEFAULT_REGISTRY);
|
|
113
|
+
add(MIRROR_REGISTRY);
|
|
114
|
+
return candidates;
|
|
115
|
+
}
|
|
116
|
+
|
|
53
117
|
/** Compare two `MAJOR.MINOR.PATCH` strings. Returns 1, -1 or 0. */
|
|
54
118
|
function compareVersions(a, b) {
|
|
55
119
|
const parse = (value) => String(value)
|
|
@@ -66,40 +130,69 @@ function compareVersions(a, b) {
|
|
|
66
130
|
return 0;
|
|
67
131
|
}
|
|
68
132
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
* Never throws: an update check is a convenience, and a machine that is offline
|
|
73
|
-
* or behind a proxy should still get a working settings screen.
|
|
74
|
-
*/
|
|
75
|
-
async function checkForUpdate({ timeoutMs = 8000, fetchImpl = globalThis.fetch } = {}) {
|
|
76
|
-
const current = currentVersion();
|
|
133
|
+
/** One registry, one question: what is the latest published version? */
|
|
134
|
+
async function askRegistry(registry, { timeoutMs, fetchImpl }) {
|
|
77
135
|
const controller = new AbortController();
|
|
78
136
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
79
137
|
try {
|
|
80
|
-
const response = await fetchImpl(
|
|
138
|
+
const response = await fetchImpl(`${registry}/${PACKAGE_NAME}/latest`, {
|
|
81
139
|
signal: controller.signal,
|
|
82
140
|
headers: { Accept: 'application/vnd.npm.install-v1+json' },
|
|
83
141
|
});
|
|
84
|
-
if (!response.ok) {
|
|
85
|
-
return { ok: false, current, errorKey: 'update.errorNetwork' };
|
|
86
|
-
}
|
|
142
|
+
if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? '?'}` };
|
|
87
143
|
const body = await response.json();
|
|
88
144
|
const latest = typeof body?.version === 'string' ? body.version : null;
|
|
89
|
-
if (!latest) return { ok: false,
|
|
90
|
-
return {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
latest,
|
|
94
|
-
updateAvailable: compareVersions(latest, current) > 0,
|
|
95
|
-
};
|
|
96
|
-
} catch {
|
|
97
|
-
return { ok: false, current, errorKey: 'update.errorNetwork' };
|
|
145
|
+
if (!latest) return { ok: false, message: 'registry answered without a version' };
|
|
146
|
+
return { ok: true, latest };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { ok: false, message: error?.name === 'AbortError' ? 'timed out' : String(error?.message || error) };
|
|
98
149
|
} finally {
|
|
99
150
|
clearTimeout(timer);
|
|
100
151
|
}
|
|
101
152
|
}
|
|
102
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Ask what the current release is.
|
|
156
|
+
*
|
|
157
|
+
* Never throws: an update check is a convenience, and a machine that is offline
|
|
158
|
+
* or behind a proxy should still get a working settings screen. Each configured
|
|
159
|
+
* registry is tried in turn, because the first one is only a guess at which
|
|
160
|
+
* mirror this machine can actually reach.
|
|
161
|
+
*/
|
|
162
|
+
async function checkForUpdate({
|
|
163
|
+
timeoutMs = 6000,
|
|
164
|
+
fetchImpl = globalThis.fetch,
|
|
165
|
+
registries = registryCandidates(),
|
|
166
|
+
} = {}) {
|
|
167
|
+
const current = currentVersion();
|
|
168
|
+
const failures = [];
|
|
169
|
+
|
|
170
|
+
for (const registry of registries) {
|
|
171
|
+
const attempt = await askRegistry(registry, { timeoutMs, fetchImpl });
|
|
172
|
+
if (attempt.ok) {
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
current,
|
|
176
|
+
latest: attempt.latest,
|
|
177
|
+
registry,
|
|
178
|
+
updateAvailable: compareVersions(attempt.latest, current) > 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
failures.push(`${registry}: ${attempt.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The reason is carried out with the failure. "Could not reach npm registry"
|
|
185
|
+
// with nothing after it leaves the user guessing between DNS, a proxy, a
|
|
186
|
+
// firewall and a mirror that does not carry the package.
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
current,
|
|
190
|
+
errorKey: 'update.errorNetwork',
|
|
191
|
+
message: failures.join('; '),
|
|
192
|
+
triedRegistries: registries,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
103
196
|
/**
|
|
104
197
|
* Install the newest release over this one.
|
|
105
198
|
*
|
|
@@ -113,6 +206,11 @@ function performUpdate({
|
|
|
113
206
|
// Seam for tests: the suite runs from a checkout, where the guard below is
|
|
114
207
|
// correctly the only reachable outcome.
|
|
115
208
|
installKindImpl = installKind,
|
|
209
|
+
// The registry that just answered the version check. Installing from
|
|
210
|
+
// somewhere the machine has been shown to reach beats installing from a
|
|
211
|
+
// default it may have no route to. Empty means "whatever npm is configured
|
|
212
|
+
// with", which is the right answer when no check has run.
|
|
213
|
+
registry = '',
|
|
116
214
|
} = {}) {
|
|
117
215
|
return new Promise((resolve) => {
|
|
118
216
|
const kind = installKindImpl();
|
|
@@ -120,7 +218,10 @@ function performUpdate({
|
|
|
120
218
|
resolve({ ok: false, errorKey: `update.cannot.${kind}` });
|
|
121
219
|
return;
|
|
122
220
|
}
|
|
123
|
-
const
|
|
221
|
+
const args = ['install', '-g', `${PACKAGE_NAME}@latest`];
|
|
222
|
+
const normalizedRegistry = normalizeRegistry(registry);
|
|
223
|
+
if (normalizedRegistry) args.push('--registry', normalizedRegistry);
|
|
224
|
+
const child = spawnImpl('npm', args, {
|
|
124
225
|
encoding: 'utf8',
|
|
125
226
|
timeout: timeoutMs,
|
|
126
227
|
});
|
|
@@ -139,10 +240,13 @@ function performUpdate({
|
|
|
139
240
|
|
|
140
241
|
module.exports = {
|
|
141
242
|
PACKAGE_NAME,
|
|
243
|
+
DEFAULT_REGISTRY,
|
|
244
|
+
MIRROR_REGISTRY,
|
|
142
245
|
canSelfUpdate,
|
|
143
246
|
checkForUpdate,
|
|
144
247
|
compareVersions,
|
|
145
248
|
currentVersion,
|
|
146
249
|
installKind,
|
|
147
250
|
performUpdate,
|
|
251
|
+
registryCandidates,
|
|
148
252
|
};
|