herdr-remote 0.2.1 → 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/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
  }
@@ -3462,12 +3597,65 @@ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
3462
3597
  function About({ ctx }) {
3463
3598
  const { t, draft } = ctx;
3464
3599
  const [selected, setSelected] = useState8("auto");
3600
+ const [update, setUpdate] = useState8({ phase: "idle" });
3465
3601
  const detected = detectLocale({ preference: "auto" });
3466
- const options = [
3602
+ const languageOptions = [
3467
3603
  { id: "auto", label: t("about.languageAuto", { detected }) },
3468
3604
  { id: "zh", label: t("about.languageZh") },
3469
3605
  { id: "en", label: t("about.languageEn") }
3470
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
+ };
3471
3659
  const choose = (language) => {
3472
3660
  const result = (0, import_settings_model.setField)(draft, "language", language);
3473
3661
  if (result.errorKey) {
@@ -3483,15 +3671,22 @@ function About({ ctx }) {
3483
3671
  ctx.notify(t("error.saveFailed", { message: error.message }), "error");
3484
3672
  }
3485
3673
  };
3674
+ const activate = (id) => {
3675
+ if (id === "update") {
3676
+ activateUpdate();
3677
+ return;
3678
+ }
3679
+ choose(id);
3680
+ };
3486
3681
  useInput7((_input, key) => {
3487
3682
  const index = options.findIndex((option) => option.id === selected);
3488
3683
  if (key.upArrow) setSelected(options[(index - 1 + options.length) % options.length].id);
3489
3684
  else if (key.downArrow) setSelected(options[(index + 1) % options.length].id);
3490
- else if (key.return) choose(selected);
3685
+ else if (key.return) activate(selected);
3491
3686
  }, { isActive: ctx.editingId === null });
3492
3687
  return /* @__PURE__ */ jsxs9(Panel, { title: t("about.title"), children: [
3493
3688
  /* @__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(
3689
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: languageOptions.map((option) => /* @__PURE__ */ jsx10(
3495
3690
  Selectable,
3496
3691
  {
3497
3692
  selected: selected === option.id,
@@ -3504,7 +3699,20 @@ function About({ ctx }) {
3504
3699
  },
3505
3700
  option.id
3506
3701
  )) }),
3507
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.1" }) }),
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" }) }),
3508
3716
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3509
3717
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3510
3718
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
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.1",
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": {
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
+ };