@decentnetwork/beagle 0.1.24 → 0.1.26

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.23";
1
+ window.__DK_UI_VERSION="0.1.25";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -3191,7 +3191,7 @@ function DkDirAvatar({ p, size }) {
3191
3191
  }
3192
3192
  return /* @__PURE__ */ React.createElement(DkIdenticon, { seed: p.userid || p.name, size, radius: Math.round(size / 4) });
3193
3193
  }
3194
- function DkDirRow({ p, T, isFriend, isMe, onAdd, onOpenChat }) {
3194
+ function DkDirRow({ p, T, isFriend, isMe, onAdd, onOpenChat, onOpenProfile }) {
3195
3195
  const [state, setState] = React.useState(null);
3196
3196
  const add = () => {
3197
3197
  setState("busy");
@@ -3200,7 +3200,10 @@ function DkDirRow({ p, T, isFriend, isMe, onAdd, onOpenChat }) {
3200
3200
  const btn = (label, onClick, disabled) => /* @__PURE__ */ React.createElement(
3201
3201
  "button",
3202
3202
  {
3203
- onClick,
3203
+ onClick: (e) => {
3204
+ e.stopPropagation();
3205
+ onClick();
3206
+ },
3204
3207
  disabled,
3205
3208
  style: {
3206
3209
  padding: "5px 11px",
@@ -3216,12 +3219,23 @@ function DkDirRow({ p, T, isFriend, isMe, onAdd, onOpenChat }) {
3216
3219
  },
3217
3220
  label
3218
3221
  );
3219
- return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 11, padding: "var(--row-pad)", borderBottom: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement(DkDirAvatar, { p, size: 34 }), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "baseline", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.name), p.points != null && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--accent)", flexShrink: 0 } }, p.points.toLocaleString(), " pts"), p.ens && p.ens !== p.name && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.ens)), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement(Mono, { size: 10.5, dim: true, copy: p.userid, title: p.userid }, shortKey(p.userid, 10, 6)), p.description && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.description))), isMe ? /* @__PURE__ */ React.createElement(Tag, null, T.dirMe || "me") : isFriend ? btn(T.dirOpenChat || "chat", () => onOpenChat(p.userid), false) : state === "sent" ? /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, T.dirSent || "requested") : state && state !== "busy" ? /* @__PURE__ */ React.createElement("span", { title: state, style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--warn, #f59e0b)", flexShrink: 0 } }, T.addFailed || "failed") : btn(state === "busy" ? "\u2026" : T.dirAdd || "Add", add, state === "busy"));
3222
+ return /* @__PURE__ */ React.createElement(
3223
+ "div",
3224
+ {
3225
+ onClick: () => onOpenProfile && onOpenProfile(p),
3226
+ title: T && T.pubOpen || "View public profile",
3227
+ style: { display: "flex", alignItems: "center", gap: 11, padding: "var(--row-pad)", borderBottom: "1px solid var(--line)", cursor: "pointer" }
3228
+ },
3229
+ /* @__PURE__ */ React.createElement(DkDirAvatar, { p, size: 34 }),
3230
+ /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "baseline", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.name), p.points != null && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--accent)", flexShrink: 0 } }, p.points.toLocaleString(), " pts"), p.ens && p.ens !== p.name && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.ens)), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 } }, /* @__PURE__ */ React.createElement(Mono, { size: 10.5, dim: true, copy: p.userid, title: p.userid }, shortKey(p.userid, 10, 6)), p.description && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.description))),
3231
+ isMe ? /* @__PURE__ */ React.createElement(Tag, null, T.dirMe || "me") : isFriend ? btn(T.dirOpenChat || "chat", () => onOpenChat(p.userid), false) : state === "sent" ? /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, T.dirSent || "requested") : state && state !== "busy" ? /* @__PURE__ */ React.createElement("span", { title: state, style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--warn, #f59e0b)", flexShrink: 0 } }, T.addFailed || "failed") : btn(state === "busy" ? "\u2026" : T.dirAdd || "Add", add, state === "busy")
3232
+ );
3220
3233
  }
3221
3234
  function DiscoverTab({ T, kind, peers, meId, onAdd, onOpenChat }) {
3222
3235
  const [list, setList] = React.useState(null);
3223
3236
  const [err, setErr] = React.useState(null);
3224
3237
  const [q, setQ] = React.useState("");
3238
+ const [prof, setProf] = React.useState(null);
3225
3239
  React.useEffect(() => {
3226
3240
  let dead = false;
3227
3241
  setList(null);
@@ -3276,9 +3290,10 @@ function DiscoverTab({ T, kind, peers, meId, onAdd, onOpenChat }) {
3276
3290
  isMe: !!meId && p.userid === meId,
3277
3291
  isFriend: friendIds.has(p.userid),
3278
3292
  onAdd,
3279
- onOpenChat
3293
+ onOpenChat,
3294
+ onOpenProfile: (row) => setProf({ userid: row.userid, name: row.name })
3280
3295
  }
3281
- ))));
3296
+ ))), prof && /* @__PURE__ */ React.createElement(DkPublicProfile, { T, userid: prof.userid, fallbackName: prof.name, onClose: () => setProf(null) }));
3282
3297
  }
3283
3298
  const DK_FILE_RTC_KIND = "file";
3284
3299
  const DK_FILE_RTC_CHUNK = 16 * 1024;
@@ -4214,6 +4229,16 @@ const STR = {
4214
4229
  linksCard: "Links",
4215
4230
  linkPlaceholder: "username or profile URL",
4216
4231
  save: "save",
4232
+ updTitle: "Update available",
4233
+ updLater: "Later",
4234
+ updNow: "Update now",
4235
+ updRestart: "Restart",
4236
+ updBusy: "Updating\u2026 (about a minute)",
4237
+ updDone: "Updated \u2014 restart to apply.",
4238
+ updRestarting: "Restarting\u2026 this page reloads when it\u2019s back. (If beagle was started by hand in a terminal, run it again.)",
4239
+ updNpx: "Running via npx \u2014 restarting picks up the latest version automatically.",
4240
+ updDev: "Running from a source checkout \u2014 update with:",
4241
+ updLanNote: "agentnet (lan) updates separately; the daemon restart is manual:",
4217
4242
  pubOpen: "View public profile",
4218
4243
  pubNone: "No public profile \u2014 this user has not registered a beagles.eth name.",
4219
4244
  ensNoEthWallet: "no Ethereum wallet extension found (MetaMask\u2026)",
@@ -4360,6 +4385,16 @@ const STR = {
4360
4385
  linksCard: "\u793E\u4EA4\u8D26\u53F7",
4361
4386
  linkPlaceholder: "\u7528\u6237\u540D\u6216\u4E3B\u9875\u94FE\u63A5",
4362
4387
  save: "\u4FDD\u5B58",
4388
+ updTitle: "\u53D1\u73B0\u65B0\u7248\u672C",
4389
+ updLater: "\u7A0D\u540E",
4390
+ updNow: "\u7ACB\u5373\u66F4\u65B0",
4391
+ updRestart: "\u91CD\u542F",
4392
+ updBusy: "\u66F4\u65B0\u4E2D\u2026(\u7EA6\u4E00\u5206\u949F)",
4393
+ updDone: "\u66F4\u65B0\u5B8C\u6210\u2014\u2014\u91CD\u542F\u540E\u751F\u6548\u3002",
4394
+ updRestarting: "\u6B63\u5728\u91CD\u542F\u2026\u5B8C\u6210\u540E\u672C\u9875\u4F1A\u81EA\u52A8\u5237\u65B0\u3002(\u5982\u679C beagle \u662F\u5728\u7EC8\u7AEF\u91CC\u624B\u52A8\u542F\u52A8\u7684,\u8BF7\u91CD\u65B0\u8FD0\u884C\u4E00\u6B21\u3002)",
4395
+ updNpx: "\u4F60\u662F\u7528 npx \u8FD0\u884C\u7684\u2014\u2014\u91CD\u542F\u5373\u81EA\u52A8\u83B7\u53D6\u6700\u65B0\u7248\u3002",
4396
+ updDev: "\u5F53\u524D\u4ECE\u6E90\u7801\u76EE\u5F55\u8FD0\u884C,\u8BF7\u624B\u52A8\u66F4\u65B0:",
4397
+ updLanNote: "agentnet(lan)\u9700\u5355\u72EC\u5347\u7EA7,daemon \u91CD\u542F\u9700\u624B\u52A8\u6267\u884C:",
4363
4398
  pubOpen: "\u67E5\u770B\u516C\u5F00\u8D44\u6599",
4364
4399
  pubNone: "\u6682\u65E0\u516C\u5F00\u8D44\u6599\u2014\u2014\u5BF9\u65B9\u8FD8\u6CA1\u6709\u6CE8\u518C beagles.eth \u540D\u5B57\u3002",
4365
4400
  ensNoEthWallet: "\u672A\u68C0\u6D4B\u5230\u4EE5\u592A\u574A\u94B1\u5305\u63D2\u4EF6(MetaMask \u7B49)",
@@ -4409,12 +4444,82 @@ function RailBtn({ icon, label, active, soon, onClick }) {
4409
4444
  gap: 3
4410
4445
  } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, size: 20, stroke: active ? 2.1 : 1.8, color: active ? "var(--accent)" : "var(--dim)" }), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 9, fontWeight: 600, letterSpacing: 0.2, color: active ? "var(--accent)" : "var(--faint)" } }, label), soon && /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", top: 5, right: 6, width: 6, height: 6, borderRadius: 999, background: "var(--warn)" } }));
4411
4446
  }
4447
+ function DkUpdateModal({ T, info, onClose }) {
4448
+ const [phase, setPhase] = React.useState("idle");
4449
+ const [err, setErr] = React.useState(null);
4450
+ const behind = (info.updates || []).filter((u) => u.behind);
4451
+ const appBehind = behind.some((u) => u.pkg !== "@decentnetwork/lan");
4452
+ const lanBehind = behind.find((u) => u.pkg === "@decentnetwork/lan");
4453
+ const short = (pkg) => pkg.replace("@decentnetwork/", "");
4454
+ const doUpdate = () => {
4455
+ setPhase("running");
4456
+ setErr(null);
4457
+ fetch("/api/update-run", { method: "POST" }).then((r) => r.json()).then((d) => {
4458
+ if (d.ok)
4459
+ setPhase("done");
4460
+ else {
4461
+ setPhase("error");
4462
+ setErr(d.error || "update failed");
4463
+ }
4464
+ }).catch((e) => {
4465
+ setPhase("error");
4466
+ setErr(String(e && e.message || e));
4467
+ });
4468
+ };
4469
+ const doRestart = () => {
4470
+ setPhase("restarting");
4471
+ fetch("/api/update-restart", { method: "POST" }).catch(() => {
4472
+ });
4473
+ const poll = setInterval(() => {
4474
+ fetch("/api/update-check").then((r) => {
4475
+ if (r.ok) {
4476
+ clearInterval(poll);
4477
+ window.location.reload();
4478
+ }
4479
+ }).catch(() => {
4480
+ });
4481
+ }, 1500);
4482
+ };
4483
+ const btn = (label, onClick, tone) => /* @__PURE__ */ React.createElement("button", { onClick, style: {
4484
+ padding: "7px 14px",
4485
+ borderRadius: 9,
4486
+ cursor: "pointer",
4487
+ fontFamily: "var(--ui)",
4488
+ fontSize: 12.5,
4489
+ border: tone ? "none" : "1px solid var(--line)",
4490
+ background: tone ? "var(--accent)" : "transparent",
4491
+ color: tone ? "#fff" : "var(--text)"
4492
+ } }, label);
4493
+ return /* @__PURE__ */ React.createElement("div", { style: { position: "fixed", inset: 0, zIndex: 95, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 430, maxWidth: "94vw", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: "20px 22px", display: "flex", flexDirection: "column", gap: 14 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 700, color: "var(--text)" } }, T.updTitle || "Update available"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, behind.map((u) => /* @__PURE__ */ React.createElement("div", { key: u.pkg, style: { display: "flex", alignItems: "center", gap: 10, fontFamily: "var(--mono)", fontSize: 12.5 } }, /* @__PURE__ */ React.createElement("span", { style: { width: 70, color: "var(--faint)" } }, short(u.pkg)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--dim)" } }, u.current), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)" } }, "\u2192"), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--accent)", fontWeight: 700 } }, u.latest)))), phase === "idle" && appBehind && info.mode === "dev" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updDev || "Running from a source checkout \u2014 update with:", /* @__PURE__ */ React.createElement("br", null), /* @__PURE__ */ React.createElement("code", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)" } }, "git pull && npm run build")), phase === "idle" && appBehind && info.mode === "npx" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updNpx || "Running via npx \u2014 restarting picks up the latest version automatically."), lanBehind && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updLanNote || "agentnet (lan) updates separately; the daemon restart is manual:", /* @__PURE__ */ React.createElement("br", null), /* @__PURE__ */ React.createElement("code", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", userSelect: "all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart")), phase === "running" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--dim)" } }, T.updBusy || "Updating\u2026 (about a minute)"), phase === "done" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--online)" } }, T.updDone || "Updated \u2014 restart to apply."), phase === "restarting" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--dim)" } }, T.updRestarting || "Restarting\u2026 this page reloads when it\u2019s back. (If beagle was started by hand in a terminal, run it again.)"), phase === "error" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--danger)", wordBreak: "break-word" } }, err), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 } }, (phase === "idle" || phase === "error") && btn(T.updLater || "Later", onClose), phase === "idle" && appBehind && info.mode === "global" && btn(T.updNow || "Update now", doUpdate, true), phase === "idle" && appBehind && info.mode === "npx" && btn(T.updRestart || "Restart", doRestart, true), phase === "done" && btn(T.updRestart || "Restart", doRestart, true))));
4494
+ }
4412
4495
  function DkApp() {
4413
4496
  const [t, setTweak] = useTweaks(DK_DEFAULTS);
4414
4497
  const [tab, setTab] = React.useState(t.startTab || "chat");
4415
4498
  const [activeId, setActiveId] = React.useState(null);
4416
4499
  const data = useDaemonData();
4417
4500
  const me = data.me;
4501
+ const [upd, setUpd] = React.useState(null);
4502
+ React.useEffect(() => {
4503
+ let dead = false;
4504
+ const check = () => fetch("/api/update-check").then((r) => r.json()).then((d) => {
4505
+ if (dead || !d.ok)
4506
+ return;
4507
+ const behind = (d.updates || []).filter((u) => u.behind);
4508
+ if (!behind.length)
4509
+ return;
4510
+ const key = behind.map((u) => u.pkg + "@" + u.latest).join(",");
4511
+ if (localStorage.getItem("dk-upd-dismissed") === key)
4512
+ return;
4513
+ setUpd({ ...d, key });
4514
+ }).catch(() => {
4515
+ });
4516
+ check();
4517
+ const timer = setInterval(check, 6 * 3600 * 1e3);
4518
+ return () => {
4519
+ dead = true;
4520
+ clearInterval(timer);
4521
+ };
4522
+ }, []);
4418
4523
  const [backend, setBackend] = React.useState(null);
4419
4524
  const refreshBackend = React.useCallback(() => {
4420
4525
  dkGet("/api/backend").then((b) => {
@@ -4631,6 +4736,16 @@ function DkApp() {
4631
4736
  color: "#fff",
4632
4737
  fontSize: 12,
4633
4738
  fontWeight: 600
4634
- } }, n.label)))), /* @__PURE__ */ React.createElement(IncomingCallModal, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(CallOverlay, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(RtcFileInbox, { T, peers, ctl: rtcFileCtl }));
4739
+ } }, n.label)))), /* @__PURE__ */ React.createElement(IncomingCallModal, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(CallOverlay, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(RtcFileInbox, { T, peers, ctl: rtcFileCtl }), upd && /* @__PURE__ */ React.createElement(
4740
+ DkUpdateModal,
4741
+ {
4742
+ T,
4743
+ info: upd,
4744
+ onClose: () => {
4745
+ localStorage.setItem("dk-upd-dismissed", upd.key);
4746
+ setUpd(null);
4747
+ }
4748
+ }
4749
+ ));
4635
4750
  }
4636
4751
  ReactDOM.createRoot(document.getElementById("root")).render(/* @__PURE__ */ React.createElement(DkApp, null));
package/dist/server.js CHANGED
@@ -187,6 +187,93 @@ async function ensPublicByUserid(maxWaitMs = 300) {
187
187
  }
188
188
  return map;
189
189
  }
190
+ // ── self-update ─────────────────────────────────────────────────────────────
191
+ // npm registry is consulted at most once per TTL (and only while a UI is
192
+ // open); the UI pops a confirm dialog when something is behind. "Update"
193
+ // reinstalls the global package in place and restarts this process —
194
+ // pm2/launchd/systemd bring it back; a bare terminal run exits with a hint.
195
+ const UPDATE_TTL_MS = 6 * 3600_000;
196
+ const BEAGLE_PKG = "@decentnetwork/beagle";
197
+ let updateCache = null;
198
+ const BEAGLE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
199
+ function beagleVersion() {
200
+ try {
201
+ return JSON.parse(readFileSync(join(BEAGLE_ROOT, "package.json"), "utf-8")).version ?? "";
202
+ }
203
+ catch {
204
+ return "";
205
+ }
206
+ }
207
+ /** How this copy of beagle got onto disk — decides what "update" can do.
208
+ * npx cache paths contain _npx AND node_modules, so test _npx first. */
209
+ function installMode() {
210
+ if (BEAGLE_ROOT.includes("_npx"))
211
+ return "npx";
212
+ if (BEAGLE_ROOT.includes(join("node_modules", "@decentnetwork", "beagle")))
213
+ return "global";
214
+ return "dev";
215
+ }
216
+ async function npmLatest(pkg) {
217
+ const ctl = new AbortController();
218
+ const t = setTimeout(() => ctl.abort(), 8000);
219
+ try {
220
+ const r = await fetch(`https://registry.npmjs.org/${pkg.replace("/", "%2F")}/latest`, { signal: ctl.signal });
221
+ if (!r.ok)
222
+ return null;
223
+ return (await r.json()).version ?? null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ finally {
229
+ clearTimeout(t);
230
+ }
231
+ }
232
+ function verLt(a, b) {
233
+ const pa = a.split(".").map((x) => parseInt(x, 10));
234
+ const pb = b.split(".").map((x) => parseInt(x, 10));
235
+ if (pa.some(Number.isNaN) || pb.some(Number.isNaN))
236
+ return false;
237
+ for (let i = 0; i < 3; i++) {
238
+ const d = (pb[i] ?? 0) - (pa[i] ?? 0);
239
+ if (d !== 0)
240
+ return d > 0;
241
+ }
242
+ return false;
243
+ }
244
+ /** Run `npm i -g @decentnetwork/beagle@latest`. npm is found on PATH, else via
245
+ * npm-cli.js next to the running node (covers pm2/cron environments whose
246
+ * PATH lacks the nvm shims). */
247
+ async function npmSelfUpdate() {
248
+ const { spawn } = await import("node:child_process");
249
+ const args = ["i", "-g", `${BEAGLE_PKG}@latest`];
250
+ const npmCli = join(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
251
+ const attempts = [
252
+ { cmd: "npm", args },
253
+ ...(existsSync(npmCli) ? [{ cmd: process.execPath, args: [npmCli, ...args] }] : []),
254
+ ];
255
+ for (const a of attempts) {
256
+ const r = await new Promise((resolveRun) => {
257
+ const ch = spawn(a.cmd, a.args, { shell: process.platform === "win32" });
258
+ let out = "";
259
+ const timer = setTimeout(() => ch.kill("SIGKILL"), 5 * 60_000);
260
+ ch.stdout?.on("data", (c) => (out += c));
261
+ ch.stderr?.on("data", (c) => (out += c));
262
+ ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
263
+ ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
264
+ });
265
+ if (r.spawnErr)
266
+ continue; // npm not found this way — try the next resolver
267
+ const tail = r.out.slice(-2000);
268
+ if (r.code === 0)
269
+ return { ok: true, log: tail };
270
+ const hint = /EACCES|permission denied/i.test(r.out)
271
+ ? " — no write access to the global npm dir; run `sudo npm i -g @decentnetwork/beagle@latest` manually"
272
+ : "";
273
+ return { ok: false, error: `npm exited ${r.code}${hint}`, log: tail };
274
+ }
275
+ return { ok: false, error: "npm not found — update manually: npm i -g @decentnetwork/beagle@latest" };
276
+ }
190
277
  /** True only when the request came from the local machine. Used to gate the
191
278
  * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
192
279
  * identity signing to other hosts. The popup always runs in the local user's
@@ -1125,6 +1212,71 @@ export function startBeagleServer(opts) {
1125
1212
  // One bootstrap call for the desktop UI: composes the design's DK_*
1126
1213
  // shapes (me / peers / requests / exits) from diag + friends-list +
1127
1214
  // ipam + routes, so the client data layer is a single poll.
1215
+ // ── self-update: check (cached), run (localhost), restart (localhost) ──
1216
+ if (req.method === "GET" && url === "/api/update-check") {
1217
+ const force = new URL(req.url || "", "http://localhost").searchParams.get("force") === "1";
1218
+ if (!force && updateCache && Date.now() - updateCache.at < UPDATE_TTL_MS) {
1219
+ sendJson(res, 200, updateCache.body);
1220
+ return;
1221
+ }
1222
+ const [beagleLatest, peerLatest, lanLatest] = await Promise.all([
1223
+ npmLatest(BEAGLE_PKG),
1224
+ npmLatest("@decentnetwork/peer"),
1225
+ npmLatest("@decentnetwork/lan"),
1226
+ ]);
1227
+ const curLan = opts.meExtra?.lanVer ?? "";
1228
+ const row = (pkg, current, latest) => ({
1229
+ pkg,
1230
+ current,
1231
+ latest,
1232
+ behind: !!(current && latest && verLt(current, latest)),
1233
+ });
1234
+ const body = {
1235
+ ok: true,
1236
+ checkedAt: Date.now(),
1237
+ mode: installMode(),
1238
+ updates: [
1239
+ row(BEAGLE_PKG, beagleVersion(), beagleLatest),
1240
+ // peer ships inside beagle: reinstalling beagle refreshes it too.
1241
+ row("@decentnetwork/peer", opts.meExtra?.peerVer ?? "", peerLatest),
1242
+ ...(curLan && curLan !== "(none)" ? [row("@decentnetwork/lan", curLan, lanLatest)] : []),
1243
+ ],
1244
+ };
1245
+ updateCache = { at: Date.now(), body };
1246
+ sendJson(res, 200, body);
1247
+ return;
1248
+ }
1249
+ if (req.method === "POST" && url === "/api/update-run") {
1250
+ if (!isLocalRequest(req)) {
1251
+ sendJson(res, 403, { ok: false, error: "update runs only from this machine (localhost)" });
1252
+ return;
1253
+ }
1254
+ const mode = installMode();
1255
+ if (mode === "dev") {
1256
+ sendJson(res, 400, { ok: false, error: "running from a source checkout — update with: git pull && npm run build" });
1257
+ return;
1258
+ }
1259
+ if (mode === "npx") {
1260
+ // npx with @latest re-resolves on every launch: restarting IS the update.
1261
+ sendJson(res, 200, { ok: true, npx: true });
1262
+ return;
1263
+ }
1264
+ const r = await npmSelfUpdate();
1265
+ updateCache = null; // next check reflects the new install
1266
+ sendJson(res, r.ok ? 200 : 502, r);
1267
+ return;
1268
+ }
1269
+ if (req.method === "POST" && url === "/api/update-restart") {
1270
+ if (!isLocalRequest(req)) {
1271
+ sendJson(res, 403, { ok: false, error: "restart only from this machine (localhost)" });
1272
+ return;
1273
+ }
1274
+ sendJson(res, 200, { ok: true });
1275
+ // Let the response flush, then exit 0: pm2/launchd/systemd restart the
1276
+ // process; a bare `beagle` in a terminal just exits (the UI says so).
1277
+ setTimeout(() => process.exit(0), 400);
1278
+ return;
1279
+ }
1128
1280
  // Public profile card for any userid (friend or directory row): what
1129
1281
  // they published on their beagles.eth record. Read-only, no signing.
1130
1282
  if (req.method === "GET" && url === "/api/ens-profile") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",