herdr-remote 0.2.16 → 0.2.18
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/bin/herdr-remote.js +4 -0
- package/dist/tui.mjs +1394 -86
- package/herdr-plugin.toml +1 -1
- package/package.json +3 -2
- package/src/binary-plist.js +146 -0
- package/src/font-subset.js +110 -0
- package/src/host-connector.js +197 -0
- package/src/i18n/en.js +7 -1
- package/src/i18n/zh.js +7 -1
- package/src/service.js +14 -0
- package/src/terminal-font.js +1204 -0
- package/src/updater.js +203 -61
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
|
|
134
|
-
async function
|
|
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(
|
|
143
|
+
const response = await fetchImpl(url, {
|
|
139
144
|
signal: controller.signal,
|
|
140
|
-
headers: { Accept: 'application/
|
|
145
|
+
headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' },
|
|
141
146
|
});
|
|
142
147
|
if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? '?'}` };
|
|
143
|
-
|
|
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.
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
232
|
+
ok: true,
|
|
189
233
|
current,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
210
|
-
//
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
};
|