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 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 askRegistry(registry, { timeoutMs, fetchImpl }) {
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(`${registry}/${PACKAGE_NAME}/latest`, {
476
+ const response = await fetchImpl(url, {
474
477
  signal: controller.signal,
475
- headers: { Accept: "application/vnd.npm.install-v1+json" }
478
+ headers: { Accept: "application/json", "Cache-Control": "no-cache" }
476
479
  });
477
480
  if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? "?"}` };
478
- const body = await response.json();
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 failures = [];
495
- for (const registry of registries) {
496
- const attempt = await askRegistry(registry, { timeoutMs, fetchImpl });
497
- if (attempt.ok) {
498
- return {
499
- ok: true,
500
- current,
501
- latest: attempt.latest,
502
- registry,
503
- updateAvailable: compareVersions(attempt.latest, current) > 0
504
- };
505
- }
506
- failures.push(`${registry}: ${attempt.message}`);
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: false,
530
+ ok: true,
510
531
  current,
511
- errorKey: "update.errorNetwork",
512
- message: failures.join("; "),
513
- triedRegistries: registries
532
+ latest,
533
+ registry: sources[0],
534
+ sources,
535
+ behind,
536
+ updateAvailable: compareVersions(latest, current) > 0
514
537
  };
515
538
  }
516
- function performUpdate2({
517
- spawnImpl = spawn,
518
- timeoutMs = 18e4,
519
- // Seam for tests: the suite runs from a checkout, where the guard below is
520
- // correctly the only reachable outcome.
521
- installKindImpl = installKind2,
522
- // The registry that just answered the version check. Installing from
523
- // somewhere the machine has been shown to reach beats installing from a
524
- // default it may have no route to. Empty means "whatever npm is configured
525
- // with", which is the right answer when no check has run.
526
- registry = ""
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
- const kind = installKindImpl();
530
- if (kind !== "npm") {
531
- resolve({ ok: false, errorKey: `update.cannot.${kind}` });
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
- resolve({ ok: false, errorKey: "update.errorFailed", output: error.message });
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@latest",
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@latest",
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 useEffect6, useMemo as useMemo4, useRef as useRef3, useState as useState10 } from "react";
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 registryRef = useState8(() => ({ current: "" }))[0];
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
- registryRef.current = result.registry ?? "";
3865
- setUpdate(result.updateAvailable ? { phase: "available", latest: result.latest } : { phase: "current", latest: result.latest });
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)({ registry: registryRef.current });
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
- setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorFailed" });
3872
- ctx.notify(t("update.errorFailed"), "error");
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__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx10(
3935
- Selectable,
3936
- {
3937
- selected: selected === "update",
3938
- onSelect: () => {
3939
- setSelected("update");
3940
- activateUpdate();
3941
- },
3942
- onHover: () => setSelected("update"),
3943
- children: updateLabel
3944
- }
3945
- ) }),
3946
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.16" }) }),
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({ initialLanguage, needsWizard }) {
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
- useEffect6(() => {
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
- useEffect6(() => {
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__ */ jsx12(Text11, { bold: true, children: t("app.name") }),
4392
- /* @__PURE__ */ jsx12(Text11, { color: theme.muted, children: ` ${t("app.tagline")}` })
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
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.16"
3
+ version = "0.2.17"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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@latest',
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@latest',
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 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
  };