herdr-remote 0.2.1 → 0.2.3

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
@@ -557,7 +557,19 @@ var require_en = __commonJS({
557
557
  "hint.restartRequired": "Restart the services to apply these changes.",
558
558
  "hint.unsavedChanges": "Unsaved changes \u2014 press s to save.",
559
559
  "hint.save": "s save",
560
- "hint.editing": "\u21B5 confirm \xB7 esc cancel"
560
+ "hint.editing": "\u21B5 confirm \xB7 esc cancel",
561
+ "update.title": "Updates",
562
+ "update.check": "Check for updates",
563
+ "update.checking": "Checking npm\u2026",
564
+ "update.upToDate": "Up to date ({version})",
565
+ "update.available": "Update available: {version} \u2014 press Enter to install",
566
+ "update.updating": "Installing {version}\u2026",
567
+ "update.done": "Updated to {version}",
568
+ "update.restartHint": "Update installed. Restart herdr-remote to use it.",
569
+ "update.errorNetwork": "Could not reach the npm registry",
570
+ "update.errorFailed": "The update failed. Try: npm install -g herdr-remote@latest",
571
+ "update.cannot.source": "Running from a source checkout \u2014 update with git, not npm",
572
+ "update.cannot.linked": "Running from a linked working copy \u2014 npm link manages this one"
561
573
  };
562
574
  }
563
575
  });
@@ -765,7 +777,19 @@ var require_zh = __commonJS({
765
777
  "hint.restartRequired": "\u9700\u8981\u91CD\u542F\u670D\u52A1\u624D\u80FD\u751F\u6548\u3002",
766
778
  "hint.unsavedChanges": "\u6709\u672A\u4FDD\u5B58\u7684\u4FEE\u6539 \u2014 \u6309 s \u4FDD\u5B58\u3002",
767
779
  "hint.save": "s \u4FDD\u5B58",
768
- "hint.editing": "\u21B5 \u786E\u8BA4 \xB7 esc \u53D6\u6D88"
780
+ "hint.editing": "\u21B5 \u786E\u8BA4 \xB7 esc \u53D6\u6D88",
781
+ "update.title": "\u66F4\u65B0",
782
+ "update.check": "\u68C0\u67E5\u66F4\u65B0",
783
+ "update.checking": "\u6B63\u5728\u67E5\u8BE2 npm\u2026",
784
+ "update.upToDate": "\u5DF2\u662F\u6700\u65B0\uFF08{version}\uFF09",
785
+ "update.available": "\u6709\u65B0\u7248\u672C {version} \u2014\u2014 \u6309 Enter \u5B89\u88C5",
786
+ "update.updating": "\u6B63\u5728\u5B89\u88C5 {version}\u2026",
787
+ "update.done": "\u5DF2\u66F4\u65B0\u5230 {version}",
788
+ "update.restartHint": "\u66F4\u65B0\u5DF2\u5B89\u88C5\uFF0C\u91CD\u542F herdr-remote \u540E\u751F\u6548\u3002",
789
+ "update.errorNetwork": "\u65E0\u6CD5\u8FDE\u63A5 npm registry",
790
+ "update.errorFailed": "\u66F4\u65B0\u5931\u8D25\uFF0C\u53EF\u624B\u52A8\u6267\u884C\uFF1Anpm install -g herdr-remote@latest",
791
+ "update.cannot.source": "\u5F53\u524D\u4ECE\u6E90\u7801\u76EE\u5F55\u8FD0\u884C \u2014\u2014 \u8BF7\u7528 git \u66F4\u65B0\uFF0C\u800C\u4E0D\u662F npm",
792
+ "update.cannot.linked": "\u5F53\u524D\u662F npm link \u7684\u5DE5\u4F5C\u526F\u672C \u2014\u2014 \u7531 npm link \u7BA1\u7406"
769
793
  };
770
794
  }
771
795
  });
@@ -2272,6 +2296,116 @@ var require_herdr_plugin = __commonJS({
2272
2296
  }
2273
2297
  });
2274
2298
 
2299
+ // src/updater.js
2300
+ var require_updater = __commonJS({
2301
+ "src/updater.js"(exports, module) {
2302
+ "use strict";
2303
+ var fs = __require("node:fs");
2304
+ var path = __require("node:path");
2305
+ var { spawn } = __require("node:child_process");
2306
+ var { PACKAGE_ROOT } = require_config();
2307
+ var PACKAGE_NAME = "herdr-remote";
2308
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
2309
+ function currentVersion() {
2310
+ try {
2311
+ return __require(path.join(PACKAGE_ROOT, "package.json")).version;
2312
+ } catch {
2313
+ return "0.0.0";
2314
+ }
2315
+ }
2316
+ function installKind2() {
2317
+ const root = PACKAGE_ROOT;
2318
+ if (!root.split(path.sep).includes("node_modules")) return "source";
2319
+ try {
2320
+ if (fs.lstatSync(root).isSymbolicLink()) return "linked";
2321
+ } catch {
2322
+ }
2323
+ return "npm";
2324
+ }
2325
+ function canSelfUpdate2() {
2326
+ return installKind2() === "npm";
2327
+ }
2328
+ function compareVersions(a, b) {
2329
+ const parse = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
2330
+ const left = parse(a);
2331
+ const right = parse(b);
2332
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
2333
+ const diff = (left[index] || 0) - (right[index] || 0);
2334
+ if (diff > 0) return 1;
2335
+ if (diff < 0) return -1;
2336
+ }
2337
+ return 0;
2338
+ }
2339
+ async function checkForUpdate2({ timeoutMs = 8e3, fetchImpl = globalThis.fetch } = {}) {
2340
+ const current = currentVersion();
2341
+ const controller = new AbortController();
2342
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2343
+ try {
2344
+ const response = await fetchImpl(REGISTRY_URL, {
2345
+ signal: controller.signal,
2346
+ headers: { Accept: "application/vnd.npm.install-v1+json" }
2347
+ });
2348
+ if (!response.ok) {
2349
+ return { ok: false, current, errorKey: "update.errorNetwork" };
2350
+ }
2351
+ const body = await response.json();
2352
+ const latest = typeof body?.version === "string" ? body.version : null;
2353
+ if (!latest) return { ok: false, current, errorKey: "update.errorNetwork" };
2354
+ return {
2355
+ ok: true,
2356
+ current,
2357
+ latest,
2358
+ updateAvailable: compareVersions(latest, current) > 0
2359
+ };
2360
+ } catch {
2361
+ return { ok: false, current, errorKey: "update.errorNetwork" };
2362
+ } finally {
2363
+ clearTimeout(timer);
2364
+ }
2365
+ }
2366
+ function performUpdate2({
2367
+ spawnImpl = spawn,
2368
+ timeoutMs = 18e4,
2369
+ // Seam for tests: the suite runs from a checkout, where the guard below is
2370
+ // correctly the only reachable outcome.
2371
+ installKindImpl = installKind2
2372
+ } = {}) {
2373
+ return new Promise((resolve) => {
2374
+ const kind = installKindImpl();
2375
+ if (kind !== "npm") {
2376
+ resolve({ ok: false, errorKey: `update.cannot.${kind}` });
2377
+ return;
2378
+ }
2379
+ const child = spawnImpl("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
2380
+ encoding: "utf8",
2381
+ timeout: timeoutMs
2382
+ });
2383
+ let output = "";
2384
+ const collect = (chunk) => {
2385
+ output += String(chunk);
2386
+ };
2387
+ child.stdout?.on("data", collect);
2388
+ child.stderr?.on("data", collect);
2389
+ child.on("error", (error) => {
2390
+ resolve({ ok: false, errorKey: "update.errorFailed", output: error.message });
2391
+ });
2392
+ child.on("close", (code) => {
2393
+ resolve({ ok: code === 0, output: output.trim().slice(-2e3) });
2394
+ });
2395
+ });
2396
+ }
2397
+ module.exports = {
2398
+ PACKAGE_NAME,
2399
+ canSelfUpdate: canSelfUpdate2,
2400
+ checkForUpdate: checkForUpdate2,
2401
+ compareVersions,
2402
+ currentVersion,
2403
+ installKind: installKind2,
2404
+ performUpdate: performUpdate2
2405
+ };
2406
+ }
2407
+ });
2408
+
2275
2409
  // tui/src/index.tsx
2276
2410
  import { render } from "ink";
2277
2411
 
@@ -2580,6 +2714,7 @@ var import_service = __toESM(require_service());
2580
2714
  var import_lifecycle = __toESM(require_lifecycle());
2581
2715
  var keepalive = __toESM(require_keepalive());
2582
2716
  var herdrPlugin = __toESM(require_herdr_plugin());
2717
+ var import_updater = __toESM(require_updater());
2583
2718
  function detectLocale(options = {}) {
2584
2719
  return (0, import_i18n.detectLocale)(options);
2585
2720
  }
@@ -2997,16 +3132,21 @@ function RelayScreen({ ctx }) {
2997
3132
  { id: "regenerate", kind: "action", label: t("relay.regenerate") }
2998
3133
  ];
2999
3134
  const addresses = useMemo2(() => listReachableAddresses({ includeLoopback: false }), []);
3000
- const applyField = (id, value) => {
3001
- const result = (0, import_settings_model.setField)(draft, id, value);
3002
- if (result.errorKey) {
3003
- ctx.notify(t(result.errorKey), "error");
3004
- return false;
3135
+ const applyFields = (updates) => {
3136
+ let next = draft;
3137
+ for (const [id, value] of updates) {
3138
+ const result = (0, import_settings_model.setField)(next, id, value);
3139
+ if (result.errorKey) {
3140
+ ctx.notify(t(result.errorKey), "error");
3141
+ return false;
3142
+ }
3143
+ next = result.draft;
3005
3144
  }
3006
- ctx.updateDraft(result.draft);
3145
+ ctx.updateDraft(next);
3007
3146
  ctx.notify("", "info");
3008
3147
  return true;
3009
3148
  };
3149
+ const applyField = (id, value) => applyFields([[id, value]]);
3010
3150
  const save = () => {
3011
3151
  try {
3012
3152
  const result = (0, import_settings_model.saveDraft)(draft);
@@ -3077,7 +3217,7 @@ function RelayScreen({ ctx }) {
3077
3217
  current: draft.relay.mode === "remote" && draft.relay.remoteUrl === import_config.OFFICIAL_RELAY_URL ? "official" : draft.relay.mode,
3078
3218
  onPick: (choice) => {
3079
3219
  if (choice === "official") {
3080
- if (applyField("mode", "remote")) applyField("remoteUrl", import_config.OFFICIAL_RELAY_URL);
3220
+ applyFields([["mode", "remote"], ["remoteUrl", import_config.OFFICIAL_RELAY_URL]]);
3081
3221
  ctx.setEditing(null);
3082
3222
  return;
3083
3223
  }
@@ -3462,12 +3602,65 @@ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
3462
3602
  function About({ ctx }) {
3463
3603
  const { t, draft } = ctx;
3464
3604
  const [selected, setSelected] = useState8("auto");
3605
+ const [update, setUpdate] = useState8({ phase: "idle" });
3465
3606
  const detected = detectLocale({ preference: "auto" });
3466
- const options = [
3607
+ const languageOptions = [
3467
3608
  { id: "auto", label: t("about.languageAuto", { detected }) },
3468
3609
  { id: "zh", label: t("about.languageZh") },
3469
3610
  { id: "en", label: t("about.languageEn") }
3470
3611
  ];
3612
+ const updatable = (0, import_updater.canSelfUpdate)();
3613
+ const updateLabel = (() => {
3614
+ switch (update.phase) {
3615
+ case "checking":
3616
+ return t("update.checking");
3617
+ case "current":
3618
+ return t("update.upToDate", { version: update.latest });
3619
+ case "available":
3620
+ return t("update.available", { version: update.latest });
3621
+ case "updating":
3622
+ return t("update.updating", { version: update.latest });
3623
+ case "done":
3624
+ return t("update.done", { version: update.latest });
3625
+ case "error":
3626
+ return t(update.messageKey);
3627
+ default:
3628
+ return t("update.check");
3629
+ }
3630
+ })();
3631
+ const options = [...languageOptions, { id: "update", label: updateLabel }];
3632
+ const runCheck = async () => {
3633
+ setUpdate({ phase: "checking" });
3634
+ const result = await (0, import_updater.checkForUpdate)();
3635
+ if (!result.ok) {
3636
+ setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorNetwork" });
3637
+ return;
3638
+ }
3639
+ setUpdate(result.updateAvailable ? { phase: "available", latest: result.latest } : { phase: "current", latest: result.latest });
3640
+ };
3641
+ const runUpdate = async (latest) => {
3642
+ setUpdate({ phase: "updating", latest });
3643
+ const result = await (0, import_updater.performUpdate)();
3644
+ if (!result.ok) {
3645
+ setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorFailed" });
3646
+ ctx.notify(t("update.errorFailed"), "error");
3647
+ return;
3648
+ }
3649
+ setUpdate({ phase: "done", latest });
3650
+ ctx.notify(t("update.restartHint"), "success");
3651
+ };
3652
+ const activateUpdate = () => {
3653
+ if (update.phase === "checking" || update.phase === "updating") return;
3654
+ if (update.phase === "available") {
3655
+ if (!updatable) {
3656
+ setUpdate({ phase: "error", messageKey: `update.cannot.${(0, import_updater.installKind)()}` });
3657
+ return;
3658
+ }
3659
+ void runUpdate(update.latest);
3660
+ return;
3661
+ }
3662
+ void runCheck();
3663
+ };
3471
3664
  const choose = (language) => {
3472
3665
  const result = (0, import_settings_model.setField)(draft, "language", language);
3473
3666
  if (result.errorKey) {
@@ -3483,15 +3676,22 @@ function About({ ctx }) {
3483
3676
  ctx.notify(t("error.saveFailed", { message: error.message }), "error");
3484
3677
  }
3485
3678
  };
3679
+ const activate = (id) => {
3680
+ if (id === "update") {
3681
+ activateUpdate();
3682
+ return;
3683
+ }
3684
+ choose(id);
3685
+ };
3486
3686
  useInput7((_input, key) => {
3487
3687
  const index = options.findIndex((option) => option.id === selected);
3488
3688
  if (key.upArrow) setSelected(options[(index - 1 + options.length) % options.length].id);
3489
3689
  else if (key.downArrow) setSelected(options[(index + 1) % options.length].id);
3490
- else if (key.return) choose(selected);
3690
+ else if (key.return) activate(selected);
3491
3691
  }, { isActive: ctx.editingId === null });
3492
3692
  return /* @__PURE__ */ jsxs9(Panel, { title: t("about.title"), children: [
3493
3693
  /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.language") }),
3494
- /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: options.map((option) => /* @__PURE__ */ jsx10(
3694
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: languageOptions.map((option) => /* @__PURE__ */ jsx10(
3495
3695
  Selectable,
3496
3696
  {
3497
3697
  selected: selected === option.id,
@@ -3504,7 +3704,20 @@ function About({ ctx }) {
3504
3704
  },
3505
3705
  option.id
3506
3706
  )) }),
3507
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.1" }) }),
3707
+ /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("update.title") }),
3708
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx10(
3709
+ Selectable,
3710
+ {
3711
+ selected: selected === "update",
3712
+ onSelect: () => {
3713
+ setSelected("update");
3714
+ activateUpdate();
3715
+ },
3716
+ onHover: () => setSelected("update"),
3717
+ children: updateLabel
3718
+ }
3719
+ ) }),
3720
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.3" }) }),
3508
3721
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3509
3722
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3510
3723
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
@@ -3516,10 +3729,10 @@ function About({ ctx }) {
3516
3729
  import { useMemo as useMemo3, useState as useState9 } from "react";
3517
3730
  import { Box as Box10, Text as Text10, useInput as useInput8 } from "ink";
3518
3731
  import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
3519
- function stepsFor(mode) {
3732
+ function stepsFor(mode, remoteUrl) {
3520
3733
  const steps = ["language", "access"];
3521
3734
  if (mode === "lan") steps.push("address");
3522
- if (mode === "remote") steps.push("relayUrl", "password");
3735
+ if (mode === "remote" && remoteUrl !== import_config.OFFICIAL_RELAY_URL) steps.push("relayUrl", "password");
3523
3736
  steps.push("finish");
3524
3737
  return steps;
3525
3738
  }
@@ -3534,18 +3747,26 @@ function Wizard({ ctx, onDone }) {
3534
3747
  () => listReachableAddresses({ includeLoopback: false }),
3535
3748
  []
3536
3749
  );
3537
- const steps = useMemo3(() => stepsFor(draft.relay.mode), [draft.relay.mode]);
3750
+ const steps = useMemo3(
3751
+ () => stepsFor(draft.relay.mode, draft.relay.remoteUrl),
3752
+ [draft.relay.mode, draft.relay.remoteUrl]
3753
+ );
3538
3754
  const stepIndex = Math.max(0, steps.indexOf(step));
3539
- const apply = (id, value) => {
3540
- const result = (0, import_settings_model.setField)(draft, id, value);
3541
- if (result.errorKey) {
3542
- ctx.notify(t(result.errorKey), "error");
3543
- return false;
3755
+ const applyAll = (updates) => {
3756
+ let next = draft;
3757
+ for (const [id, value] of updates) {
3758
+ const result = (0, import_settings_model.setField)(next, id, value);
3759
+ if (result.errorKey) {
3760
+ ctx.notify(t(result.errorKey), "error");
3761
+ return false;
3762
+ }
3763
+ next = result.draft;
3544
3764
  }
3545
- ctx.updateDraft(result.draft);
3765
+ ctx.updateDraft(next);
3546
3766
  ctx.notify("", "info");
3547
3767
  return true;
3548
3768
  };
3769
+ const apply = (id, value) => applyAll([[id, value]]);
3549
3770
  const advance = (from, order = steps) => {
3550
3771
  const index = order.indexOf(from);
3551
3772
  setStep(order[Math.min(order.length - 1, index + 1)]);
@@ -3630,13 +3851,14 @@ function Wizard({ ctx, onDone }) {
3630
3851
  current: draft.relay.mode === "remote" && draft.relay.remoteUrl === import_config.OFFICIAL_RELAY_URL ? "official" : draft.relay.mode,
3631
3852
  onPick: (choice) => {
3632
3853
  if (choice === "official") {
3633
- if (!apply("mode", "remote")) return;
3634
- if (!apply("remoteUrl", import_config.OFFICIAL_RELAY_URL)) return;
3854
+ if (!applyAll([["mode", "remote"], ["remoteUrl", import_config.OFFICIAL_RELAY_URL]])) return;
3635
3855
  (0, import_service.setRelayPassword)("");
3636
- advance("access", ["language", "access", "finish"]);
3856
+ advance("access", stepsFor("remote", import_config.OFFICIAL_RELAY_URL));
3637
3857
  return;
3638
3858
  }
3639
- if (apply("mode", choice)) advance("access", stepsFor(choice));
3859
+ if (apply("mode", choice)) {
3860
+ advance("access", stepsFor(choice, draft.relay.remoteUrl));
3861
+ }
3640
3862
  },
3641
3863
  onCancel: back
3642
3864
  }
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.0"
3
+ version = "0.2.3"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Mobile-first remote access to the native Herdr TUI through a 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.1",
3
+ "version": "0.2.3",
4
4
  "description": "Remote browser access to your Herdr terminal workspaces: Herdr plugin, host connector, and bilingual configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/i18n/en.js CHANGED
@@ -218,4 +218,16 @@ module.exports = {
218
218
  'hint.unsavedChanges': 'Unsaved changes — press s to save.',
219
219
  'hint.save': 's save',
220
220
  'hint.editing': '↵ confirm · esc cancel',
221
+ 'update.title': 'Updates',
222
+ 'update.check': 'Check for updates',
223
+ 'update.checking': 'Checking npm…',
224
+ 'update.upToDate': 'Up to date ({version})',
225
+ 'update.available': 'Update available: {version} — press Enter to install',
226
+ 'update.updating': 'Installing {version}…',
227
+ 'update.done': 'Updated to {version}',
228
+ 'update.restartHint': 'Update installed. Restart herdr-remote to use it.',
229
+ 'update.errorNetwork': 'Could not reach the npm registry',
230
+ 'update.errorFailed': 'The update failed. Try: npm install -g herdr-remote@latest',
231
+ 'update.cannot.source': 'Running from a source checkout — update with git, not npm',
232
+ 'update.cannot.linked': 'Running from a linked working copy — npm link manages this one',
221
233
  };
package/src/i18n/zh.js CHANGED
@@ -217,4 +217,16 @@ module.exports = {
217
217
  'hint.unsavedChanges': '有未保存的修改 — 按 s 保存。',
218
218
  'hint.save': 's 保存',
219
219
  'hint.editing': '↵ 确认 · esc 取消',
220
+ 'update.title': '更新',
221
+ 'update.check': '检查更新',
222
+ 'update.checking': '正在查询 npm…',
223
+ 'update.upToDate': '已是最新({version})',
224
+ 'update.available': '有新版本 {version} —— 按 Enter 安装',
225
+ 'update.updating': '正在安装 {version}…',
226
+ 'update.done': '已更新到 {version}',
227
+ 'update.restartHint': '更新已安装,重启 herdr-remote 后生效。',
228
+ 'update.errorNetwork': '无法连接 npm registry',
229
+ 'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@latest',
230
+ 'update.cannot.source': '当前从源码目录运行 —— 请用 git 更新,而不是 npm',
231
+ 'update.cannot.linked': '当前是 npm link 的工作副本 —— 由 npm link 管理',
220
232
  };
package/src/updater.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // Self-update for the npm-installed CLI.
4
+ //
5
+ // The update path is only meaningful for a package installed from npm. A source
6
+ // checkout is managed by git and a linked development copy by the developer, so
7
+ // this refuses to touch either: silently running `npm install -g` over a
8
+ // checkout would replace the tree someone is working in.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { spawn } = require('node:child_process');
13
+ const { PACKAGE_ROOT } = require('./config');
14
+
15
+ const PACKAGE_NAME = 'herdr-remote';
16
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
17
+
18
+ function currentVersion() {
19
+ try {
20
+ return require(path.join(PACKAGE_ROOT, 'package.json')).version;
21
+ } catch {
22
+ return '0.0.0';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * How this copy got here.
28
+ *
29
+ * - `npm` installed from the registry; updating is `npm install -g`.
30
+ * - `linked` `npm link`ed into a global tree from a working copy.
31
+ * - `source` run straight out of a checkout.
32
+ *
33
+ * The distinction is drawn from the path rather than from npm, which would
34
+ * mean shelling out on every render.
35
+ */
36
+ function installKind() {
37
+ const root = PACKAGE_ROOT;
38
+ // A real install always lives inside a node_modules tree.
39
+ if (!root.split(path.sep).includes('node_modules')) return 'source';
40
+ try {
41
+ // `npm link` leaves a symlink where a published install has a directory.
42
+ if (fs.lstatSync(root).isSymbolicLink()) return 'linked';
43
+ } catch {
44
+ // Unreadable: treat as a normal install and let npm report the problem.
45
+ }
46
+ return 'npm';
47
+ }
48
+
49
+ function canSelfUpdate() {
50
+ return installKind() === 'npm';
51
+ }
52
+
53
+ /** Compare two `MAJOR.MINOR.PATCH` strings. Returns 1, -1 or 0. */
54
+ function compareVersions(a, b) {
55
+ const parse = (value) => String(value)
56
+ .split('-')[0]
57
+ .split('.')
58
+ .map((part) => Number.parseInt(part, 10) || 0);
59
+ const left = parse(a);
60
+ const right = parse(b);
61
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
62
+ const diff = (left[index] || 0) - (right[index] || 0);
63
+ if (diff > 0) return 1;
64
+ if (diff < 0) return -1;
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /**
70
+ * Ask the registry what the current release is.
71
+ *
72
+ * Never throws: an update check is a convenience, and a machine that is offline
73
+ * or behind a proxy should still get a working settings screen.
74
+ */
75
+ async function checkForUpdate({ timeoutMs = 8000, fetchImpl = globalThis.fetch } = {}) {
76
+ const current = currentVersion();
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
79
+ try {
80
+ const response = await fetchImpl(REGISTRY_URL, {
81
+ signal: controller.signal,
82
+ headers: { Accept: 'application/vnd.npm.install-v1+json' },
83
+ });
84
+ if (!response.ok) {
85
+ return { ok: false, current, errorKey: 'update.errorNetwork' };
86
+ }
87
+ const body = await response.json();
88
+ const latest = typeof body?.version === 'string' ? body.version : null;
89
+ if (!latest) return { ok: false, current, errorKey: 'update.errorNetwork' };
90
+ return {
91
+ ok: true,
92
+ current,
93
+ latest,
94
+ updateAvailable: compareVersions(latest, current) > 0,
95
+ };
96
+ } catch {
97
+ return { ok: false, current, errorKey: 'update.errorNetwork' };
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Install the newest release over this one.
105
+ *
106
+ * Resolves with `{ ok, output }` rather than rejecting, so the caller can show
107
+ * npm's own message; npm's diagnostics are more useful than anything that could
108
+ * be invented here.
109
+ */
110
+ function performUpdate({
111
+ spawnImpl = spawn,
112
+ timeoutMs = 180_000,
113
+ // Seam for tests: the suite runs from a checkout, where the guard below is
114
+ // correctly the only reachable outcome.
115
+ installKindImpl = installKind,
116
+ } = {}) {
117
+ return new Promise((resolve) => {
118
+ const kind = installKindImpl();
119
+ if (kind !== 'npm') {
120
+ resolve({ ok: false, errorKey: `update.cannot.${kind}` });
121
+ return;
122
+ }
123
+ const child = spawnImpl('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
124
+ encoding: 'utf8',
125
+ timeout: timeoutMs,
126
+ });
127
+ let output = '';
128
+ const collect = (chunk) => { output += String(chunk); };
129
+ child.stdout?.on('data', collect);
130
+ child.stderr?.on('data', collect);
131
+ child.on('error', (error) => {
132
+ resolve({ ok: false, errorKey: 'update.errorFailed', output: error.message });
133
+ });
134
+ child.on('close', (code) => {
135
+ resolve({ ok: code === 0, output: output.trim().slice(-2000) });
136
+ });
137
+ });
138
+ }
139
+
140
+ module.exports = {
141
+ PACKAGE_NAME,
142
+ canSelfUpdate,
143
+ checkForUpdate,
144
+ compareVersions,
145
+ currentVersion,
146
+ installKind,
147
+ performUpdate,
148
+ };