@decentnetwork/beagle 0.1.90 → 0.1.92

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
@@ -8,7 +8,8 @@ import { existsSync, readFileSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { dirname, join, resolve } from "node:path";
10
10
  import { homedir } from "node:os";
11
- import { startBeagleServer } from "./server.js";
11
+ import { startBeagleServer, installMode } from "./server.js";
12
+ import { isSupervised, relaunchSelf, waitForPredecessor } from "./relaunch.js";
12
13
  import { openPeerHost, defaultConfigDir, decentlanCarrierDir, makeDaemonHost } from "./peer-host.js";
13
14
  import { SwitchablePeerHost } from "./lan-handoff.js";
14
15
  import { loadNodeConfig, saveAutoAccept } from "./node-config.js";
@@ -97,6 +98,9 @@ const resolveVer = (pkg) => {
97
98
  };
98
99
  async function main() {
99
100
  const args = parseArgs(process.argv.slice(2));
101
+ // Spawned by a predecessor restarting itself after an update: let it finish
102
+ // exiting before touching the identity lock or the port. No-op otherwise.
103
+ await waitForPredecessor();
100
104
  // Bootstrap list: prefer the user's decentlan config so both stacks agree on
101
105
  // the fleet, else fall back to the shipped defaults. A wrong list here is the
102
106
  // difference between "joins the network" and "silently alone".
@@ -189,6 +193,19 @@ async function main() {
189
193
  },
190
194
  listenHost: args.host,
191
195
  listenPort: args.port,
196
+ // After an update: stop the peer cleanly, then either let the supervisor
197
+ // relaunch us or, with none, spawn our own successor. The successor waits
198
+ // for this pid to be gone (waitForPredecessor above), so the identity
199
+ // lock and the port are free by the time it needs them.
200
+ onRestart: async () => {
201
+ await Promise.race([
202
+ peerHost.stop().catch(() => undefined),
203
+ new Promise((r) => setTimeout(r, 5000)),
204
+ ]);
205
+ if (!isSupervised())
206
+ relaunchSelf(installMode());
207
+ process.exit(0);
208
+ },
192
209
  });
193
210
  const shutdown = async () => {
194
211
  await peerHost.stop().catch(() => undefined);
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.90";
1
+ window.__DK_UI_VERSION="0.1.92";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -2443,7 +2443,7 @@ ${peer.address}`
2443
2443
  const register = () => {
2444
2444
  const l = label.trim();
2445
2445
  if (l)
2446
- post("/api/ens-register", { name: l, displayName: me.name });
2446
+ post("/api/ens-register", { name: l, displayName: me.name, description: me.description || "" });
2447
2447
  };
2448
2448
  const bind = (chain, address) => post("/api/ens-bind-wallet", { chain, address });
2449
2449
  const bindEth = async () => {
@@ -4755,7 +4755,7 @@ ${peer.address}`
4755
4755
  updRestart: "Restart",
4756
4756
  updBusy: "Updating\u2026 (about a minute)",
4757
4757
  updDone: "Updated \u2014 restart to apply.",
4758
- updRestarting: "Restarting\u2026 this page reloads when it\u2019s back. (If beagle was started by hand in a terminal, run it again.)",
4758
+ updRestarting: "Restarting\u2026 this page reloads when it\u2019s back. (If it has not come back after a minute, run beagle again.)",
4759
4759
  updNpx: "Running via npx \u2014 restarting picks up the latest version automatically.",
4760
4760
  updDev: "Running from a source checkout \u2014 update with:",
4761
4761
  updPeerDaemon: "The peer version belongs to the running agentnet daemon, not to this app. Update it with:",
@@ -4937,7 +4937,7 @@ ${peer.address}`
4937
4937
  updRestart: "\u91CD\u542F",
4938
4938
  updBusy: "\u66F4\u65B0\u4E2D\u2026(\u7EA6\u4E00\u5206\u949F)",
4939
4939
  updDone: "\u66F4\u65B0\u5B8C\u6210\u2014\u2014\u91CD\u542F\u540E\u751F\u6548\u3002",
4940
- 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)",
4940
+ updRestarting: "\u6B63\u5728\u91CD\u542F\u2026\u5B8C\u6210\u540E\u672C\u9875\u4F1A\u81EA\u52A8\u5237\u65B0\u3002(\u5982\u679C\u4E00\u5206\u949F\u540E\u8FD8\u6CA1\u56DE\u6765,\u8BF7\u91CD\u65B0\u8FD0\u884C\u4E00\u6B21 beagle\u3002)",
4941
4941
  updNpx: "\u4F60\u662F\u7528 npx \u8FD0\u884C\u7684\u2014\u2014\u91CD\u542F\u5373\u81EA\u52A8\u83B7\u53D6\u6700\u65B0\u7248\u3002",
4942
4942
  updDev: "\u5F53\u524D\u4ECE\u6E90\u7801\u76EE\u5F55\u8FD0\u884C,\u8BF7\u624B\u52A8\u66F4\u65B0:",
4943
4943
  updPeerDaemon: "peer \u7684\u7248\u672C\u5C5E\u4E8E\u6B63\u5728\u8FD0\u884C\u7684 agentnet daemon,\u4E0D\u5C5E\u4E8E\u672C\u5E94\u7528\u3002\u8BF7\u7528\u8FD9\u6761\u547D\u4EE4\u66F4\u65B0:",
@@ -0,0 +1,8 @@
1
+ /** Something outside this process will restart it when it exits. */
2
+ export declare function isSupervised(): boolean;
3
+ /** Start the successor. Caller exits right after. */
4
+ export declare function relaunchSelf(mode: "global" | "npx" | "dev" | "unknown"): void;
5
+ /** In the successor: block until the process that spawned us has exited.
6
+ * No-op when we were not spawned by relaunchSelf. Gives up after 20s rather
7
+ * than hang forever behind a predecessor that will not die. */
8
+ export declare function waitForPredecessor(): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // relaunch.ts — bring Beagle back after a self-update when nobody else will.
2
+ //
3
+ // "Restart" after an update used to be process.exit(0) and a hope: pm2,
4
+ // launchd and systemd relaunch a process that exits, so under any of them
5
+ // that was a restart. A `beagle` started by hand in a terminal has no such
6
+ // minder — it exited and stayed exited, the page sat on "Restarting…"
7
+ // forever, and the person had to notice and run it again (air-wli,
8
+ // 2026-09-12). The dialog even admitted as much in parentheses.
9
+ //
10
+ // When no supervisor is detected, spawn our own successor before exiting:
11
+ // the same node, the same script path (which after `npm i -g` now holds the
12
+ // new version), the same arguments, detached so our exit does not take it
13
+ // down. The successor is told our pid and waits for us to be gone before it
14
+ // opens the peer or binds the port, so it never races us for the identity
15
+ // lock or gets EADDRINUSE from a parent still shutting down.
16
+ import { spawn } from "node:child_process";
17
+ const WAIT_ENV = "BEAGLE_RELAUNCH_WAIT_PID";
18
+ /** Set on a successor we spawned, and inherited by its successors in turn.
19
+ * A detached child is reparented to pid 1, which looks exactly like a
20
+ * launchd job — without this mark the SECOND update would have exited
21
+ * and stayed exited, which is the bug this file exists to fix. */
22
+ const SELF_ENV = "BEAGLE_SELF_RELAUNCHED";
23
+ /** Something outside this process will restart it when it exits. */
24
+ export function isSupervised() {
25
+ const env = process.env;
26
+ if (env[SELF_ENV])
27
+ return false; // we are our own minder
28
+ if (env.pm_id !== undefined)
29
+ return true; // pm2
30
+ if (env.INVOCATION_ID || env.JOURNAL_STREAM)
31
+ return true; // systemd
32
+ if (process.pid === 1)
33
+ return true; // a container with a restart policy
34
+ // launchd is the parent of every job it runs. A hand-started process in a
35
+ // terminal has the shell as its parent; only when that shell is gone does
36
+ // it get reparented to 1 — and then nobody was restarting it anyway, but a
37
+ // process that outlived its terminal is not one a person is watching, so
38
+ // exiting quietly is the lesser surprise.
39
+ if (process.platform === "darwin" && process.ppid === 1)
40
+ return true;
41
+ return false;
42
+ }
43
+ /** Start the successor. Caller exits right after. */
44
+ export function relaunchSelf(mode) {
45
+ const args = process.argv.slice(2);
46
+ let cmd;
47
+ let cmdArgs;
48
+ if (mode === "npx") {
49
+ // argv[1] points into npx's cache of the OLD version. `@latest`
50
+ // re-resolves, which is the whole update for an npx user.
51
+ cmd = process.platform === "win32" ? "npx.cmd" : "npx";
52
+ cmdArgs = ["-y", "@decentnetwork/beagle@latest", ...args];
53
+ }
54
+ else {
55
+ cmd = process.execPath;
56
+ cmdArgs = [process.argv[1], ...args];
57
+ }
58
+ const child = spawn(cmd, cmdArgs, {
59
+ detached: true,
60
+ // Keep the terminal this was started from as the log, exactly as before.
61
+ stdio: "inherit",
62
+ env: { ...process.env, [WAIT_ENV]: String(process.pid), [SELF_ENV]: "1" },
63
+ shell: process.platform === "win32" && mode === "npx",
64
+ });
65
+ child.unref();
66
+ }
67
+ /** In the successor: block until the process that spawned us has exited.
68
+ * No-op when we were not spawned by relaunchSelf. Gives up after 20s rather
69
+ * than hang forever behind a predecessor that will not die. */
70
+ export async function waitForPredecessor() {
71
+ const pid = Number(process.env[WAIT_ENV]);
72
+ delete process.env[WAIT_ENV];
73
+ if (!Number.isInteger(pid) || pid <= 0)
74
+ return;
75
+ const deadline = Date.now() + 20_000;
76
+ while (Date.now() < deadline) {
77
+ try {
78
+ process.kill(pid, 0);
79
+ }
80
+ catch {
81
+ return; // ESRCH: gone. (EPERM cannot happen — same user, our own parent.)
82
+ }
83
+ await new Promise((r) => setTimeout(r, 100));
84
+ }
85
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { IpcRequest, IpcResponse } from "./ipc.js";
2
+ /** How this copy of beagle got onto disk — decides what "update" can do.
3
+ * npx cache paths contain _npx AND node_modules, so test _npx first. */
4
+ export declare function installMode(): "global" | "npx" | "dev" | "unknown";
2
5
  export interface BeagleServerOptions {
3
6
  /** Call the running daemon over IPC (injected so this module stays
4
7
  * decoupled from the CLI's ipc client). */
@@ -35,6 +38,10 @@ export interface BeagleServerOptions {
35
38
  }>;
36
39
  cancelArm: () => Promise<string>;
37
40
  };
41
+ /** Called instead of process.exit(0) on /api/update-restart, so the
42
+ * process that owns the peer can shut it down and relaunch itself when
43
+ * no supervisor will (see relaunch.ts). */
44
+ onRestart?: () => void | Promise<void>;
38
45
  listenHost?: string;
39
46
  listenPort?: number;
40
47
  log?: (msg: string) => void;
package/dist/server.js CHANGED
@@ -22,6 +22,7 @@ import yaml from "js-yaml";
22
22
  import { DEFAULT_EXITS } from "./exits.js";
23
23
  import * as peerAddr from "@decentnetwork/peer";
24
24
  import { bridgeRoster } from "./bridge-roster.js";
25
+ import { isSupervised } from "./relaunch.js";
25
26
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
26
27
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
27
28
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -74,6 +75,29 @@ async function ensFetchJson(path) {
74
75
  clearTimeout(timer);
75
76
  }
76
77
  }
78
+ /** The record beagle writes for its own name: what the profile page shows
79
+ * (displayName, description) on top of whatever the record already held —
80
+ * wallet binds, socials, avatar, referee — so a profile edit never strips a
81
+ * bind. Shared by /api/ens-register and /api/set-profile; the two used to
82
+ * build it separately, and only register did, which is why a status message
83
+ * edited in Profile never reached https://app.beagle.chat/<name>. */
84
+ function ensProfileRecord(identity, name, carried, displayName, description) {
85
+ return {
86
+ name,
87
+ owner: identity.userid,
88
+ addresses: carried?.addresses ?? {},
89
+ texts: {
90
+ ...(carried?.texts ?? {}),
91
+ description,
92
+ displayName,
93
+ carrierAddress: identity.address,
94
+ carrierUserId: identity.userid,
95
+ },
96
+ referee: carried?.referee ?? "",
97
+ nft: carried?.nft ?? "",
98
+ nftid: carried?.nftid ?? 0,
99
+ };
100
+ }
77
101
  /** Sign a name record with the Carrier identity and POST it to the gateway.
78
102
  * The record itself (as a JSON string) is what gets signed; the gateway
79
103
  * re-parses `signature.message` and verifies against `owner` inside it. */
@@ -268,7 +292,7 @@ async function runningVersions(call) {
268
292
  }
269
293
  /** How this copy of beagle got onto disk — decides what "update" can do.
270
294
  * npx cache paths contain _npx AND node_modules, so test _npx first. */
271
- function installMode() {
295
+ export function installMode() {
272
296
  if (BEAGLE_ROOT.includes("_npx"))
273
297
  return "npx";
274
298
  if (BEAGLE_ROOT.includes(join("node_modules", "@decentnetwork", "beagle")))
@@ -1218,24 +1242,15 @@ export function startBeagleServer(opts) {
1218
1242
  const mine = existing ?? (await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`));
1219
1243
  const carried = mine && mine.owner === identity.userid ? mine : null;
1220
1244
  const displayName = (typeof body.displayName === "string" && body.displayName.trim().slice(0, 64)) || label;
1245
+ // The description IS the Profile status message. It used to fall
1246
+ // through to "<name>'s Beagle Chat Profile", a placeholder that then
1247
+ // sat on the public page as if the person had written it.
1248
+ const node = (diag.ok ? diag.data?.node : null) ?? {};
1221
1249
  const description = (typeof body.description === "string" && body.description.trim().slice(0, 200)) ||
1250
+ (typeof node.statusMessage === "string" && node.statusMessage.trim().slice(0, 200)) ||
1222
1251
  carried?.texts?.description ||
1223
- `${displayName}'s Beagle Chat Profile`;
1224
- const r = await ensSignAndSet(opts.call, {
1225
- name: fullName,
1226
- owner: identity.userid,
1227
- addresses: carried?.addresses ?? {},
1228
- texts: {
1229
- ...(carried?.texts ?? {}),
1230
- description,
1231
- displayName,
1232
- carrierAddress: identity.address,
1233
- carrierUserId: identity.userid,
1234
- },
1235
- referee: carried?.referee ?? "",
1236
- nft: carried?.nft ?? "",
1237
- nftid: carried?.nftid ?? 0,
1238
- });
1252
+ "";
1253
+ const r = await ensSignAndSet(opts.call, ensProfileRecord({ userid: identity.userid, address: identity.address }, fullName, carried, displayName, description));
1239
1254
  sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, name: fullName } : { ok: false, error: r.error });
1240
1255
  return;
1241
1256
  }
@@ -1796,10 +1811,17 @@ export function startBeagleServer(opts) {
1796
1811
  sendJson(res, 403, { ok: false, error: "restart only from this machine (localhost)" });
1797
1812
  return;
1798
1813
  }
1799
- sendJson(res, 200, { ok: true });
1800
- // Let the response flush, then exit 0: pm2/launchd/systemd restart the
1801
- // process; a bare `beagle` in a terminal just exits (the UI says so).
1802
- setTimeout(() => process.exit(0), 400);
1814
+ sendJson(res, 200, { ok: true, supervised: isSupervised() });
1815
+ // Let the response flush, then hand over. Under pm2/launchd/systemd an
1816
+ // exit IS the restart; a bare `beagle` in a terminal relaunches itself
1817
+ // (cli.ts onRestart) — it used to just exit and stay exited.
1818
+ setTimeout(() => {
1819
+ if (!opts.onRestart) {
1820
+ process.exit(0);
1821
+ return;
1822
+ }
1823
+ void Promise.resolve(opts.onRestart()).catch(() => process.exit(0));
1824
+ }, 400);
1803
1825
  return;
1804
1826
  }
1805
1827
  // Public profile card for any userid (friend or directory row): what
@@ -2249,6 +2271,31 @@ export function startBeagleServer(opts) {
2249
2271
  const { name, description, punkId } = await readBody(req);
2250
2272
  const r = await opts.call({ op: "set-profile", name, description, punkId });
2251
2273
  sendJson(res, r.ok ? 200 : 400, r);
2274
+ // Also the public record, when this identity registered a beagles.eth
2275
+ // name here: displayName and description on the record ARE the
2276
+ // profile name and status message, and editing them in Profile used
2277
+ // to change only what Carrier advertises, never the page. Best
2278
+ // effort, after the reply: the gateway being down must not turn a
2279
+ // profile save into a failure. A punk-only call (the avatar advertise
2280
+ // on poll) carries neither field and leaves the record alone.
2281
+ if (r.ok && (typeof name === "string" || typeof description === "string")) {
2282
+ void (async () => {
2283
+ const diag = await opts.call({ op: "diag" });
2284
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
2285
+ if (!identity.userid || !identity.address)
2286
+ return;
2287
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
2288
+ if (!mine || mine.owner !== identity.userid)
2289
+ return; // not registered here — nothing to sign
2290
+ const displayName = (typeof name === "string" && name.trim().slice(0, 64)) ||
2291
+ mine.texts?.displayName ||
2292
+ mine.name.replace(/\.beagles\.eth$/, "");
2293
+ const desc = typeof description === "string" ? description.trim().slice(0, 200) : (mine.texts?.description ?? "");
2294
+ const w = await ensSignAndSet(opts.call, ensProfileRecord({ userid: identity.userid, address: identity.address }, mine.name, mine, displayName, desc));
2295
+ if (!w.ok)
2296
+ console.warn(`[ens] profile saved, but the ${mine.name} record was not updated: ${w.error}`);
2297
+ })().catch((e) => console.warn(`[ens] record update after profile save failed: ${e.message}`));
2298
+ }
2252
2299
  return;
2253
2300
  }
2254
2301
  if (req.method === "GET" && url === "/api/routes") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
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",