@decentnetwork/beagle 0.1.81 → 0.1.83

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/cli.js CHANGED
@@ -12,6 +12,7 @@ import { startBeagleServer } from "./server.js";
12
12
  import { openPeerHost, defaultConfigDir, decentlanCarrierDir, makeDaemonHost } from "./peer-host.js";
13
13
  import { SwitchablePeerHost } from "./lan-handoff.js";
14
14
  import { loadNodeConfig, saveAutoAccept } from "./node-config.js";
15
+ import { setUiPortHint } from "./identity-lock.js";
15
16
  function parseArgs(argv) {
16
17
  const get = (flag) => {
17
18
  const i = argv.indexOf(flag);
@@ -134,6 +135,11 @@ async function main() {
134
135
  onAutoAcceptChange: (enabled) => saveAutoAccept(args.configDir, enabled),
135
136
  force: "embedded",
136
137
  })).host;
138
+ // Publish the port before anything takes the identity lock, so the lock file
139
+ // and the way to reach us are written together. `agentnet restart` reads it:
140
+ // on a machine where beagle holds the identity there is no daemon for it to
141
+ // talk to, and without this the documented upgrade one-liner just failed.
142
+ setUiPortHint(args.port, decentlanCarrierDir(args.configDir));
137
143
  const lanHost = new SwitchablePeerHost(peerHost, {
138
144
  dataDir: decentlanCarrierDir(args.configDir),
139
145
  makeEmbedded: openEmbedded,
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.80";
1
+ window.__DK_UI_VERSION="0.1.82";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -4315,6 +4315,11 @@ ${peer.address}`
4315
4315
  return [values, setTweak];
4316
4316
  }
4317
4317
  Object.assign(window, { useTweaks });
4318
+ function ownershipFix(info) {
4319
+ const r = String(info && info.selfUpdateBlocked || "");
4320
+ const at = r.indexOf("sudo chown");
4321
+ return at >= 0 ? r.slice(at) : 'sudo chown -R $(id -u):$(id -g) "$(npm prefix -g)/lib/node_modules/@decentnetwork"';
4322
+ }
4318
4323
  function dkParseDeepLinkAddress() {
4319
4324
  try {
4320
4325
  const h = window.location.hash || "";
@@ -4843,7 +4848,31 @@ ${peer.address}`
4843
4848
  background: tone ? "var(--accent)" : "transparent",
4844
4849
  color: tone ? "#fff" : "var(--text)"
4845
4850
  } }, label);
4846
- 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 === "unknown" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updUnknown || "Update now runs the global install. If that is not how this was installed, run:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/beagle@latest"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/beagle@latest") }))), 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."), daemonPeerBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updPeerDaemon || "The peer version belongs to the running agentnet daemon, not to this app. Update it with:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), lanBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updLanNote || "agentnet (lan): one click reinstalls it and asks the running daemon to restart itself. Manual equivalent:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), phase === "lan-done" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--online)" } }, T.updLanDone || "lan updated \u2014 daemon restarted with the new version \u2713"), phase === "lan-norestart" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--warn, #f59e0b)" } }, T.updLanNoRestart || "lan updated, but the daemon was not running (or refused) \u2014 the new version applies on its next start."), 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" && (() => {
4851
+ 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" && info.selfUpdatable === false && // Say it before the button is pressed. The card used to offer an
4852
+ // update, fail on permissions, and hand back `sudo npm i -g` — which
4853
+ // works once and leaves the directory root-owned, so the next update
4854
+ // fails identically. That advice was the loop. This is the command
4855
+ // that ends it, and it is needed once.
4856
+ /* @__PURE__ */ React.createElement("div", { style: {
4857
+ fontFamily: "var(--ui)",
4858
+ fontSize: 12,
4859
+ color: "var(--dim)",
4860
+ lineHeight: 1.6,
4861
+ borderLeft: "2px solid var(--danger)",
4862
+ paddingLeft: 10
4863
+ } }, T.updNoPerm || "Automatic updates cannot write the global npm directory \u2014 this is what a first install with sudo leaves behind. Run this once and it never comes back:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: {
4864
+ flex: 1,
4865
+ minWidth: 0,
4866
+ fontFamily: "var(--mono)",
4867
+ fontSize: 11.5,
4868
+ color: "var(--text)",
4869
+ background: "var(--panel-2)",
4870
+ border: "1px solid var(--line)",
4871
+ borderRadius: 8,
4872
+ padding: "8px 10px",
4873
+ userSelect: "all",
4874
+ wordBreak: "break-all"
4875
+ } }, ownershipFix(info)), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy(ownershipFix(info)) }))), 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 === "unknown" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updUnknown || "Update now runs the global install. If that is not how this was installed, run:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/beagle@latest"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/beagle@latest") }))), 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."), daemonPeerBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updPeerDaemon || "The peer version belongs to the running agentnet daemon, not to this app. Update it with:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), lanBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updLanNote || "agentnet (lan): one click reinstalls it and asks the running daemon to restart itself. Manual equivalent:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), phase === "lan-done" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--online)" } }, T.updLanDone || "lan updated \u2014 daemon restarted with the new version \u2713"), phase === "lan-norestart" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--warn, #f59e0b)" } }, T.updLanNoRestart || "lan updated, but the daemon was not running (or refused) \u2014 the new version applies on its next start."), 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" && (() => {
4847
4876
  const m = /`([^`]+)`/.exec(err || "");
4848
4877
  return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--danger)", wordBreak: "break-word" } }, m ? err.replace("`" + m[1] + "`", "") : err), m && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, m[1]), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy(m[1]) })), m && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)" } }, T.updManualHint || "Run it in a terminal, then click Restart to apply."));
4849
4878
  })(), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 } }, (phase === "idle" || phase === "error" || phase === "lan-done" || phase === "lan-norestart") && btn(phase === "idle" ? T.updLater || "Later" : T.receivedClose || "Close", onClose), phase === "idle" && lanBehind && btn(T.updLanNow || "Update lan", doLanUpdate, !appBehind), phase === "idle" && appBehind && (info.mode === "global" || info.mode === "unknown") && btn(T.updNow || "Update now", doUpdate, true), phase === "idle" && appBehind && info.mode === "npx" && btn(T.updRestart || "Restart", doRestart, true), phase === "error" && btn(T.updRestart || "Restart", doRestart, true), phase === "done" && btn(T.updRestart || "Restart", doRestart, true))));
@@ -15,4 +15,8 @@ export declare class IdentityLockedError extends Error {
15
15
  * A stale file (holder gone) is taken over silently: that is a crash we already
16
16
  * survived, not a conflict.
17
17
  */
18
+ /** Where a holder publishes the port its local HTTP API is on. */
19
+ export declare function uiPortHintPath(dataDir: string): string;
20
+ /** Called by the server once it knows which port it actually bound. */
21
+ export declare function setUiPortHint(port: number, dataDir?: string): void;
18
22
  export declare function acquireIdentityLock(dataDir: string): () => void;
@@ -13,7 +13,7 @@
13
13
  // exclusion protocol whose other participant we cannot modify (decentlan is
14
14
  // sealed) and must not fork.
15
15
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
16
- import { resolve } from "node:path";
16
+ import { resolve, join } from "node:path";
17
17
  /** Same path decentlan uses: `<carrier dataDir>/daemon.pid`. */
18
18
  export function identityLockPath(dataDir) {
19
19
  return resolve(dataDir, "daemon.pid");
@@ -55,6 +55,27 @@ export class IdentityLockedError extends Error {
55
55
  * A stale file (holder gone) is taken over silently: that is a crash we already
56
56
  * survived, not a conflict.
57
57
  */
58
+ /** Where a holder publishes the port its local HTTP API is on. */
59
+ export function uiPortHintPath(dataDir) {
60
+ return join(dataDir, "holder.http-port");
61
+ }
62
+ let uiPortForHint;
63
+ /** Called by the server once it knows which port it actually bound. */
64
+ export function setUiPortHint(port, dataDir) {
65
+ uiPortForHint = port;
66
+ if (dataDir)
67
+ writeUiPortHint(dataDir);
68
+ }
69
+ function writeUiPortHint(dataDir) {
70
+ if (!uiPortForHint)
71
+ return;
72
+ try {
73
+ writeFileSync(uiPortHintPath(dataDir), String(uiPortForHint), "utf-8");
74
+ }
75
+ catch {
76
+ /* best effort — the lock itself is what matters */
77
+ }
78
+ }
58
79
  export function acquireIdentityLock(dataDir) {
59
80
  const holder = identityLockHolder(dataDir);
60
81
  if (holder !== null && holder !== process.pid)
@@ -65,11 +86,22 @@ export function acquireIdentityLock(dataDir) {
65
86
  mkdirSync(dataDir, { recursive: true });
66
87
  const file = identityLockPath(dataDir);
67
88
  writeFileSync(file, String(process.pid), "utf-8");
89
+ // Also leave a way to REACH us. The documented upgrade is
90
+ // `npm i -g @decentnetwork/lan@latest && agentnet restart`, and on a machine
91
+ // where beagle holds the identity there is no daemon for that second half to
92
+ // talk to — it failed, and the person was left holding a new package and a
93
+ // process still running the old one. `agentnet restart` can drive our own
94
+ // /api/update-restart instead, but only if it can find the port.
95
+ writeUiPortHint(dataDir);
68
96
  let released = false;
69
97
  return () => {
70
98
  if (released)
71
99
  return;
72
100
  released = true;
101
+ try {
102
+ unlinkSync(uiPortHintPath(dataDir));
103
+ }
104
+ catch { /* may not exist */ }
73
105
  // Only remove it if it is still OURS. A daemon may have taken over after we
74
106
  // released the peer during the LAN handoff, and deleting its lock would
75
107
  // silently re-open the door this whole module exists to keep shut.
package/dist/server.js CHANGED
@@ -336,13 +336,84 @@ async function npmGlobalInstall(pkg) {
336
336
  const tail = r.out.slice(-2000);
337
337
  if (r.code === 0)
338
338
  return { ok: true, log: tail };
339
+ // Do NOT tell people to re-run it with sudo. `sudo npm i -g` succeeds and
340
+ // leaves the package directory owned by root, so the NEXT unprivileged
341
+ // update fails exactly the same way — the advice was the thing keeping
342
+ // them in the loop, and it did, twice in one evening. POSIX needs write
343
+ // permission on a directory to rename it (the `..` entry is updated), so
344
+ // it is ownership of the package dir itself that has to change, once.
339
345
  const hint = /EACCES|permission denied/i.test(r.out)
340
- ? ` — no write access to the global npm dir; run \`sudo npm i -g ${pkg}@latest\` manually`
346
+ ? ` — ${await globalNpmOwnershipHint(pkg)}`
341
347
  : "";
342
348
  return { ok: false, error: `npm exited ${r.code}${hint}`, log: tail };
343
349
  }
344
350
  return { ok: false, error: `npm not found — update manually: npm i -g ${pkg}@latest` };
345
351
  }
352
+ /**
353
+ * Whether this process could write the global npm scope directory.
354
+ *
355
+ * Checked up front so the update card can say "you need to run this once"
356
+ * rather than offering a button that fails. The check is a real access() on
357
+ * the directory npm would have to rename, not a guess from the uid.
358
+ */
359
+ async function globalNpmWritable() {
360
+ try {
361
+ const prefix = await npmGlobalPrefix();
362
+ if (!prefix)
363
+ return { ok: true }; // cannot tell — do not cry wolf
364
+ const scope = join(prefix, "lib", "node_modules", "@decentnetwork");
365
+ if (!existsSync(scope))
366
+ return { ok: true }; // nothing installed there yet
367
+ const { access } = await import("node:fs/promises");
368
+ const { constants } = await import("node:fs");
369
+ await access(scope, constants.W_OK);
370
+ return { ok: true };
371
+ }
372
+ catch {
373
+ return { ok: false, reason: await globalNpmOwnershipHint(BEAGLE_PKG) };
374
+ }
375
+ }
376
+ /**
377
+ * The one-time fix for a global npm dir this user cannot write.
378
+ *
379
+ * Almost always the result of a first install done with `sudo npm i -g`: the
380
+ * scope directory ends up root-owned, and every later update — including this
381
+ * app's own — dies renaming it. Telling people to sudo again works once and
382
+ * rebuilds the trap. Hand them the command that ends it instead, with the real
383
+ * paths for THIS machine.
384
+ */
385
+ async function globalNpmOwnershipHint(pkg) {
386
+ const prefix = await npmGlobalPrefix();
387
+ if (!prefix) {
388
+ return `no write access to the global npm dir. \`sudo npm i -g ${pkg}@latest\` would work but leaves it root-owned, so the next update fails the same way — fix the ownership once instead.`;
389
+ }
390
+ const scope = join(prefix, "lib", "node_modules", "@decentnetwork");
391
+ const bins = [join(prefix, "bin", "agentnet"), join(prefix, "bin", "beagle")].join(" ");
392
+ return (`no write access to ${scope}. This is what a first install with sudo leaves behind, and ` +
393
+ `\`sudo npm i -g\` would rebuild it — run this ONCE and updates never need sudo again: ` +
394
+ `sudo chown -R $(id -u):$(id -g) ${scope} ${bins}`);
395
+ }
396
+ /** `npm prefix -g`, or undefined if npm cannot be reached. */
397
+ async function npmGlobalPrefix() {
398
+ const { spawn } = await import("node:child_process");
399
+ return new Promise((res) => {
400
+ try {
401
+ const ch = spawn("npm", ["prefix", "-g"], { shell: process.platform === "win32" });
402
+ let out = "";
403
+ const timer = setTimeout(() => ch.kill("SIGKILL"), 10_000);
404
+ ch.stdout?.on("data", (c) => (out += c));
405
+ ch.on("error", () => { clearTimeout(timer); res(undefined); });
406
+ ch.on("close", (code) => {
407
+ clearTimeout(timer);
408
+ const v = out.trim().split("\n").pop()?.trim();
409
+ res(code === 0 && v ? v : undefined);
410
+ });
411
+ }
412
+ catch {
413
+ res(undefined);
414
+ }
415
+ });
416
+ }
346
417
  /** Ask the running agentnet daemon to re-exec itself so it picks up the
347
418
  * just-installed lan version. Tries the PATH binary, then the global
348
419
  * install's CLI entry directly (pm2 PATH often lacks the nvm shims). */
@@ -1612,10 +1683,19 @@ export function startBeagleServer(opts) {
1612
1683
  latest,
1613
1684
  behind: !!(current && latest && verLt(current, latest)),
1614
1685
  });
1686
+ // Can this process actually perform an update, or will it hit EACCES?
1687
+ // Worth knowing BEFORE the person clicks: the card used to offer an
1688
+ // update, fail on permissions, and hand back a sudo command that fixes
1689
+ // it once and rebuilds the problem for next time.
1690
+ const writable = await globalNpmWritable();
1615
1691
  const body = {
1616
1692
  ok: true,
1617
1693
  checkedAt: Date.now(),
1618
1694
  mode: installMode(),
1695
+ // false => the button cannot work; the UI shows the one-time fix
1696
+ // instead of an action that is going to fail.
1697
+ selfUpdatable: writable.ok,
1698
+ selfUpdateBlocked: writable.ok ? undefined : writable.reason,
1619
1699
  // WHO owns the peer version. With the daemon backend it is the
1620
1700
  // agentnet/lan process, not this app — so "update beagle" cannot
1621
1701
  // move that row, and telling someone to rebuild beagle (or pull the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
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",
@@ -34,7 +34,7 @@
34
34
  "yargs": "^17.7.2"
35
35
  },
36
36
  "devDependencies": {
37
- "@decentnetwork/beagle-ui": "^0.2.4",
37
+ "@decentnetwork/beagle-ui": "^0.2.5",
38
38
  "@types/js-yaml": "^4.0.9",
39
39
  "@types/node": "^20.11.0",
40
40
  "@types/yargs": "^17.0.32",