herdr-remote 0.2.16 → 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/dist/tui.mjs +232 -86
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/host-connector.js +55 -0
- package/src/i18n/en.js +7 -1
- package/src/i18n/zh.js +7 -1
- package/src/updater.js +203 -61
package/dist/tui.mjs
CHANGED
|
@@ -415,6 +415,9 @@ var require_updater = __commonJS({
|
|
|
415
415
|
}
|
|
416
416
|
return "npm";
|
|
417
417
|
}
|
|
418
|
+
function updateChecksEnabled2(env = process.env) {
|
|
419
|
+
return env.HERDR_REMOTE_UPDATE_CHECK !== "0";
|
|
420
|
+
}
|
|
418
421
|
function canSelfUpdate2() {
|
|
419
422
|
return installKind2() === "npm";
|
|
420
423
|
}
|
|
@@ -466,92 +469,173 @@ var require_updater = __commonJS({
|
|
|
466
469
|
}
|
|
467
470
|
return 0;
|
|
468
471
|
}
|
|
469
|
-
async function
|
|
472
|
+
async function fetchJson(url, { timeoutMs, fetchImpl }) {
|
|
470
473
|
const controller = new AbortController();
|
|
471
474
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
472
475
|
try {
|
|
473
|
-
const response = await fetchImpl(
|
|
476
|
+
const response = await fetchImpl(url, {
|
|
474
477
|
signal: controller.signal,
|
|
475
|
-
headers: { Accept: "application/
|
|
478
|
+
headers: { Accept: "application/json", "Cache-Control": "no-cache" }
|
|
476
479
|
});
|
|
477
480
|
if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? "?"}` };
|
|
478
|
-
|
|
479
|
-
const latest = typeof body?.version === "string" ? body.version : null;
|
|
480
|
-
if (!latest) return { ok: false, message: "registry answered without a version" };
|
|
481
|
-
return { ok: true, latest };
|
|
481
|
+
return { ok: true, body: await response.json() };
|
|
482
482
|
} catch (error) {
|
|
483
483
|
return { ok: false, message: error?.name === "AbortError" ? "timed out" : String(error?.message || error) };
|
|
484
484
|
} finally {
|
|
485
485
|
clearTimeout(timer);
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
|
+
async function askRegistry(registry, { timeoutMs, fetchImpl }) {
|
|
489
|
+
const endpoints = [
|
|
490
|
+
{ url: `${registry}/-/package/${PACKAGE_NAME}/dist-tags`, read: (body) => body?.latest },
|
|
491
|
+
{ url: `${registry}/${PACKAGE_NAME}/latest`, read: (body) => body?.version }
|
|
492
|
+
];
|
|
493
|
+
let message = null;
|
|
494
|
+
for (const endpoint of endpoints) {
|
|
495
|
+
const attempt = await fetchJson(endpoint.url, { timeoutMs, fetchImpl });
|
|
496
|
+
if (!attempt.ok) {
|
|
497
|
+
message = attempt.message;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const latest = endpoint.read(attempt.body);
|
|
501
|
+
if (typeof latest === "string" && latest) return { ok: true, latest };
|
|
502
|
+
message = "registry answered without a version";
|
|
503
|
+
}
|
|
504
|
+
return { ok: false, message };
|
|
505
|
+
}
|
|
488
506
|
async function checkForUpdate2({
|
|
489
507
|
timeoutMs = 6e3,
|
|
490
508
|
fetchImpl = globalThis.fetch,
|
|
491
509
|
registries = registryCandidates()
|
|
492
510
|
} = {}) {
|
|
493
511
|
const current = currentVersion();
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
|
|
512
|
+
const attempts = await Promise.all(registries.map(async (registry) => ({
|
|
513
|
+
registry,
|
|
514
|
+
...await askRegistry(registry, { timeoutMs, fetchImpl })
|
|
515
|
+
})));
|
|
516
|
+
const answers = attempts.filter((attempt) => attempt.ok);
|
|
517
|
+
if (answers.length === 0) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
current,
|
|
521
|
+
errorKey: "update.errorNetwork",
|
|
522
|
+
message: attempts.map((attempt) => `${attempt.registry}: ${attempt.message}`).join("; "),
|
|
523
|
+
triedRegistries: registries
|
|
524
|
+
};
|
|
507
525
|
}
|
|
526
|
+
const latest = answers.map((answer) => answer.latest).reduce((best, version) => compareVersions(version, best) > 0 ? version : best);
|
|
527
|
+
const sources = answers.filter((answer) => compareVersions(answer.latest, latest) === 0).map((answer) => answer.registry);
|
|
528
|
+
const behind = answers.filter((answer) => compareVersions(answer.latest, latest) < 0).map((answer) => ({ registry: answer.registry, version: answer.latest }));
|
|
508
529
|
return {
|
|
509
|
-
ok:
|
|
530
|
+
ok: true,
|
|
510
531
|
current,
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
532
|
+
latest,
|
|
533
|
+
registry: sources[0],
|
|
534
|
+
sources,
|
|
535
|
+
behind,
|
|
536
|
+
updateAvailable: compareVersions(latest, current) > 0
|
|
514
537
|
};
|
|
515
538
|
}
|
|
516
|
-
function
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
539
|
+
function npmErrorSummary(output) {
|
|
540
|
+
const lines = String(output || "").split(/\r?\n/).map((line) => line.trim()).filter((line) => /^npm (error|ERR!)/.test(line) && !/complete log of this run/i.test(line)).map((line) => line.replace(/^npm (error|ERR!)\s*/, "")).filter(Boolean);
|
|
541
|
+
return (lines.length > 0 ? lines.slice(0, 3) : String(output || "").trim().split(/\r?\n/).slice(-3)).join(" ");
|
|
542
|
+
}
|
|
543
|
+
function isNotYetPublished(output) {
|
|
544
|
+
return /\bETARGET\b|notarget|No matching version found/i.test(String(output || ""));
|
|
545
|
+
}
|
|
546
|
+
function installedVersionOnDisk() {
|
|
547
|
+
try {
|
|
548
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function runNpm(spawnImpl, args, timeoutMs) {
|
|
528
554
|
return new Promise((resolve) => {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
555
|
+
let child;
|
|
556
|
+
try {
|
|
557
|
+
child = spawnImpl("npm", args, { timeout: timeoutMs });
|
|
558
|
+
} catch (error) {
|
|
559
|
+
resolve({ ok: false, spawnFailed: true, output: String(error?.message || error) });
|
|
532
560
|
return;
|
|
533
561
|
}
|
|
534
|
-
const args = ["install", "-g", `${PACKAGE_NAME}@latest`];
|
|
535
|
-
const normalizedRegistry = normalizeRegistry(registry);
|
|
536
|
-
if (normalizedRegistry) args.push("--registry", normalizedRegistry);
|
|
537
|
-
const child = spawnImpl("npm", args, {
|
|
538
|
-
encoding: "utf8",
|
|
539
|
-
timeout: timeoutMs
|
|
540
|
-
});
|
|
541
562
|
let output = "";
|
|
563
|
+
let settled = false;
|
|
564
|
+
const finish = (result) => {
|
|
565
|
+
if (!settled) {
|
|
566
|
+
settled = true;
|
|
567
|
+
resolve(result);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
542
570
|
const collect = (chunk) => {
|
|
543
571
|
output += String(chunk);
|
|
544
572
|
};
|
|
545
573
|
child.stdout?.on("data", collect);
|
|
546
574
|
child.stderr?.on("data", collect);
|
|
547
|
-
child.on("error", (error) => {
|
|
548
|
-
|
|
549
|
-
});
|
|
550
|
-
child.on("close", (code) => {
|
|
551
|
-
resolve({ ok: code === 0, output: output.trim().slice(-2e3) });
|
|
552
|
-
});
|
|
575
|
+
child.on("error", (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
|
|
576
|
+
child.on("close", (code) => finish({ ok: code === 0, output: output.trim() }));
|
|
553
577
|
});
|
|
554
578
|
}
|
|
579
|
+
var VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
580
|
+
async function performUpdate2({
|
|
581
|
+
spawnImpl = spawn,
|
|
582
|
+
timeoutMs = 18e4,
|
|
583
|
+
// Seam for tests: the suite runs from a checkout, where the guard below is
|
|
584
|
+
// correctly the only reachable outcome.
|
|
585
|
+
installKindImpl = installKind2,
|
|
586
|
+
// The registries that reported `version`, preferred first. Empty means
|
|
587
|
+
// "whatever npm is configured with", which is right when no check has run.
|
|
588
|
+
registry = "",
|
|
589
|
+
sources = [],
|
|
590
|
+
version = null,
|
|
591
|
+
attempts = 3,
|
|
592
|
+
retryDelayMs = 15e3,
|
|
593
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
594
|
+
readInstalledVersion = installedVersionOnDisk,
|
|
595
|
+
onAttempt = () => {
|
|
596
|
+
}
|
|
597
|
+
} = {}) {
|
|
598
|
+
const kind = installKindImpl();
|
|
599
|
+
if (kind !== "npm") return { ok: false, errorKey: `update.cannot.${kind}` };
|
|
600
|
+
const target = typeof version === "string" && VERSION_PATTERN.test(version) ? version : null;
|
|
601
|
+
const spec = `${PACKAGE_NAME}@${target || "latest"}`;
|
|
602
|
+
const registries = [];
|
|
603
|
+
for (const candidate of [registry, ...sources]) {
|
|
604
|
+
const normalized = normalizeRegistry(candidate);
|
|
605
|
+
if (normalized && !registries.includes(normalized)) registries.push(normalized);
|
|
606
|
+
}
|
|
607
|
+
if (registries.length === 0) registries.push("");
|
|
608
|
+
let last = { output: "" };
|
|
609
|
+
let notYetPublished = false;
|
|
610
|
+
for (const source of registries) {
|
|
611
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
612
|
+
onAttempt({ registry: source, attempt });
|
|
613
|
+
const args = ["install", "-g", spec, "--prefer-online"];
|
|
614
|
+
if (source) args.push("--registry", source);
|
|
615
|
+
const result = await runNpm(spawnImpl, args, timeoutMs);
|
|
616
|
+
if (result.ok) {
|
|
617
|
+
const installed = readInstalledVersion();
|
|
618
|
+
if (target && installed && compareVersions(installed, target) < 0) {
|
|
619
|
+
return { ok: false, errorKey: "update.errorNotApplied", installed, output: result.output.slice(-2e3) };
|
|
620
|
+
}
|
|
621
|
+
return { ok: true, installed, output: result.output.slice(-2e3) };
|
|
622
|
+
}
|
|
623
|
+
last = result;
|
|
624
|
+
if (result.spawnFailed) {
|
|
625
|
+
return { ok: false, errorKey: "update.errorFailed", output: result.output, summary: result.output };
|
|
626
|
+
}
|
|
627
|
+
notYetPublished = isNotYetPublished(result.output);
|
|
628
|
+
if (!notYetPublished) break;
|
|
629
|
+
if (attempt < attempts) await sleep(retryDelayMs);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
errorKey: notYetPublished ? "update.errorNotYetPublished" : "update.errorFailed",
|
|
635
|
+
output: String(last.output || "").slice(-2e3),
|
|
636
|
+
summary: npmErrorSummary(last.output)
|
|
637
|
+
};
|
|
638
|
+
}
|
|
555
639
|
module.exports = {
|
|
556
640
|
PACKAGE_NAME,
|
|
557
641
|
DEFAULT_REGISTRY,
|
|
@@ -561,8 +645,11 @@ var require_updater = __commonJS({
|
|
|
561
645
|
compareVersions,
|
|
562
646
|
currentVersion,
|
|
563
647
|
installKind: installKind2,
|
|
648
|
+
installedVersionOnDisk,
|
|
649
|
+
npmErrorSummary,
|
|
564
650
|
performUpdate: performUpdate2,
|
|
565
|
-
registryCandidates
|
|
651
|
+
registryCandidates,
|
|
652
|
+
updateChecksEnabled: updateChecksEnabled2
|
|
566
653
|
};
|
|
567
654
|
}
|
|
568
655
|
});
|
|
@@ -910,16 +997,22 @@ var require_en = __commonJS({
|
|
|
910
997
|
"hint.save": "s save",
|
|
911
998
|
"hint.editing": "\u21B5 confirm \xB7 esc cancel",
|
|
912
999
|
"update.title": "Updates",
|
|
1000
|
+
"update.banner": "herdr-remote {latest} is out (running {current}). Press 7 and Enter on the update row to install.",
|
|
913
1001
|
"update.check": "Check for updates",
|
|
914
1002
|
"update.checking": "Checking npm\u2026",
|
|
915
1003
|
"update.upToDate": "Up to date ({version})",
|
|
916
1004
|
"update.available": "Update {version} available (press Enter to install)",
|
|
917
1005
|
"update.updating": "Installing {version}\u2026",
|
|
1006
|
+
"update.updatingRetry": "Installing {version}\u2026 (npm has not caught up yet, attempt {attempt})",
|
|
918
1007
|
"update.done": "Updated to {version}",
|
|
919
1008
|
"update.restartHint": "Update installed. Restart herdr-remote to apply.",
|
|
920
1009
|
"update.errorNetwork": "Could not reach any npm registry",
|
|
921
1010
|
"update.errorNetworkDetail": "No registry answered: {message}",
|
|
922
|
-
"update.errorFailed": "Update failed. Run: npm install -g herdr-remote@
|
|
1011
|
+
"update.errorFailed": "Update failed. Run: npm install -g herdr-remote@{version} --prefer-online",
|
|
1012
|
+
"update.errorFailedDetail": "npm: {message}",
|
|
1013
|
+
"update.errorNotYetPublished": "npm has not caught up with {version} yet (common in the first minutes after a release). Press Enter to try again shortly.",
|
|
1014
|
+
"update.errorNotApplied": "npm reported success, but {installed} is still installed",
|
|
1015
|
+
"update.mirrorBehind": "{registries} still has an older version; installing from {source}",
|
|
923
1016
|
"update.cannot.source": "Source checkout \u2014 update with git",
|
|
924
1017
|
"update.cannot.linked": "Linked package \u2014 managed by npm link"
|
|
925
1018
|
};
|
|
@@ -1131,16 +1224,22 @@ var require_zh = __commonJS({
|
|
|
1131
1224
|
"hint.save": "s \u4FDD\u5B58",
|
|
1132
1225
|
"hint.editing": "\u21B5 \u786E\u8BA4 \xB7 esc \u53D6\u6D88",
|
|
1133
1226
|
"update.title": "\u66F4\u65B0",
|
|
1227
|
+
"update.banner": "herdr-remote {latest} \u5DF2\u53D1\u5E03\uFF08\u5F53\u524D {current}\uFF09\uFF0C\u6309 7 \u8FDB\u5165\u300C\u8BED\u8A00\u4E0E\u5173\u4E8E\u300D\uFF0C\u5728\u66F4\u65B0\u4E00\u884C\u6309 Enter \u5B89\u88C5\u3002",
|
|
1134
1228
|
"update.check": "\u68C0\u67E5\u66F4\u65B0",
|
|
1135
1229
|
"update.checking": "\u67E5\u8BE2 npm \u4E2D\u2026",
|
|
1136
1230
|
"update.upToDate": "\u5DF2\u662F\u6700\u65B0\uFF08{version}\uFF09",
|
|
1137
1231
|
"update.available": "\u65B0\u7248\u672C {version} \u53EF\u7528\uFF08\u6309 Enter \u5B89\u88C5\uFF09",
|
|
1138
1232
|
"update.updating": "\u5B89\u88C5 {version} \u4E2D\u2026",
|
|
1233
|
+
"update.updatingRetry": "\u5B89\u88C5 {version} \u4E2D\u2026\uFF08npm \u5C1A\u672A\u540C\u6B65\uFF0C\u7B2C {attempt} \u6B21\u5C1D\u8BD5\uFF09",
|
|
1139
1234
|
"update.done": "\u5DF2\u66F4\u65B0\u81F3 {version}",
|
|
1140
1235
|
"update.restartHint": "\u66F4\u65B0\u5DF2\u5B89\u88C5\uFF0C\u91CD\u542F herdr-remote \u751F\u6548\u3002",
|
|
1141
1236
|
"update.errorNetwork": "\u6240\u6709 npm registry \u5747\u65E0\u6CD5\u8FDE\u63A5",
|
|
1142
1237
|
"update.errorNetworkDetail": "\u65E0 registry \u5E94\u7B54\uFF1A{message}",
|
|
1143
|
-
"update.errorFailed": "\u66F4\u65B0\u5931\u8D25\uFF0C\u53EF\u624B\u52A8\u6267\u884C\uFF1Anpm install -g herdr-remote@
|
|
1238
|
+
"update.errorFailed": "\u66F4\u65B0\u5931\u8D25\uFF0C\u53EF\u624B\u52A8\u6267\u884C\uFF1Anpm install -g herdr-remote@{version} --prefer-online",
|
|
1239
|
+
"update.errorFailedDetail": "npm\uFF1A{message}",
|
|
1240
|
+
"update.errorNotYetPublished": "npm \u8FD8\u6CA1\u540C\u6B65\u5230 {version}\uFF08\u521A\u53D1\u5E03\u7684\u51E0\u5206\u949F\u5185\u5E38\u89C1\uFF09\uFF0C\u7A0D\u540E\u6309 Enter \u91CD\u8BD5\u3002",
|
|
1241
|
+
"update.errorNotApplied": "npm \u62A5\u544A\u6210\u529F\uFF0C\u4F46\u5B89\u88C5\u7684\u4ECD\u662F {installed}",
|
|
1242
|
+
"update.mirrorBehind": "{registries} \u4ECD\u662F\u65E7\u7248\u672C\uFF0C\u5C06\u4ECE {source} \u5B89\u88C5",
|
|
1144
1243
|
"update.cannot.source": "\u6E90\u7801\u76EE\u5F55\u8FD0\u884C\uFF0C\u8BF7\u4F7F\u7528 git \u66F4\u65B0",
|
|
1145
1244
|
"update.cannot.linked": "npm link \u5DE5\u4F5C\u526F\u672C\uFF0C\u7531 npm link \u7BA1\u7406"
|
|
1146
1245
|
};
|
|
@@ -2625,7 +2724,7 @@ var require_lifecycle = __commonJS({
|
|
|
2625
2724
|
import { render } from "ink";
|
|
2626
2725
|
|
|
2627
2726
|
// tui/src/App.tsx
|
|
2628
|
-
import { useCallback as useCallback2, useEffect as
|
|
2727
|
+
import { useCallback as useCallback2, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef3, useState as useState10 } from "react";
|
|
2629
2728
|
import { Box as Box11, Text as Text11, useApp, useInput as useInput9 } from "ink";
|
|
2630
2729
|
|
|
2631
2730
|
// tui/src/mouse/index.tsx
|
|
@@ -3819,14 +3918,16 @@ function HerdrScreen({ ctx }) {
|
|
|
3819
3918
|
}
|
|
3820
3919
|
|
|
3821
3920
|
// tui/src/screens/About.tsx
|
|
3822
|
-
import { useState as useState8 } from "react";
|
|
3921
|
+
import { useEffect as useEffect6, useState as useState8 } from "react";
|
|
3823
3922
|
import { Box as Box9, Text as Text9, useInput as useInput7 } from "ink";
|
|
3824
3923
|
import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
3924
|
+
var registryHost = (registry) => registry.replace(/^https?:\/\//, "");
|
|
3825
3925
|
function About({ ctx }) {
|
|
3826
3926
|
const { t, draft } = ctx;
|
|
3827
3927
|
const [selected, setSelected] = useState8("auto");
|
|
3828
3928
|
const [update, setUpdate] = useState8({ phase: "idle" });
|
|
3829
|
-
const
|
|
3929
|
+
const [sourceNote, setSourceNote] = useState8(null);
|
|
3930
|
+
const checkRef = useState8(() => ({ current: { registry: "", sources: [] } }))[0];
|
|
3830
3931
|
const detected = detectLocale({ preference: "auto" });
|
|
3831
3932
|
const languageOptions = [
|
|
3832
3933
|
{ id: "auto", label: t("about.languageAuto", { detected }) },
|
|
@@ -3843,36 +3944,57 @@ function About({ ctx }) {
|
|
|
3843
3944
|
case "available":
|
|
3844
3945
|
return t("update.available", { version: update.latest });
|
|
3845
3946
|
case "updating":
|
|
3846
|
-
return t("update.updating", { version: update.latest });
|
|
3947
|
+
return update.attempt > 1 ? t("update.updatingRetry", { version: update.latest, attempt: update.attempt }) : t("update.updating", { version: update.latest });
|
|
3847
3948
|
case "done":
|
|
3848
3949
|
return t("update.done", { version: update.latest });
|
|
3849
3950
|
case "error":
|
|
3850
|
-
return t(update.messageKey);
|
|
3951
|
+
return t(update.messageKey, update.params);
|
|
3851
3952
|
default:
|
|
3852
3953
|
return t("update.check");
|
|
3853
3954
|
}
|
|
3854
3955
|
})();
|
|
3855
3956
|
const options = [...languageOptions, { id: "update", label: updateLabel }];
|
|
3957
|
+
const applyCheck = (result) => {
|
|
3958
|
+
checkRef.current = { registry: result.registry ?? "", sources: result.sources ?? [] };
|
|
3959
|
+
const behind = result.behind ?? [];
|
|
3960
|
+
setSourceNote(result.updateAvailable && behind.length > 0 && result.registry ? t("update.mirrorBehind", {
|
|
3961
|
+
registries: behind.map((entry) => `${registryHost(entry.registry)} ${entry.version ?? ""}`.trim()).join(", "),
|
|
3962
|
+
source: registryHost(result.registry)
|
|
3963
|
+
}) : null);
|
|
3964
|
+
setUpdate(result.updateAvailable ? { phase: "available", latest: result.latest } : { phase: "current", latest: result.latest });
|
|
3965
|
+
};
|
|
3966
|
+
useEffect6(() => {
|
|
3967
|
+
if (ctx.updateCheck?.ok && update.phase === "idle") applyCheck(ctx.updateCheck);
|
|
3968
|
+
}, [ctx.updateCheck]);
|
|
3856
3969
|
const runCheck = async () => {
|
|
3857
3970
|
setUpdate({ phase: "checking" });
|
|
3971
|
+
setSourceNote(null);
|
|
3858
3972
|
const result = await (0, import_updater.checkForUpdate)();
|
|
3859
3973
|
if (!result.ok) {
|
|
3860
3974
|
setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorNetwork" });
|
|
3861
3975
|
if (result.message) ctx.notify(t("update.errorNetworkDetail", { message: result.message }), "error");
|
|
3862
3976
|
return;
|
|
3863
3977
|
}
|
|
3864
|
-
|
|
3865
|
-
|
|
3978
|
+
ctx.setUpdateCheck(result);
|
|
3979
|
+
applyCheck(result);
|
|
3866
3980
|
};
|
|
3867
3981
|
const runUpdate = async (latest) => {
|
|
3868
|
-
setUpdate({ phase: "updating", latest });
|
|
3869
|
-
const result = await (0, import_updater.performUpdate)({
|
|
3982
|
+
setUpdate({ phase: "updating", latest, attempt: 1 });
|
|
3983
|
+
const result = await (0, import_updater.performUpdate)({
|
|
3984
|
+
registry: checkRef.current.registry,
|
|
3985
|
+
sources: checkRef.current.sources,
|
|
3986
|
+
version: latest,
|
|
3987
|
+
onAttempt: ({ attempt }) => setUpdate({ phase: "updating", latest, attempt })
|
|
3988
|
+
});
|
|
3870
3989
|
if (!result.ok) {
|
|
3871
|
-
|
|
3872
|
-
|
|
3990
|
+
const params = { version: latest, installed: result.installed ?? "" };
|
|
3991
|
+
setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorFailed", params });
|
|
3992
|
+
ctx.notify(result.summary ? t("update.errorFailedDetail", { message: result.summary }) : t(result.errorKey ?? "update.errorFailed", params), "error");
|
|
3873
3993
|
return;
|
|
3874
3994
|
}
|
|
3875
3995
|
setUpdate({ phase: "done", latest });
|
|
3996
|
+
setSourceNote(null);
|
|
3997
|
+
ctx.setUpdateCheck(null);
|
|
3876
3998
|
ctx.notify(t("update.restartHint"), "success");
|
|
3877
3999
|
};
|
|
3878
4000
|
const activateUpdate = () => {
|
|
@@ -3931,19 +4053,22 @@ function About({ ctx }) {
|
|
|
3931
4053
|
option.id
|
|
3932
4054
|
)) }),
|
|
3933
4055
|
/* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("update.title") }),
|
|
3934
|
-
/* @__PURE__ */
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
4056
|
+
/* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [
|
|
4057
|
+
/* @__PURE__ */ jsx10(
|
|
4058
|
+
Selectable,
|
|
4059
|
+
{
|
|
4060
|
+
selected: selected === "update",
|
|
4061
|
+
onSelect: () => {
|
|
4062
|
+
setSelected("update");
|
|
4063
|
+
activateUpdate();
|
|
4064
|
+
},
|
|
4065
|
+
onHover: () => setSelected("update"),
|
|
4066
|
+
children: updateLabel
|
|
4067
|
+
}
|
|
4068
|
+
),
|
|
4069
|
+
sourceNote ? /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: ` ${sourceNote}` }) : null
|
|
4070
|
+
] }),
|
|
4071
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.17" }) }),
|
|
3947
4072
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
3948
4073
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
3949
4074
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
|
@@ -4260,7 +4385,11 @@ function Tab({ label, active, onSelect }) {
|
|
|
4260
4385
|
function Footer({ hints }) {
|
|
4261
4386
|
return /* @__PURE__ */ jsx12(Box11, { marginTop: 1, paddingX: 1, children: /* @__PURE__ */ jsx12(Text11, { color: theme.muted, children: hints.join(" \xB7 ") }) });
|
|
4262
4387
|
}
|
|
4263
|
-
function App({
|
|
4388
|
+
function App({
|
|
4389
|
+
initialLanguage,
|
|
4390
|
+
needsWizard,
|
|
4391
|
+
updateChecker = (0, import_updater.updateChecksEnabled)() ? import_updater.checkForUpdate : null
|
|
4392
|
+
}) {
|
|
4264
4393
|
const { exit } = useApp();
|
|
4265
4394
|
const mouse = useMouse();
|
|
4266
4395
|
const [config, setConfig] = useState10(() => (0, import_config.loadConfig)());
|
|
@@ -4272,6 +4401,18 @@ function App({ initialLanguage, needsWizard }) {
|
|
|
4272
4401
|
const [busy, setBusy] = useState10(false);
|
|
4273
4402
|
const [editingId, setEditingId] = useState10(null);
|
|
4274
4403
|
const [wizardDone, setWizardDone] = useState10(!needsWizard);
|
|
4404
|
+
const [updateCheck, setUpdateCheck] = useState10(null);
|
|
4405
|
+
useEffect7(() => {
|
|
4406
|
+
if (!updateChecker) return void 0;
|
|
4407
|
+
let cancelled = false;
|
|
4408
|
+
updateChecker().then((result) => {
|
|
4409
|
+
if (!cancelled && result?.ok) setUpdateCheck(result);
|
|
4410
|
+
}).catch(() => {
|
|
4411
|
+
});
|
|
4412
|
+
return () => {
|
|
4413
|
+
cancelled = true;
|
|
4414
|
+
};
|
|
4415
|
+
}, [updateChecker]);
|
|
4275
4416
|
const locale = useMemo4(
|
|
4276
4417
|
() => detectLocale({ preference: initialLanguage ?? draft.ui.language }),
|
|
4277
4418
|
[initialLanguage, draft.ui.language]
|
|
@@ -4298,7 +4439,7 @@ function App({ initialLanguage, needsWizard }) {
|
|
|
4298
4439
|
refresh();
|
|
4299
4440
|
});
|
|
4300
4441
|
}, [notify, refresh]);
|
|
4301
|
-
|
|
4442
|
+
useEffect7(() => {
|
|
4302
4443
|
if (!wizardDone) return void 0;
|
|
4303
4444
|
refresh();
|
|
4304
4445
|
const timer = setInterval(() => {
|
|
@@ -4307,7 +4448,7 @@ function App({ initialLanguage, needsWizard }) {
|
|
|
4307
4448
|
return () => clearInterval(timer);
|
|
4308
4449
|
}, [wizardDone, refresh, editingId]);
|
|
4309
4450
|
const mouseChoice = useRef3(null);
|
|
4310
|
-
|
|
4451
|
+
useEffect7(() => {
|
|
4311
4452
|
if (mouseChoice.current !== null) return;
|
|
4312
4453
|
if (mouse.supported && !mouse.enabled) mouse.enable();
|
|
4313
4454
|
}, [mouse.supported, mouse.enabled, mouse]);
|
|
@@ -4327,7 +4468,9 @@ function App({ initialLanguage, needsWizard }) {
|
|
|
4327
4468
|
notify,
|
|
4328
4469
|
run,
|
|
4329
4470
|
refresh,
|
|
4330
|
-
message
|
|
4471
|
+
message,
|
|
4472
|
+
updateCheck,
|
|
4473
|
+
setUpdateCheck
|
|
4331
4474
|
};
|
|
4332
4475
|
useInput9((input, key) => {
|
|
4333
4476
|
if (key.ctrl && input === "c") {
|
|
@@ -4387,9 +4530,12 @@ function App({ initialLanguage, needsWizard }) {
|
|
|
4387
4530
|
t("hint.quit")
|
|
4388
4531
|
].filter(Boolean);
|
|
4389
4532
|
return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
|
|
4390
|
-
/* @__PURE__ */ jsxs11(Box11, { marginBottom: 1, children: [
|
|
4391
|
-
/* @__PURE__ */
|
|
4392
|
-
|
|
4533
|
+
/* @__PURE__ */ jsxs11(Box11, { marginBottom: 1, flexDirection: "column", children: [
|
|
4534
|
+
/* @__PURE__ */ jsxs11(Box11, { children: [
|
|
4535
|
+
/* @__PURE__ */ jsx12(Text11, { bold: true, children: t("app.name") }),
|
|
4536
|
+
/* @__PURE__ */ jsx12(Text11, { color: theme.muted, children: ` ${t("app.tagline")}` })
|
|
4537
|
+
] }),
|
|
4538
|
+
updateCheck?.updateAvailable ? /* @__PURE__ */ jsx12(Text11, { color: theme.warn, children: t("update.banner", { latest: updateCheck.latest ?? "", current: updateCheck.current }) }) : null
|
|
4393
4539
|
] }),
|
|
4394
4540
|
/* @__PURE__ */ jsx12(Box11, { marginBottom: 1, children: TABS.map((entry, entryIndex) => /* @__PURE__ */ jsx12(
|
|
4395
4541
|
Tab,
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/host-connector.js
CHANGED
|
@@ -31,6 +31,16 @@ const { sameSummary, summarizeAgents } = require('./agent-status');
|
|
|
31
31
|
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
32
32
|
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
33
33
|
const { ensureHerdrServer, probeHerdrServer } = require('./herdr-server');
|
|
34
|
+
const {
|
|
35
|
+
checkForUpdate,
|
|
36
|
+
compareVersions,
|
|
37
|
+
currentVersion,
|
|
38
|
+
installedVersionOnDisk,
|
|
39
|
+
updateChecksEnabled,
|
|
40
|
+
} = require('./updater');
|
|
41
|
+
|
|
42
|
+
/** Windows opening within this long of a check reuse its answer. */
|
|
43
|
+
const UPDATE_RECHECK_MS = 60_000;
|
|
34
44
|
|
|
35
45
|
/** How often the agent summary is re-read while a browser is watching. */
|
|
36
46
|
const AGENT_STATUS_POLL_MS = 5_000;
|
|
@@ -129,6 +139,18 @@ class HostConnector {
|
|
|
129
139
|
this.probingStarts = new Map();
|
|
130
140
|
/** One start at a time, however many windows asked for it. */
|
|
131
141
|
this.herdrLaunch = null;
|
|
142
|
+
/**
|
|
143
|
+
* Whether a newer herdr-remote is out, asked when a window opens. The
|
|
144
|
+
* version this process runs is fixed at start; the one on disk moves when
|
|
145
|
+
* somebody updates without restarting.
|
|
146
|
+
*/
|
|
147
|
+
this.checkUpdate = options.checkUpdate !== undefined
|
|
148
|
+
? options.checkUpdate
|
|
149
|
+
: (updateChecksEnabled() ? checkForUpdate : null);
|
|
150
|
+
this.readInstalledVersion = options.readInstalledVersion || installedVersionOnDisk;
|
|
151
|
+
this.runningVersion = options.runningVersion || currentVersion();
|
|
152
|
+
this.updateCheckedAt = 0;
|
|
153
|
+
this.updateCheck = null;
|
|
132
154
|
/** The side channel that answers "does anything need me?"; see startAgentStatus. */
|
|
133
155
|
this.requestHerdr = options.requestHerdr || requestHerdr;
|
|
134
156
|
this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
|
|
@@ -414,6 +436,8 @@ class HostConnector {
|
|
|
414
436
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
415
437
|
if (!streamId) return undefined;
|
|
416
438
|
this.stopSession(streamId);
|
|
439
|
+
// A window opening is when a person is looking; that is when to ask.
|
|
440
|
+
this.reportUpdateStatus();
|
|
417
441
|
const missingHerdr = this.ensureHerdrCommand();
|
|
418
442
|
if (missingHerdr) {
|
|
419
443
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -445,6 +469,37 @@ class HostConnector {
|
|
|
445
469
|
});
|
|
446
470
|
}
|
|
447
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Tell every window whether this workstation's herdr-remote is behind the
|
|
474
|
+
* newest release. Asked at most once a minute however many windows open;
|
|
475
|
+
* the relay keeps the last answer for windows that open in between. A failed
|
|
476
|
+
* check says nothing: offline is not news.
|
|
477
|
+
*/
|
|
478
|
+
reportUpdateStatus({ now = Date.now() } = {}) {
|
|
479
|
+
if (!this.checkUpdate || this.updateCheck) return this.updateCheck;
|
|
480
|
+
if (now - this.updateCheckedAt < UPDATE_RECHECK_MS) return null;
|
|
481
|
+
this.updateCheckedAt = now;
|
|
482
|
+
this.updateCheck = Promise.resolve()
|
|
483
|
+
.then(() => this.checkUpdate())
|
|
484
|
+
.then((result) => {
|
|
485
|
+
if (!result?.ok || !result.latest) return;
|
|
486
|
+
const running = this.runningVersion;
|
|
487
|
+
const installed = this.readInstalledVersion() || running;
|
|
488
|
+
sendJson(this.ws, {
|
|
489
|
+
type: 'update_status',
|
|
490
|
+
current: running,
|
|
491
|
+
installed,
|
|
492
|
+
latest: result.latest,
|
|
493
|
+
updateAvailable: compareVersions(result.latest, running) > 0,
|
|
494
|
+
// Updated on disk, still running the old code: a restart finishes it.
|
|
495
|
+
restartPending: compareVersions(installed, running) > 0,
|
|
496
|
+
});
|
|
497
|
+
})
|
|
498
|
+
.catch(() => {})
|
|
499
|
+
.finally(() => { this.updateCheck = null; });
|
|
500
|
+
return this.updateCheck;
|
|
501
|
+
}
|
|
502
|
+
|
|
448
503
|
/**
|
|
449
504
|
* Hold a window's session until Herdr runs, and ask the browser whether it
|
|
450
505
|
* should be started. Nothing starts until somebody says yes.
|
package/src/i18n/en.js
CHANGED
|
@@ -219,16 +219,22 @@ module.exports = {
|
|
|
219
219
|
'hint.save': 's save',
|
|
220
220
|
'hint.editing': '↵ confirm · esc cancel',
|
|
221
221
|
'update.title': 'Updates',
|
|
222
|
+
'update.banner': 'herdr-remote {latest} is out (running {current}). Press 7 and Enter on the update row to install.',
|
|
222
223
|
'update.check': 'Check for updates',
|
|
223
224
|
'update.checking': 'Checking npm…',
|
|
224
225
|
'update.upToDate': 'Up to date ({version})',
|
|
225
226
|
'update.available': 'Update {version} available (press Enter to install)',
|
|
226
227
|
'update.updating': 'Installing {version}…',
|
|
228
|
+
'update.updatingRetry': 'Installing {version}… (npm has not caught up yet, attempt {attempt})',
|
|
227
229
|
'update.done': 'Updated to {version}',
|
|
228
230
|
'update.restartHint': 'Update installed. Restart herdr-remote to apply.',
|
|
229
231
|
'update.errorNetwork': 'Could not reach any npm registry',
|
|
230
232
|
'update.errorNetworkDetail': 'No registry answered: {message}',
|
|
231
|
-
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@
|
|
233
|
+
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@{version} --prefer-online',
|
|
234
|
+
'update.errorFailedDetail': 'npm: {message}',
|
|
235
|
+
'update.errorNotYetPublished': 'npm has not caught up with {version} yet (common in the first minutes after a release). Press Enter to try again shortly.',
|
|
236
|
+
'update.errorNotApplied': 'npm reported success, but {installed} is still installed',
|
|
237
|
+
'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
|
|
232
238
|
'update.cannot.source': 'Source checkout — update with git',
|
|
233
239
|
'update.cannot.linked': 'Linked package — managed by npm link',
|
|
234
240
|
};
|
package/src/i18n/zh.js
CHANGED
|
@@ -218,16 +218,22 @@ module.exports = {
|
|
|
218
218
|
'hint.save': 's 保存',
|
|
219
219
|
'hint.editing': '↵ 确认 · esc 取消',
|
|
220
220
|
'update.title': '更新',
|
|
221
|
+
'update.banner': 'herdr-remote {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在更新一行按 Enter 安装。',
|
|
221
222
|
'update.check': '检查更新',
|
|
222
223
|
'update.checking': '查询 npm 中…',
|
|
223
224
|
'update.upToDate': '已是最新({version})',
|
|
224
225
|
'update.available': '新版本 {version} 可用(按 Enter 安装)',
|
|
225
226
|
'update.updating': '安装 {version} 中…',
|
|
227
|
+
'update.updatingRetry': '安装 {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
|
|
226
228
|
'update.done': '已更新至 {version}',
|
|
227
229
|
'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
|
|
228
230
|
'update.errorNetwork': '所有 npm registry 均无法连接',
|
|
229
231
|
'update.errorNetworkDetail': '无 registry 应答:{message}',
|
|
230
|
-
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@
|
|
232
|
+
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@{version} --prefer-online',
|
|
233
|
+
'update.errorFailedDetail': 'npm:{message}',
|
|
234
|
+
'update.errorNotYetPublished': 'npm 还没同步到 {version}(刚发布的几分钟内常见),稍后按 Enter 重试。',
|
|
235
|
+
'update.errorNotApplied': 'npm 报告成功,但安装的仍是 {installed}',
|
|
236
|
+
'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
|
|
231
237
|
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
232
238
|
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
233
239
|
};
|
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
|
};
|