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.
@@ -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 (!ACCESS_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
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 REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
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
- * Ask the registry what the current release is.
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(REGISTRY_URL, {
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, current, errorKey: 'update.errorNetwork' };
90
- return {
91
- ok: true,
92
- current,
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 child = spawnImpl('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
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
  };