herdr-remote 0.2.0 → 0.2.2

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/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
File without changes
package/dist/tui.mjs CHANGED
@@ -59,6 +59,7 @@ var require_config = __commonJS({
59
59
  }
60
60
  var PACKAGE_ROOT = findPackageRoot(__dirname);
61
61
  var ACCESS_MODES2 = ["local", "lan", "remote"];
62
+ var OFFICIAL_RELAY_URL2 = "wss://herdr-remote.564616.xyz";
62
63
  var LANGUAGES = ["auto", "zh", "en"];
63
64
  var KEEPALIVE_MANAGERS = ["auto", "systemd", "launchd", "supervisor", "none"];
64
65
  var DEFAULTS = {
@@ -325,6 +326,7 @@ var require_config = __commonJS({
325
326
  PACKAGE_ROOT,
326
327
  DEFAULTS,
327
328
  ACCESS_MODES: ACCESS_MODES2,
329
+ OFFICIAL_RELAY_URL: OFFICIAL_RELAY_URL2,
328
330
  LANGUAGES,
329
331
  KEEPALIVE_MANAGERS,
330
332
  configDir,
@@ -391,9 +393,11 @@ var require_en = __commonJS({
391
393
  "nav.about": "Language & about",
392
394
  "mode.local": "This machine only",
393
395
  "mode.lan": "Local network / Tailscale",
396
+ "mode.official": "Official relay (no server needed)",
394
397
  "mode.remote": "Self-hosted relay",
395
398
  "mode.local.description": "The relay listens on 127.0.0.1. Only a browser on this machine can reach it.",
396
399
  "mode.lan.description": "The relay listens on 0.0.0.0 (every interface), so phones on your LAN or tailnet can reach it.",
400
+ "mode.official.description": "Reach this machine from anywhere through the relay run by the Herdr Remote project. Nothing to deploy. Your terminal traffic passes through a server you do not control, and only devices you pair can open it.",
397
401
  "mode.remote.description": "No local relay. The workstation dials a relay you run, which is the only way in from outside your network.",
398
402
  "overview.title": "Status",
399
403
  "overview.mode": "Access mode",
@@ -553,7 +557,19 @@ var require_en = __commonJS({
553
557
  "hint.restartRequired": "Restart the services to apply these changes.",
554
558
  "hint.unsavedChanges": "Unsaved changes \u2014 press s to save.",
555
559
  "hint.save": "s save",
556
- "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"
557
573
  };
558
574
  }
559
575
  });
@@ -597,9 +613,11 @@ var require_zh = __commonJS({
597
613
  "nav.about": "\u8BED\u8A00\u4E0E\u5173\u4E8E",
598
614
  "mode.local": "\u4EC5\u672C\u673A",
599
615
  "mode.lan": "\u5C40\u57DF\u7F51 / Tailscale",
616
+ "mode.official": "\u5B98\u65B9 relay\uFF08\u65E0\u9700\u81EA\u5EFA\u670D\u52A1\u5668\uFF09",
600
617
  "mode.remote": "\u81EA\u5EFA relay",
601
618
  "mode.local.description": "relay \u76D1\u542C 127.0.0.1\uFF0C\u53EA\u6709\u672C\u673A\u6D4F\u89C8\u5668\u80FD\u8BBF\u95EE\u3002",
602
619
  "mode.lan.description": "relay \u76D1\u542C 0.0.0.0\uFF08\u6240\u6709\u7F51\u5361\uFF09\uFF0C\u5C40\u57DF\u7F51\u6216 tailnet \u91CC\u7684\u624B\u673A\u90FD\u80FD\u8BBF\u95EE\u3002",
620
+ "mode.official.description": "\u901A\u8FC7 Herdr Remote \u5B98\u65B9 relay \u4ECE\u4EFB\u610F\u7F51\u7EDC\u8BBF\u95EE\u672C\u673A\uFF0C\u65E0\u9700\u90E8\u7F72\u4EFB\u4F55\u670D\u52A1\u5668\u3002\u7EC8\u7AEF\u6D41\u91CF\u4F1A\u7ECF\u8FC7\u4E00\u53F0\u4F60\u65E0\u6CD5\u63A7\u5236\u7684\u670D\u52A1\u5668\uFF0C\u4F46\u53EA\u6709\u4F60\u914D\u5BF9\u8FC7\u7684\u8BBE\u5907\u624D\u80FD\u6253\u5F00\u7EC8\u7AEF\u3002",
603
621
  "mode.remote.description": "\u4E0D\u542F\u52A8\u672C\u5730 relay\u3002\u672C\u673A\u8FDE\u63A5\u4F60\u81EA\u5EFA\u7684 relay\uFF0C\u8FD9\u662F\u4ECE\u5916\u7F51\u8BBF\u95EE\u7684\u552F\u4E00\u65B9\u5F0F\u3002",
604
622
  "overview.title": "\u72B6\u6001",
605
623
  "overview.mode": "\u8BBF\u95EE\u65B9\u5F0F",
@@ -759,7 +777,19 @@ var require_zh = __commonJS({
759
777
  "hint.restartRequired": "\u9700\u8981\u91CD\u542F\u670D\u52A1\u624D\u80FD\u751F\u6548\u3002",
760
778
  "hint.unsavedChanges": "\u6709\u672A\u4FDD\u5B58\u7684\u4FEE\u6539 \u2014 \u6309 s \u4FDD\u5B58\u3002",
761
779
  "hint.save": "s \u4FDD\u5B58",
762
- "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"
763
793
  };
764
794
  }
765
795
  });
@@ -2266,6 +2296,116 @@ var require_herdr_plugin = __commonJS({
2266
2296
  }
2267
2297
  });
2268
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
+
2269
2409
  // tui/src/index.tsx
2270
2410
  import { render } from "ink";
2271
2411
 
@@ -2574,6 +2714,7 @@ var import_service = __toESM(require_service());
2574
2714
  var import_lifecycle = __toESM(require_lifecycle());
2575
2715
  var keepalive = __toESM(require_keepalive());
2576
2716
  var herdrPlugin = __toESM(require_herdr_plugin());
2717
+ var import_updater = __toESM(require_updater());
2577
2718
  function detectLocale(options = {}) {
2578
2719
  return (0, import_i18n.detectLocale)(options);
2579
2720
  }
@@ -3059,14 +3200,23 @@ function RelayScreen({ ctx }) {
3059
3200
  return /* @__PURE__ */ jsx7(Panel, { title: t("field.mode"), children: /* @__PURE__ */ jsx7(
3060
3201
  ChoiceList,
3061
3202
  {
3062
- options: ["local", "lan", "remote"].map((mode) => ({
3063
- id: mode,
3064
- label: t(`mode.${mode}`),
3065
- description: t(`mode.${mode}.description`)
3066
- })),
3067
- current: draft.relay.mode,
3068
- onPick: (mode) => {
3069
- applyField("mode", mode);
3203
+ options: [
3204
+ ...["local", "lan"].map((mode) => ({
3205
+ id: mode,
3206
+ label: t(`mode.${mode}`),
3207
+ description: t(`mode.${mode}.description`)
3208
+ })),
3209
+ { id: "official", label: t("mode.official"), description: t("mode.official.description") },
3210
+ { id: "remote", label: t("mode.remote"), description: t("mode.remote.description") }
3211
+ ],
3212
+ current: draft.relay.mode === "remote" && draft.relay.remoteUrl === import_config.OFFICIAL_RELAY_URL ? "official" : draft.relay.mode,
3213
+ onPick: (choice) => {
3214
+ if (choice === "official") {
3215
+ if (applyField("mode", "remote")) applyField("remoteUrl", import_config.OFFICIAL_RELAY_URL);
3216
+ ctx.setEditing(null);
3217
+ return;
3218
+ }
3219
+ applyField("mode", choice);
3070
3220
  ctx.setEditing(null);
3071
3221
  },
3072
3222
  onCancel: () => ctx.setEditing(null)
@@ -3447,12 +3597,65 @@ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
3447
3597
  function About({ ctx }) {
3448
3598
  const { t, draft } = ctx;
3449
3599
  const [selected, setSelected] = useState8("auto");
3600
+ const [update, setUpdate] = useState8({ phase: "idle" });
3450
3601
  const detected = detectLocale({ preference: "auto" });
3451
- const options = [
3602
+ const languageOptions = [
3452
3603
  { id: "auto", label: t("about.languageAuto", { detected }) },
3453
3604
  { id: "zh", label: t("about.languageZh") },
3454
3605
  { id: "en", label: t("about.languageEn") }
3455
3606
  ];
3607
+ const updatable = (0, import_updater.canSelfUpdate)();
3608
+ const updateLabel = (() => {
3609
+ switch (update.phase) {
3610
+ case "checking":
3611
+ return t("update.checking");
3612
+ case "current":
3613
+ return t("update.upToDate", { version: update.latest });
3614
+ case "available":
3615
+ return t("update.available", { version: update.latest });
3616
+ case "updating":
3617
+ return t("update.updating", { version: update.latest });
3618
+ case "done":
3619
+ return t("update.done", { version: update.latest });
3620
+ case "error":
3621
+ return t(update.messageKey);
3622
+ default:
3623
+ return t("update.check");
3624
+ }
3625
+ })();
3626
+ const options = [...languageOptions, { id: "update", label: updateLabel }];
3627
+ const runCheck = async () => {
3628
+ setUpdate({ phase: "checking" });
3629
+ const result = await (0, import_updater.checkForUpdate)();
3630
+ if (!result.ok) {
3631
+ setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorNetwork" });
3632
+ return;
3633
+ }
3634
+ setUpdate(result.updateAvailable ? { phase: "available", latest: result.latest } : { phase: "current", latest: result.latest });
3635
+ };
3636
+ const runUpdate = async (latest) => {
3637
+ setUpdate({ phase: "updating", latest });
3638
+ const result = await (0, import_updater.performUpdate)();
3639
+ if (!result.ok) {
3640
+ setUpdate({ phase: "error", messageKey: result.errorKey ?? "update.errorFailed" });
3641
+ ctx.notify(t("update.errorFailed"), "error");
3642
+ return;
3643
+ }
3644
+ setUpdate({ phase: "done", latest });
3645
+ ctx.notify(t("update.restartHint"), "success");
3646
+ };
3647
+ const activateUpdate = () => {
3648
+ if (update.phase === "checking" || update.phase === "updating") return;
3649
+ if (update.phase === "available") {
3650
+ if (!updatable) {
3651
+ setUpdate({ phase: "error", messageKey: `update.cannot.${(0, import_updater.installKind)()}` });
3652
+ return;
3653
+ }
3654
+ void runUpdate(update.latest);
3655
+ return;
3656
+ }
3657
+ void runCheck();
3658
+ };
3456
3659
  const choose = (language) => {
3457
3660
  const result = (0, import_settings_model.setField)(draft, "language", language);
3458
3661
  if (result.errorKey) {
@@ -3468,15 +3671,22 @@ function About({ ctx }) {
3468
3671
  ctx.notify(t("error.saveFailed", { message: error.message }), "error");
3469
3672
  }
3470
3673
  };
3674
+ const activate = (id) => {
3675
+ if (id === "update") {
3676
+ activateUpdate();
3677
+ return;
3678
+ }
3679
+ choose(id);
3680
+ };
3471
3681
  useInput7((_input, key) => {
3472
3682
  const index = options.findIndex((option) => option.id === selected);
3473
3683
  if (key.upArrow) setSelected(options[(index - 1 + options.length) % options.length].id);
3474
3684
  else if (key.downArrow) setSelected(options[(index + 1) % options.length].id);
3475
- else if (key.return) choose(selected);
3685
+ else if (key.return) activate(selected);
3476
3686
  }, { isActive: ctx.editingId === null });
3477
3687
  return /* @__PURE__ */ jsxs9(Panel, { title: t("about.title"), children: [
3478
3688
  /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.language") }),
3479
- /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: options.map((option) => /* @__PURE__ */ jsx10(
3689
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: languageOptions.map((option) => /* @__PURE__ */ jsx10(
3480
3690
  Selectable,
3481
3691
  {
3482
3692
  selected: selected === option.id,
@@ -3489,7 +3699,20 @@ function About({ ctx }) {
3489
3699
  },
3490
3700
  option.id
3491
3701
  )) }),
3492
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.0" }) }),
3702
+ /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("update.title") }),
3703
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx10(
3704
+ Selectable,
3705
+ {
3706
+ selected: selected === "update",
3707
+ onSelect: () => {
3708
+ setSelected("update");
3709
+ activateUpdate();
3710
+ },
3711
+ onHover: () => setSelected("update"),
3712
+ children: updateLabel
3713
+ }
3714
+ ) }),
3715
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.2" }) }),
3493
3716
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3494
3717
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3495
3718
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
@@ -3592,14 +3815,36 @@ function Wizard({ ctx, onDone }) {
3592
3815
  /* @__PURE__ */ jsx11(
3593
3816
  ChoiceList,
3594
3817
  {
3595
- options: ["local", "lan", "remote"].map((mode) => ({
3596
- id: mode,
3597
- label: t(`mode.${mode}`),
3598
- description: t(`mode.${mode}.description`)
3599
- })),
3600
- current: draft.relay.mode,
3601
- onPick: (mode) => {
3602
- if (apply("mode", mode)) advance("access", stepsFor(mode));
3818
+ options: [
3819
+ ...["local", "lan"].map((mode) => ({
3820
+ id: mode,
3821
+ label: t(`mode.${mode}`),
3822
+ description: t(`mode.${mode}.description`)
3823
+ })),
3824
+ // Offered ahead of the self-hosted option because it is the one
3825
+ // that needs no server. It is still a separate, explicit choice:
3826
+ // it routes the terminal through a relay this user does not own.
3827
+ {
3828
+ id: "official",
3829
+ label: t("mode.official"),
3830
+ description: t("mode.official.description")
3831
+ },
3832
+ {
3833
+ id: "remote",
3834
+ label: t("mode.remote"),
3835
+ description: t("mode.remote.description")
3836
+ }
3837
+ ],
3838
+ current: draft.relay.mode === "remote" && draft.relay.remoteUrl === import_config.OFFICIAL_RELAY_URL ? "official" : draft.relay.mode,
3839
+ onPick: (choice) => {
3840
+ if (choice === "official") {
3841
+ if (!apply("mode", "remote")) return;
3842
+ if (!apply("remoteUrl", import_config.OFFICIAL_RELAY_URL)) return;
3843
+ (0, import_service.setRelayPassword)("");
3844
+ advance("access", ["language", "access", "finish"]);
3845
+ return;
3846
+ }
3847
+ if (apply("mode", choice)) advance("access", stepsFor(choice));
3603
3848
  },
3604
3849
  onCancel: back
3605
3850
  }
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.2"
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.0",
3
+ "version": "0.2.2",
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": {
@@ -42,7 +42,7 @@
42
42
  "start": "node bin/herdr-remote.js"
43
43
  },
44
44
  "dependencies": {
45
- "herdr-remote-relay": "^0.2.0",
45
+ "herdr-remote-relay": "^0.2.1",
46
46
  "ink": "^7.1.1",
47
47
  "node-pty": "^1.1.0",
48
48
  "qrcode": "^1.5.4",
package/src/config.js CHANGED
@@ -38,6 +38,13 @@ const PACKAGE_ROOT = findPackageRoot(__dirname);
38
38
  // remote — no local relay; the host connector dials an operator-run relay,
39
39
  // which is the only way in from outside the local network.
40
40
  const ACCESS_MODES = ['local', 'lan', 'remote'];
41
+
42
+ // The relay this project runs for people who do not want to host one. Offered
43
+ // as a one-keystroke choice during setup; it is never selected automatically,
44
+ // because routing a terminal through someone else's server has to be a
45
+ // deliberate decision. It is a plain public relay: no join password, and each
46
+ // workstation stays reachable only through its own host token.
47
+ const OFFICIAL_RELAY_URL = 'wss://herdr-remote.564616.xyz';
41
48
  const LANGUAGES = ['auto', 'zh', 'en'];
42
49
  const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
43
50
 
@@ -385,6 +392,7 @@ module.exports = {
385
392
  PACKAGE_ROOT,
386
393
  DEFAULTS,
387
394
  ACCESS_MODES,
395
+ OFFICIAL_RELAY_URL,
388
396
  LANGUAGES,
389
397
  KEEPALIVE_MANAGERS,
390
398
  configDir,
package/src/exit-codes.js CHANGED
File without changes
File without changes
File without changes
File without changes
package/src/i18n/en.js CHANGED
@@ -41,9 +41,11 @@ module.exports = {
41
41
 
42
42
  'mode.local': 'This machine only',
43
43
  'mode.lan': 'Local network / Tailscale',
44
+ 'mode.official': 'Official relay (no server needed)',
44
45
  'mode.remote': 'Self-hosted relay',
45
46
  'mode.local.description': 'The relay listens on 127.0.0.1. Only a browser on this machine can reach it.',
46
47
  'mode.lan.description': 'The relay listens on 0.0.0.0 (every interface), so phones on your LAN or tailnet can reach it.',
48
+ 'mode.official.description': 'Reach this machine from anywhere through the relay run by the Herdr Remote project. Nothing to deploy. Your terminal traffic passes through a server you do not control, and only devices you pair can open it.',
47
49
  'mode.remote.description': 'No local relay. The workstation dials a relay you run, which is the only way in from outside your network.',
48
50
 
49
51
  'overview.title': 'Status',
@@ -216,4 +218,16 @@ module.exports = {
216
218
  'hint.unsavedChanges': 'Unsaved changes — press s to save.',
217
219
  'hint.save': 's save',
218
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',
219
233
  };
package/src/i18n/index.js CHANGED
File without changes
package/src/i18n/zh.js CHANGED
@@ -40,9 +40,11 @@ module.exports = {
40
40
 
41
41
  'mode.local': '仅本机',
42
42
  'mode.lan': '局域网 / Tailscale',
43
+ 'mode.official': '官方 relay(无需自建服务器)',
43
44
  'mode.remote': '自建 relay',
44
45
  'mode.local.description': 'relay 监听 127.0.0.1,只有本机浏览器能访问。',
45
46
  'mode.lan.description': 'relay 监听 0.0.0.0(所有网卡),局域网或 tailnet 里的手机都能访问。',
47
+ 'mode.official.description': '通过 Herdr Remote 官方 relay 从任意网络访问本机,无需部署任何服务器。终端流量会经过一台你无法控制的服务器,但只有你配对过的设备才能打开终端。',
46
48
  'mode.remote.description': '不启动本地 relay。本机连接你自建的 relay,这是从外网访问的唯一方式。',
47
49
 
48
50
  'overview.title': '状态',
@@ -215,4 +217,16 @@ module.exports = {
215
217
  'hint.unsavedChanges': '有未保存的修改 — 按 s 保存。',
216
218
  'hint.save': 's 保存',
217
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 管理',
218
232
  };
package/src/keepalive.js CHANGED
File without changes
package/src/lifecycle.js CHANGED
File without changes
File without changes
File without changes
package/src/service.js CHANGED
File without changes
File without changes
File without changes
package/src/state.js CHANGED
File without changes
package/src/supervisor.js CHANGED
File without changes
File without changes
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
+ };