herdr-remote 0.2.15 → 0.2.17

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/src/updater.js CHANGED
@@ -57,6 +57,11 @@ function installKind() {
57
57
  return 'npm';
58
58
  }
59
59
 
60
+ /** `HERDR_REMOTE_UPDATE_CHECK=0` turns the automatic checks off (offline machines, tests). */
61
+ function updateChecksEnabled(env = process.env) {
62
+ return env.HERDR_REMOTE_UPDATE_CHECK !== '0';
63
+ }
64
+
60
65
  function canSelfUpdate() {
61
66
  return installKind() === 'npm';
62
67
  }
@@ -130,20 +135,17 @@ function compareVersions(a, b) {
130
135
  return 0;
131
136
  }
132
137
 
133
- /** One registry, one question: what is the latest published version? */
134
- async function askRegistry(registry, { timeoutMs, fetchImpl }) {
138
+ /** One JSON request with a deadline. Never throws. */
139
+ async function fetchJson(url, { timeoutMs, fetchImpl }) {
135
140
  const controller = new AbortController();
136
141
  const timer = setTimeout(() => controller.abort(), timeoutMs);
137
142
  try {
138
- const response = await fetchImpl(`${registry}/${PACKAGE_NAME}/latest`, {
143
+ const response = await fetchImpl(url, {
139
144
  signal: controller.signal,
140
- headers: { Accept: 'application/vnd.npm.install-v1+json' },
145
+ headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' },
141
146
  });
142
147
  if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? '?'}` };
143
- const body = await response.json();
144
- const latest = typeof body?.version === 'string' ? body.version : null;
145
- if (!latest) return { ok: false, message: 'registry answered without a version' };
146
- return { ok: true, latest };
148
+ return { ok: true, body: await response.json() };
147
149
  } catch (error) {
148
150
  return { ok: false, message: error?.name === 'AbortError' ? 'timed out' : String(error?.message || error) };
149
151
  } finally {
@@ -151,13 +153,45 @@ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
151
153
  }
152
154
  }
153
155
 
156
+ /**
157
+ * One registry, one question: what is the latest published version?
158
+ *
159
+ * The dist-tags endpoint first: a few bytes, answered fresh by npmjs and
160
+ * npmmirror alike. `/<package>/latest` is the fallback for registries without
161
+ * it, asked for plain JSON. It used to be asked for npm's abbreviated install
162
+ * format, which npmjs refuses on that path with a 406, so every check quietly
163
+ * fell through to a mirror that had not synced the release yet.
164
+ */
165
+ async function askRegistry(registry, { timeoutMs, fetchImpl }) {
166
+ const endpoints = [
167
+ { url: `${registry}/-/package/${PACKAGE_NAME}/dist-tags`, read: (body) => body?.latest },
168
+ { url: `${registry}/${PACKAGE_NAME}/latest`, read: (body) => body?.version },
169
+ ];
170
+ let message = null;
171
+ for (const endpoint of endpoints) {
172
+ const attempt = await fetchJson(endpoint.url, { timeoutMs, fetchImpl });
173
+ if (!attempt.ok) {
174
+ message = attempt.message;
175
+ continue;
176
+ }
177
+ const latest = endpoint.read(attempt.body);
178
+ if (typeof latest === 'string' && latest) return { ok: true, latest };
179
+ message = 'registry answered without a version';
180
+ }
181
+ return { ok: false, message };
182
+ }
183
+
154
184
  /**
155
185
  * Ask what the current release is.
156
186
  *
157
187
  * 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.
188
+ * or behind a proxy should still get a working settings screen.
189
+ *
190
+ * Every registry is asked at once and the newest answer wins. Taking the first
191
+ * answer let a mirror a few minutes (or hours) behind report the release before
192
+ * last as current. `sources` lists the registries that already carry the
193
+ * newest version, preferred order first, which is where an install should come
194
+ * from; `behind` lists the ones that do not, so the screen can say why.
161
195
  */
162
196
  async function checkForUpdate({
163
197
  timeoutMs = 6000,
@@ -165,77 +199,182 @@ async function checkForUpdate({
165
199
  registries = registryCandidates(),
166
200
  } = {}) {
167
201
  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}`);
202
+ const attempts = await Promise.all(registries.map(async (registry) => ({
203
+ registry,
204
+ ...(await askRegistry(registry, { timeoutMs, fetchImpl })),
205
+ })));
206
+ const answers = attempts.filter((attempt) => attempt.ok);
207
+
208
+ if (answers.length === 0) {
209
+ // The reason is carried out with the failure. "Could not reach npm
210
+ // registry" with nothing after it leaves the user guessing between DNS, a
211
+ // proxy, a firewall and a mirror that does not carry the package.
212
+ return {
213
+ ok: false,
214
+ current,
215
+ errorKey: 'update.errorNetwork',
216
+ message: attempts.map((attempt) => `${attempt.registry}: ${attempt.message}`).join('; '),
217
+ triedRegistries: registries,
218
+ };
182
219
  }
183
220
 
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.
221
+ const latest = answers
222
+ .map((answer) => answer.latest)
223
+ .reduce((best, version) => (compareVersions(version, best) > 0 ? version : best));
224
+ const sources = answers
225
+ .filter((answer) => compareVersions(answer.latest, latest) === 0)
226
+ .map((answer) => answer.registry);
227
+ const behind = answers
228
+ .filter((answer) => compareVersions(answer.latest, latest) < 0)
229
+ .map((answer) => ({ registry: answer.registry, version: answer.latest }));
230
+
187
231
  return {
188
- ok: false,
232
+ ok: true,
189
233
  current,
190
- errorKey: 'update.errorNetwork',
191
- message: failures.join('; '),
192
- triedRegistries: registries,
234
+ latest,
235
+ registry: sources[0],
236
+ sources,
237
+ behind,
238
+ updateAvailable: compareVersions(latest, current) > 0,
193
239
  };
194
240
  }
195
241
 
242
+ /** Just the lines npm meant for a person: its `npm error` / `npm ERR!` summary. */
243
+ function npmErrorSummary(output) {
244
+ const lines = String(output || '').split(/\r?\n/)
245
+ .map((line) => line.trim())
246
+ .filter((line) => /^npm (error|ERR!)/.test(line) && !/complete log of this run/i.test(line))
247
+ .map((line) => line.replace(/^npm (error|ERR!)\s*/, ''))
248
+ .filter(Boolean);
249
+ return (lines.length > 0 ? lines.slice(0, 3) : String(output || '').trim().split(/\r?\n/).slice(-3)).join(' ');
250
+ }
251
+
252
+ /** "No matching version": the registry has not caught up with its own dist-tag. */
253
+ function isNotYetPublished(output) {
254
+ return /\bETARGET\b|notarget|No matching version found/i.test(String(output || ''));
255
+ }
256
+
257
+ /** What is actually installed now, read from disk rather than the require cache. */
258
+ function installedVersionOnDisk() {
259
+ try {
260
+ return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ function runNpm(spawnImpl, args, timeoutMs) {
267
+ return new Promise((resolve) => {
268
+ let child;
269
+ try {
270
+ child = spawnImpl('npm', args, { timeout: timeoutMs });
271
+ } catch (error) {
272
+ resolve({ ok: false, spawnFailed: true, output: String(error?.message || error) });
273
+ return;
274
+ }
275
+ let output = '';
276
+ let settled = false;
277
+ const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
278
+ const collect = (chunk) => { output += String(chunk); };
279
+ child.stdout?.on('data', collect);
280
+ child.stderr?.on('data', collect);
281
+ child.on('error', (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
282
+ child.on('close', (code) => finish({ ok: code === 0, output: output.trim() }));
283
+ });
284
+ }
285
+
286
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
287
+
196
288
  /**
197
- * Install the newest release over this one.
289
+ * Install a release over this one.
198
290
  *
199
291
  * Resolves with `{ ok, output }` rather than rejecting, so the caller can show
200
292
  * npm's own message; npm's diagnostics are more useful than anything that could
201
293
  * be invented here.
294
+ *
295
+ * - The exact version the check found is installed, not `@latest`, from a
296
+ * registry that reported it, with `--prefer-online` so npm revalidates the
297
+ * package metadata it cached before the release existed.
298
+ * - Right after a release, npm's view of the package can still lack the new
299
+ * version although its dist-tag already points at it: npm answers ETARGET.
300
+ * That is a wait, not a failure, so it is retried a few times, then the next
301
+ * registry that carries the version is tried.
302
+ * - Success is what is on disk afterwards, not npm's exit code.
303
+ *
304
+ * @param {{
305
+ * spawnImpl?: Function,
306
+ * timeoutMs?: number,
307
+ * installKindImpl?: () => string,
308
+ * registry?: string,
309
+ * sources?: string[],
310
+ * version?: string | null,
311
+ * attempts?: number,
312
+ * retryDelayMs?: number,
313
+ * sleep?: (ms: number) => Promise<unknown>,
314
+ * readInstalledVersion?: () => string | null,
315
+ * onAttempt?: (progress: { registry: string, attempt: number }) => void,
316
+ * }} [options]
317
+ * @returns {Promise<{ ok: boolean, errorKey?: string, installed?: string | null, output?: string, summary?: string }>}
202
318
  */
203
- function performUpdate({
319
+ async function performUpdate({
204
320
  spawnImpl = spawn,
205
321
  timeoutMs = 180_000,
206
322
  // Seam for tests: the suite runs from a checkout, where the guard below is
207
323
  // correctly the only reachable outcome.
208
324
  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.
325
+ // The registries that reported `version`, preferred first. Empty means
326
+ // "whatever npm is configured with", which is right when no check has run.
213
327
  registry = '',
328
+ sources = [],
329
+ version = null,
330
+ attempts = 3,
331
+ retryDelayMs = 15_000,
332
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
333
+ readInstalledVersion = installedVersionOnDisk,
334
+ onAttempt = () => {},
214
335
  } = {}) {
215
- return new Promise((resolve) => {
216
- const kind = installKindImpl();
217
- if (kind !== 'npm') {
218
- resolve({ ok: false, errorKey: `update.cannot.${kind}` });
219
- return;
336
+ const kind = installKindImpl();
337
+ if (kind !== 'npm') return { ok: false, errorKey: `update.cannot.${kind}` };
338
+
339
+ const target = typeof version === 'string' && VERSION_PATTERN.test(version) ? version : null;
340
+ const spec = `${PACKAGE_NAME}@${target || 'latest'}`;
341
+ const registries = [];
342
+ for (const candidate of [registry, ...sources]) {
343
+ const normalized = normalizeRegistry(candidate);
344
+ if (normalized && !registries.includes(normalized)) registries.push(normalized);
345
+ }
346
+ if (registries.length === 0) registries.push('');
347
+
348
+ let last = { output: '' };
349
+ let notYetPublished = false;
350
+ for (const source of registries) {
351
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
352
+ onAttempt({ registry: source, attempt });
353
+ const args = ['install', '-g', spec, '--prefer-online'];
354
+ if (source) args.push('--registry', source);
355
+ const result = await runNpm(spawnImpl, args, timeoutMs);
356
+ if (result.ok) {
357
+ const installed = readInstalledVersion();
358
+ if (target && installed && compareVersions(installed, target) < 0) {
359
+ return { ok: false, errorKey: 'update.errorNotApplied', installed, output: result.output.slice(-2000) };
360
+ }
361
+ return { ok: true, installed, output: result.output.slice(-2000) };
362
+ }
363
+ last = result;
364
+ if (result.spawnFailed) {
365
+ return { ok: false, errorKey: 'update.errorFailed', output: result.output, summary: result.output };
366
+ }
367
+ notYetPublished = isNotYetPublished(result.output);
368
+ if (!notYetPublished) break; // Not a wait: this registry will not do better.
369
+ if (attempt < attempts) await sleep(retryDelayMs);
220
370
  }
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, {
225
- encoding: 'utf8',
226
- timeout: timeoutMs,
227
- });
228
- let output = '';
229
- const collect = (chunk) => { output += String(chunk); };
230
- child.stdout?.on('data', collect);
231
- child.stderr?.on('data', collect);
232
- child.on('error', (error) => {
233
- resolve({ ok: false, errorKey: 'update.errorFailed', output: error.message });
234
- });
235
- child.on('close', (code) => {
236
- resolve({ ok: code === 0, output: output.trim().slice(-2000) });
237
- });
238
- });
371
+ }
372
+ return {
373
+ ok: false,
374
+ errorKey: notYetPublished ? 'update.errorNotYetPublished' : 'update.errorFailed',
375
+ output: String(last.output || '').slice(-2000),
376
+ summary: npmErrorSummary(last.output),
377
+ };
239
378
  }
240
379
 
241
380
  module.exports = {
@@ -247,6 +386,9 @@ module.exports = {
247
386
  compareVersions,
248
387
  currentVersion,
249
388
  installKind,
389
+ installedVersionOnDisk,
390
+ npmErrorSummary,
250
391
  performUpdate,
251
392
  registryCandidates,
393
+ updateChecksEnabled,
252
394
  };