@decentnetwork/beagle 0.1.0

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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Beagle
2
+
3
+ P2P chat, file transfer and calls over the [Decent Network](https://github.com/0xli/decentlan).
4
+ No account, no server, no admin privilege — your identity is a keypair on your
5
+ own machine, and messages go peer-to-peer.
6
+
7
+ Beagle is the **application**. The protocol lives in `@decentnetwork/peer` (SDK)
8
+ and the virtual-LAN layer in `@decentnetwork/lan` (decentlan, a developer and
9
+ operator tool). Beagle iterates fast; those stay boring on purpose.
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ npm install
15
+ npm run build
16
+ node dist/cli.js
17
+ ```
18
+
19
+ Then open <http://localhost:8766>.
20
+
21
+ ```
22
+ --port <n> HTTP port (default 8766)
23
+ --host <addr> bind address (default 127.0.0.1)
24
+ --config-dir <p> identity/config dir (default ~/.agentnet)
25
+ --dora-dir <p> dora roster dir, if this machine runs a dora
26
+ ```
27
+
28
+ Port 8766 rather than decentlan's 8765, so during the transition the old
29
+ `agentnet ui` and Beagle can run side by side on one machine.
30
+
31
+ ## Status: Phase 1
32
+
33
+ Beagle picks a **backend** for its Carrier identity. Exactly one is ever live,
34
+ because one identity must never run two Carrier peers at once — two peers on the
35
+ same keypair race on session establishment and corrupt each other's state.
36
+
37
+ | Backend | Status | Needs root | Virtual LAN |
38
+ |---|---|---|---|
39
+ | `daemon` — talks to a running decentlan daemon over IPC | **shipping** | no (the daemon does) | yes |
40
+ | `embedded` — Beagle opens its own Peer in-process | Phase 2 | **no** | no |
41
+
42
+ Today Beagle requires a decentlan daemon. Phase 2 adds the embedded backend, at
43
+ which point Beagle runs standalone at ordinary user privilege and decentlan
44
+ becomes the optional "I also want a virtual LAN" add-on.
45
+
46
+ Identity is read from `~/.agentnet` by default, so a machine already running
47
+ decentlan keeps the same userid and the same friends — it is the same node,
48
+ not a new one.
49
+
50
+ ## Layout
51
+
52
+ ```
53
+ src/
54
+ ├── cli.ts # the `beagle` binary — argument parsing, startup
55
+ ├── peer-host.ts # backend abstraction + selection (the split's hinge)
56
+ ├── ipc.ts # decentlan daemon wire protocol, client side
57
+ ├── server.ts # local HTTP server: /api/* + serves the UI bundle
58
+ ├── exits.ts # official exit table (network panel only)
59
+ └── desktop/ # the UI itself (JSX, concatenated + esbuild-transpiled)
60
+ ```
61
+
62
+ `scripts/build-ui.mjs` bundles `src/desktop/*.jsx` into `dist/desktop/app.js`.
63
+ The UI runs as one concatenated script sharing global scope with React (UMD) —
64
+ no CDN, no in-browser Babel.
@@ -0,0 +1,50 @@
1
+ # Carrier bootstrap fleet, mirrored from decentlan's shipped defaults
2
+ # (US-verified hosts first — the SDK dials the first 3 in order, so
3
+ # ordering decides which relays the whole fleet meets on).
4
+ # Refresh from https://beagle.chat/assets/bgservers.json
5
+ bootstrapNodes:
6
+ - host: 13.58.208.50
7
+ port: 33445
8
+ pk: 89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh
9
+ - host: 144.202.113.167
10
+ port: 33445
11
+ pk: EfT4YMq6qfHdDsCiBCgsEmA78E2NxVYVKVUS9bD6w9GH
12
+ - host: 149.28.98.141
13
+ port: 33445
14
+ pk: 81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7
15
+ - host: 18.216.6.197
16
+ port: 33445
17
+ pk: H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2
18
+ - host: 52.57.248.163
19
+ port: 33445
20
+ pk: CfJLve8FNQPQJ9xYQ8oEVkPxeAPCN7iSdhnYFkWmWgLn
21
+ - host: 35.179.41.220
22
+ port: 33445
23
+ pk: 6u2vKadPa9wqDf531QaZy7FJN3c7Wzntm7dKXxXqvyNB
24
+ - host: 52.63.19.190
25
+ port: 33445
26
+ pk: EeNenbyS4sx3qtu82esT1V1NMe9dZib5LyQmYGM6fboK
27
+ - host: 47.100.103.201
28
+ port: 33445
29
+ pk: CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd
30
+ - host: 139.129.193.117
31
+ port: 33445
32
+ pk: 67UQhssARwMky1YgFA2oDGZ1RiQYQmS6JqWFHxtopKq5
33
+ - host: 18.216.102.47
34
+ port: 33445
35
+ pk: G5z8MqiNDFTadFUPfMdYsYtkUDbX5mNCMVHMZtsCnFeb
36
+ - host: 54.193.141.205
37
+ port: 33445
38
+ pk: 7TfZWZNV8vnBxxWzJXuvKgX2QyKkLpg2oXx3LQ5tg8LW
39
+ - host: 154.64.235.176
40
+ port: 33445
41
+ pk: GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL
42
+ - host: 52.74.215.181
43
+ port: 33445
44
+ pk: Xv6d34WaUw9bPn7YihzVAFw7D2igbQJZ3jwmzzfYVFV
45
+ - host: 52.83.171.135
46
+ port: 443
47
+ pk: 5tuHgK1Q4CYf4K5PutsEPK5E3Z7cbtEBdx7LwmdzqXHL
48
+ - host: 52.83.191.228
49
+ port: 33445
50
+ pk: 3khtxZo89SBScAMaHhTvD68pPHiKxgZT6hTCSZZVgNEm
@@ -0,0 +1,49 @@
1
+ # Official exit / infrastructure nodes shipped with @decentnetwork/lan.
2
+ #
3
+ # A fresh install auto-friends ONLY the dora registries (config/default-doras.yaml)
4
+ # and the exits listed here — NOT every machine that happens to be in the dora
5
+ # roster. This keeps a new client's friend list to infrastructure (doras + exits)
6
+ # instead of filling up with other people's personal/compute boxes.
7
+ #
8
+ # Each exit carries a `region`, so `agentnet proxy router` (with no routes.yaml)
9
+ # auto-builds the routing table: China sites → china exits, Japan/Binance →
10
+ # japan exit, Google/YouTube/GitHub/etc → us exits, everything else direct.
11
+ #
12
+ # Mechanism: these userids (plus the dora userids) become the default
13
+ # `dora.autoFriend` whitelist for a freshly-initialised config. The client
14
+ # still LEARNS every roster entry into IPAM (so names/IPs resolve), but only
15
+ # proactively friends infrastructure. Exits still serve arbitrary clients
16
+ # because the daemon auto-ACCEPTS incoming friend-requests — so hub-and-spoke
17
+ # works without meshing every personal machine.
18
+ #
19
+ # To run a full mesh instead (friend everyone in the roster):
20
+ # agentnet dora autofriend all
21
+ # To add/replace an exit: edit this file (data, not code) and republish, or
22
+ # locally: agentnet dora autofriend allow <name|userid> ...
23
+ #
24
+ # `region` is one of: china | japan | us (matches the router's built-in
25
+ # domain lists; see BUILTIN_REGION_DOMAINS in src/proxy/multi-exit-router.ts).
26
+ exits:
27
+ # --- China (China-only / GFW-blocked-from-outside sites) ---
28
+ - name: cn
29
+ userid: 5Aj6uQMd1cNRb9cGT4AwHCN4RdVZjfaGLtgKGhdP1LzN
30
+ virtual_ip: 10.86.1.15
31
+ region: china
32
+ - name: sh
33
+ userid: 6D1PLSVqbSpcxnDMAd8ZXLWycYveeP2hU4g3igDQEqA7
34
+ virtual_ip: 10.86.1.16
35
+ region: china
36
+ - name: callpass
37
+ userid: DZN2L9RV1YkHjqGHMTA6juZhskKA65AD2RyPt4umZVc7
38
+ virtual_ip: 10.86.1.17
39
+ region: china
40
+ # --- Japan (host "lico", dora name node-91) — Binance routes here ---
41
+ - name: tokyo
42
+ userid: 4uNnLnAQHVJ7td657MDaz4UnEuhnTW9sRAPSVeAEEQe4
43
+ virtual_ip: 10.86.68.90
44
+ region: japan
45
+ # --- US (Google, YouTube, GitHub, X/Twitter, LinkedIn, OpenAI) ---
46
+ - name: gojipower
47
+ userid: 2wErj1XreXt1UchE3FGhuvkZ4GoBpo8JGMn8X49nm2ec
48
+ virtual_ip: 10.86.166.16
49
+ region: us
@@ -0,0 +1,108 @@
1
+ import { EventEmitter } from "node:events";
2
+ export interface InlineFileEvent {
3
+ pubkey: string;
4
+ name: string;
5
+ fileType?: string;
6
+ data: Uint8Array;
7
+ via: "online" | "offline";
8
+ }
9
+ export interface InviteEvent {
10
+ pubkey: string;
11
+ ext?: string;
12
+ bundle?: string;
13
+ data: Uint8Array;
14
+ }
15
+ export interface FileOffer {
16
+ friendId: string;
17
+ fileNumber: number;
18
+ fileId: string;
19
+ name: string;
20
+ size: number;
21
+ kind: number;
22
+ }
23
+ export interface FileProgress {
24
+ friendId: string;
25
+ fileId: string;
26
+ received: number;
27
+ total: number;
28
+ /** True when this is OUR outgoing transfer, false/absent when receiving. */
29
+ sending?: boolean;
30
+ }
31
+ export interface FileComplete {
32
+ friendId: string;
33
+ fileId: string;
34
+ name: string;
35
+ size: number;
36
+ /** Present on the receiving side only. */
37
+ data?: Uint8Array;
38
+ sending?: boolean;
39
+ }
40
+ export interface FileCancel {
41
+ friendId: string;
42
+ fileId: string;
43
+ sending: boolean;
44
+ }
45
+ export interface CarrierNodeOptions {
46
+ keyFile: string;
47
+ bootstrapNodes: {
48
+ host: string;
49
+ port: number;
50
+ pk: string;
51
+ }[];
52
+ expressNodes?: {
53
+ host: string;
54
+ port: number;
55
+ pk: string;
56
+ tls?: boolean;
57
+ }[];
58
+ nickname?: string;
59
+ statusMessage?: string;
60
+ fileResumeDir?: string;
61
+ }
62
+ export interface FriendView {
63
+ pubkey: string;
64
+ carrierId: string;
65
+ name?: string;
66
+ status: "online" | "offline" | "requested";
67
+ address?: string;
68
+ acceptedAt?: number;
69
+ requestedAt?: number;
70
+ }
71
+ export declare class CarrierNode extends EventEmitter {
72
+ #private;
73
+ create(opts: CarrierNodeOptions): Promise<void>;
74
+ start(): Promise<void>;
75
+ /** Join the DHT and publish ourselves so peers can find us. Deliberately
76
+ * fault-tolerant: a transient bootstrap timeout must not stop the app from
77
+ * starting — friends on a relay path still reach us, and the retry loop
78
+ * keeps trying in the background. */
79
+ join(): Promise<void>;
80
+ identity(): {
81
+ pubkey: string;
82
+ userid: string;
83
+ address: string;
84
+ };
85
+ friends(): FriendView[];
86
+ isFriendOnline(userid: string): boolean;
87
+ sessionStatus(pubkey: string): unknown;
88
+ dhtHealth(): unknown;
89
+ sendText(userid: string, text: string): Promise<void>;
90
+ sendFriendRequest(address: string, hello?: string): Promise<void>;
91
+ acceptFriendRequest(pubkey: string): Promise<void>;
92
+ removeFriend(userid: string): boolean;
93
+ setUserInfo(info: {
94
+ name?: string;
95
+ description?: string;
96
+ }): void;
97
+ sign(message: Uint8Array): Uint8Array;
98
+ sendFile(userid: string, data: Uint8Array, name: string): string | null;
99
+ cancelSend(userid: string, fileId: string): boolean;
100
+ acceptFile(userid: string, fileNumber: number): void;
101
+ sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
102
+ sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
103
+ /** Native (iOS/Android/C) friends can't see toxcore file transfer; they only
104
+ * receive the inline FileModel envelope. decentlan detects this from the DHT
105
+ * key signature — an internal we don't have here, so beagle probes the
106
+ * capability and falls back rather than guessing wrong. */
107
+ stop(): Promise<void>;
108
+ }
@@ -0,0 +1,209 @@
1
+ // CarrierNode — a messaging-only wrapper around @decentnetwork/peer.
2
+ //
3
+ // This is the deliberate opposite of decentlan's PeerManager (785 lines): that
4
+ // one also owns packet sessions, frame codecs, the IP/dora/rate custom-packet
5
+ // channels and the TUN plumbing. Beagle needs none of it. What a chat app
6
+ // actually needs from Carrier is: an identity, a friend list, text, files,
7
+ // and the invite channel that carries call signaling.
8
+ //
9
+ // Everything here runs at ordinary user privilege. Nothing opens a TUN, so
10
+ // nothing needs root — which is the entire point of the embedded backend.
11
+ import { EventEmitter } from "node:events";
12
+ import { Logger } from "./logger.js";
13
+ export class CarrierNode extends EventEmitter {
14
+ #peer;
15
+ #logger = new Logger({ prefix: "Carrier" });
16
+ #identity;
17
+ async create(opts) {
18
+ const mod = (await import("@decentnetwork/peer"));
19
+ this.#peer = await mod.Peer.create({
20
+ keyFile: opts.keyFile,
21
+ // "legacy" is what every other node on this network speaks — a beagle
22
+ // node that negotiated anything else would simply be invisible to the
23
+ // iOS/Android apps and to decentlan.
24
+ compatibilityMode: "legacy",
25
+ bootstrapNodes: opts.bootstrapNodes,
26
+ expressNodes: opts.expressNodes,
27
+ nickname: opts.nickname,
28
+ statusMessage: opts.statusMessage,
29
+ fileResumeDir: opts.fileResumeDir,
30
+ // NOTE: no bulkDataPacketId. decentlan registers the IP channel (163) as
31
+ // the SDK's bulk stream; beagle has no IP channel, so the SDK keeps its
32
+ // default and file transfer is unaffected.
33
+ });
34
+ this.#wireEvents();
35
+ }
36
+ async start() {
37
+ const peer = this.#require();
38
+ await peer.start();
39
+ // Identity only exists after start().
40
+ this.#identity = { pubkey: peer.pubkey(), userid: peer.userid(), address: peer.address() };
41
+ this.#logger.info(`identity ${this.#identity.address}`);
42
+ }
43
+ /** Join the DHT and publish ourselves so peers can find us. Deliberately
44
+ * fault-tolerant: a transient bootstrap timeout must not stop the app from
45
+ * starting — friends on a relay path still reach us, and the retry loop
46
+ * keeps trying in the background. */
47
+ async join() {
48
+ const peer = this.#require();
49
+ try {
50
+ await peer.joinNetwork();
51
+ }
52
+ catch (error) {
53
+ this.#logger.warn(`joinNetwork failed (${error.message}) — retrying in background`);
54
+ void this.#retryJoin();
55
+ return;
56
+ }
57
+ void this.#announce();
58
+ }
59
+ async #retryJoin() {
60
+ const peer = this.#require();
61
+ for (let attempt = 1; attempt <= 60; attempt++) {
62
+ await new Promise((r) => setTimeout(r, 10_000));
63
+ try {
64
+ await peer.joinNetwork();
65
+ this.#logger.info(`joined after ${attempt} retries`);
66
+ void this.#announce();
67
+ return;
68
+ }
69
+ catch {
70
+ // keep trying
71
+ }
72
+ }
73
+ }
74
+ async #announce() {
75
+ const peer = this.#require();
76
+ for (let i = 0; i < 3; i++) {
77
+ try {
78
+ const stored = await peer.announceSelf(45_000);
79
+ if (stored?.length)
80
+ break;
81
+ }
82
+ catch {
83
+ // announce is best-effort; relay paths still work without it
84
+ }
85
+ }
86
+ // Stay findable: re-announce on the same cadence peerd uses.
87
+ setInterval(() => {
88
+ void peer.announceSelf(20_000).catch(() => undefined);
89
+ }, 120_000).unref?.();
90
+ }
91
+ #require() {
92
+ if (!this.#peer)
93
+ throw new Error("CarrierNode not created");
94
+ return this.#peer;
95
+ }
96
+ #wireEvents() {
97
+ const peer = this.#require();
98
+ peer.onText((m) => {
99
+ // Empty texts are session keepalives, not messages.
100
+ if (!m.text.length)
101
+ return;
102
+ this.emit("message", m.pubkey, m.text, m.via);
103
+ });
104
+ peer.onFriendRequest((r) => this.emit("friend-request", r));
105
+ peer.onFriendConnection?.((e) => this.emit("friend-connection", e));
106
+ peer.onInlineFile?.((f) => this.emit("inline-file", f));
107
+ peer.onInvite?.((evt) => {
108
+ // Only the "carrier" extension carries call signaling; other extensions
109
+ // belong to other apps and must not be handed to the call engine.
110
+ if (evt.ext !== undefined && evt.ext !== "carrier")
111
+ return;
112
+ this.emit("call-signal", { pubkey: evt.pubkey, data: evt.data });
113
+ });
114
+ // Forwarded verbatim — the embedded host keys off friendId/fileId/sending.
115
+ peer.onFile?.((o) => this.emit("file-offer", o));
116
+ peer.onFileProgress?.((p) => this.emit("file-progress", p));
117
+ peer.onFileComplete?.((p) => this.emit("file-complete", p));
118
+ peer.onFileCancel?.((p) => this.emit("file-cancel", p));
119
+ }
120
+ identity() {
121
+ if (!this.#identity)
122
+ throw new Error("CarrierNode not started");
123
+ return this.#identity;
124
+ }
125
+ friends() {
126
+ return this.#require().friends().map((f) => ({
127
+ pubkey: f.pubkey,
128
+ carrierId: f.userid || f.pubkey,
129
+ name: f.name,
130
+ // Keep "requested" distinct from "offline": it answers "did my friend
131
+ // request get through?", which collapsing would hide.
132
+ status: f.status === "online" ? "online" : f.status === "requested" ? "requested" : "offline",
133
+ address: f.address,
134
+ acceptedAt: f.acceptedAt,
135
+ requestedAt: f.requestedAt,
136
+ }));
137
+ }
138
+ isFriendOnline(userid) {
139
+ return this.friends().some((f) => f.carrierId === userid && f.status === "online");
140
+ }
141
+ sessionStatus(pubkey) {
142
+ try {
143
+ return this.#require().sessionStatus(pubkey);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ dhtHealth() {
150
+ try {
151
+ return this.#require().dhtHealth();
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ sendText(userid, text) {
158
+ return this.#require().sendText(userid, text);
159
+ }
160
+ sendFriendRequest(address, hello) {
161
+ return this.#require().sendFriendRequest(address, hello);
162
+ }
163
+ acceptFriendRequest(pubkey) {
164
+ return this.#require().acceptFriendRequest(pubkey);
165
+ }
166
+ removeFriend(userid) {
167
+ return this.#require().removeFriend(userid);
168
+ }
169
+ setUserInfo(info) {
170
+ this.#require().setUserInfo(info);
171
+ }
172
+ sign(message) {
173
+ return this.#require().sign(message);
174
+ }
175
+ sendFile(userid, data, name) {
176
+ return this.#require().sendFile(userid, data, { name });
177
+ }
178
+ cancelSend(userid, fileId) {
179
+ // isSending=true: we are the sender. Without it the SDK looks for an
180
+ // inbound transfer with that id and finds nothing.
181
+ return this.#require().cancelFileById?.(userid, fileId, true) ?? false;
182
+ }
183
+ acceptFile(userid, fileNumber) {
184
+ this.#require().acceptFile(userid, fileNumber);
185
+ }
186
+ async sendInlineFile(userid, data, name) {
187
+ const peer = this.#require();
188
+ if (!peer.sendInlineFile)
189
+ throw new Error("This peer SDK build has no inline-file support");
190
+ await peer.sendInlineFile(userid, { name, data });
191
+ }
192
+ async sendCallSignal(userid, data) {
193
+ const peer = this.#require();
194
+ if (!peer.sendInvite)
195
+ throw new Error("This peer SDK build has no invite channel (calls unavailable)");
196
+ // ext "carrier" is the channel iOS/Android Beagle listen on for calls.
197
+ await peer.sendInvite(userid, data, { ext: "carrier" });
198
+ }
199
+ /** Native (iOS/Android/C) friends can't see toxcore file transfer; they only
200
+ * receive the inline FileModel envelope. decentlan detects this from the DHT
201
+ * key signature — an internal we don't have here, so beagle probes the
202
+ * capability and falls back rather than guessing wrong. */
203
+ async stop() {
204
+ if (!this.#peer)
205
+ return;
206
+ await this.#peer.stop().catch(() => undefined);
207
+ this.#peer = undefined;
208
+ }
209
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // `beagle` — start the Beagle app: a local web UI for chat, files and calls.
3
+ //
4
+ // Deliberately NOT a subcommand tree. Beagle is a consumer app; the whole CLI
5
+ // is "run it". Diagnostics and network administration stay in decentlan's
6
+ // `agentnet` CLI, which is the developer/operator tool.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { startBeagleServer } from "./server.js";
12
+ import { openPeerHost, defaultConfigDir } from "./peer-host.js";
13
+ import { loadNodeConfig } from "./node-config.js";
14
+ function parseArgs(argv) {
15
+ const get = (flag) => {
16
+ const i = argv.indexOf(flag);
17
+ return i >= 0 ? argv[i + 1] : undefined;
18
+ };
19
+ if (argv.includes("--help") || argv.includes("-h")) {
20
+ console.log([
21
+ "beagle — P2P chat, files and calls over the Decent Network",
22
+ "",
23
+ "Usage: beagle [options]",
24
+ "",
25
+ " --port <n> HTTP port (default 8766)",
26
+ " --host <addr> bind address (default 127.0.0.1)",
27
+ " --config-dir <p> identity/config dir (default ~/.agentnet)",
28
+ " --dora-dir <p> dora roster dir, if this machine runs a dora",
29
+ " --backend <k> force 'daemon' or 'embedded' (default: auto)",
30
+ " -h, --help this text",
31
+ ].join("\n"));
32
+ process.exit(0);
33
+ }
34
+ return {
35
+ // 8766, not decentlan's 8765: during the transition both can run at once,
36
+ // so a user can compare old and new side by side without a port fight.
37
+ port: Number(get("--port") ?? process.env.BEAGLE_PORT ?? 8766),
38
+ host: get("--host") ?? "127.0.0.1",
39
+ configDir: get("--config-dir") ?? defaultConfigDir(),
40
+ doraDir: get("--dora-dir"),
41
+ backend: get("--backend"),
42
+ };
43
+ }
44
+ const readJsonVer = (file) => {
45
+ try {
46
+ return JSON.parse(readFileSync(file, "utf-8")).version ?? "";
47
+ }
48
+ catch {
49
+ return "";
50
+ }
51
+ };
52
+ async function main() {
53
+ const args = parseArgs(process.argv.slice(2));
54
+ // Bootstrap list: prefer the user's decentlan config so both stacks agree on
55
+ // the fleet, else fall back to the shipped defaults. A wrong list here is the
56
+ // difference between "joins the network" and "silently alone".
57
+ const { bootstrapNodes, expressNodes, nickname, statusMessage, autoAccept } = loadNodeConfig(args.configDir);
58
+ let peerHost;
59
+ let why = "";
60
+ try {
61
+ ({ host: peerHost, why } = await openPeerHost({
62
+ configDir: args.configDir,
63
+ bootstrapNodes,
64
+ expressNodes,
65
+ nickname,
66
+ statusMessage,
67
+ autoAcceptFriends: autoAccept,
68
+ force: args.backend,
69
+ }));
70
+ }
71
+ catch (error) {
72
+ console.error(error.message);
73
+ process.exit(1);
74
+ }
75
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
76
+ // Version lookup for the "my node" panel.
77
+ //
78
+ // NOT via createRequire().resolve(): peer and lan declare `exports` with an
79
+ // "import" condition only, so CJS resolution throws
80
+ // ERR_PACKAGE_PATH_NOT_EXPORTED for both the bare specifier and
81
+ // "<pkg>/package.json". (decentlan's own UI has the same call and has been
82
+ // silently showing an empty peer version because of it.) import.meta.resolve
83
+ // uses ESM resolution, which honours that condition.
84
+ const resolveVer = (pkg) => {
85
+ const manifestFrom = (start) => {
86
+ let dir = start;
87
+ for (let depth = 0; depth < 6; depth++) {
88
+ const manifest = join(dir, "package.json");
89
+ if (existsSync(manifest)) {
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(manifest, "utf-8"));
92
+ if (parsed.name === pkg)
93
+ return parsed.version ?? "";
94
+ }
95
+ catch {
96
+ // unreadable manifest — keep walking
97
+ }
98
+ }
99
+ const up = dirname(dir);
100
+ if (up === dir)
101
+ break;
102
+ dir = up;
103
+ }
104
+ return undefined;
105
+ };
106
+ try {
107
+ return manifestFrom(dirname(fileURLToPath(import.meta.resolve(pkg)))) ?? "";
108
+ }
109
+ catch {
110
+ // Fall back to a plain node_modules lookup beside this package, which
111
+ // also covers the case where the dependency is absent entirely.
112
+ return manifestFrom(join(moduleDir, "..", "node_modules", ...pkg.split("/"))) ?? "";
113
+ }
114
+ };
115
+ // If this machine also runs a dora, surface its allocation table.
116
+ const doraCandidates = args.doraDir
117
+ ? [resolve(args.doraDir, "roster.yaml")]
118
+ : [resolve(homedir(), ".dora-test", "roster.yaml"), resolve(homedir(), ".decent-registry", "roster.yaml")];
119
+ const doraRosterPath = doraCandidates.find((p) => existsSync(p));
120
+ console.log(`beagle ${readJsonVer(join(moduleDir, "..", "package.json"))} — backend: ${peerHost.kind} (${why})`);
121
+ // The "open <url>" line is printed by the server itself, once the socket is
122
+ // actually bound. Printing it here first meant a port clash left a bogus
123
+ // invitation on screen directly above the error explaining it was wrong.
124
+ startBeagleServer({
125
+ call: (r) => peerHost.call(r),
126
+ routesPath: resolve(args.configDir, "routes.yaml"),
127
+ doraRosterPath,
128
+ downloadsDir: resolve(args.configDir, "downloads"),
129
+ meExtra: {
130
+ // The panel reads "lan <x> · peer <y>". Report the real decentlan version
131
+ // powering this backend, not beagle's own — conflating them made the
132
+ // node look like it was running lan 0.1.0.
133
+ lanVer: resolveVer("@decentnetwork/lan") || "(none)",
134
+ peerVer: resolveVer("@decentnetwork/peer"),
135
+ wire: "163",
136
+ channel: "@next",
137
+ },
138
+ listenHost: args.host,
139
+ listenPort: args.port,
140
+ });
141
+ const shutdown = async () => {
142
+ await peerHost.stop().catch(() => undefined);
143
+ process.exit(0);
144
+ };
145
+ process.on("SIGINT", shutdown);
146
+ process.on("SIGTERM", shutdown);
147
+ await new Promise(() => { }); // run until signalled
148
+ }
149
+ main().catch((error) => {
150
+ console.error(error);
151
+ process.exit(1);
152
+ });