@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/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // Library surface. Beagle is normally run as a binary (`beagle`), but the
2
+ // server and the backend abstraction are exported so it can be embedded.
3
+ export { startBeagleServer } from "./server.js";
4
+ export { openPeerHost, defaultConfigDir, decentlanCarrierDir } from "./peer-host.js";
5
+ export { ipcCall, ipcSocketPath, daemonIsRunning } from "./ipc.js";
package/dist/ipc.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /** Ops the daemon answers. Every one is peer-level — none of them needs the
2
+ * TUN — which is what makes the beagle/decentlan split possible at all. */
3
+ export type IpcOp = "ping" | "diag" | "friend-request" | "friends-pending" | "friends-accept" | "friends-reject" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "sign" | "chat-send" | "chat-history" | "chat-log-local" | "chat-mark-read" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "file-log-local" | "call-signal" | "call-poll";
4
+ export interface IpcRequest {
5
+ op: IpcOp;
6
+ address?: string;
7
+ hello?: string;
8
+ userid?: string;
9
+ text?: string;
10
+ alias?: string;
11
+ name?: string;
12
+ description?: string;
13
+ path?: string;
14
+ ids?: string[];
15
+ ts?: number;
16
+ since?: number;
17
+ limit?: number;
18
+ enabled?: boolean;
19
+ origin?: string;
20
+ nonce?: string;
21
+ signal?: unknown;
22
+ [key: string]: unknown;
23
+ }
24
+ export interface IpcResponseOk {
25
+ ok: true;
26
+ data?: Record<string, unknown>;
27
+ }
28
+ export interface IpcResponseErr {
29
+ ok: false;
30
+ error: string;
31
+ }
32
+ export type IpcResponse = IpcResponseOk | IpcResponseErr;
33
+ /** Must match decentlan's ipcSocketPath() exactly — daemon and client have to
34
+ * agree on this string or beagle silently finds no daemon. */
35
+ export declare function ipcSocketPath(dataDir: string, platform?: NodeJS.Platform): string;
36
+ /** fs.existsSync is meaningful for Unix-domain sockets but always false for
37
+ * Windows named pipes, so on Windows we connect and let net report. */
38
+ export declare function ipcSocketSupportsFsExistenceCheck(platform?: NodeJS.Platform): boolean;
39
+ export declare function daemonIsRunning(dataDir: string): boolean;
40
+ /** One request, one newline-delimited JSON response. */
41
+ export declare function ipcCall(dataDir: string, req: IpcRequest, timeoutMs?: number): Promise<IpcResponse>;
package/dist/ipc.js ADDED
@@ -0,0 +1,76 @@
1
+ // The decentlan daemon IPC contract, from the client side.
2
+ //
3
+ // Deliberately a LOCAL copy rather than a deep import of
4
+ // `@decentnetwork/lan/dist/daemon/ipc.js`: that package exports only ".", and
5
+ // more importantly this is a wire protocol between two independently-released
6
+ // programs. Beagle owns its own view of it, the same way any client owns its
7
+ // view of a server's API — decentlan is free to add ops without beagle
8
+ // rebuilding, and beagle never breaks because an internal lan module moved.
9
+ //
10
+ // Backend B of the PeerHost split (see docs/BEAGLE-SPLIT-PLAN.md in decentlan):
11
+ // used when a decentlan daemon is already running. Backend A (an embedded Peer,
12
+ // no daemon, no root) arrives in Phase 2.
13
+ import { createConnection } from "node:net";
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync } from "node:fs";
16
+ /** Must match decentlan's ipcSocketPath() exactly — daemon and client have to
17
+ * agree on this string or beagle silently finds no daemon. */
18
+ export function ipcSocketPath(dataDir, platform = process.platform) {
19
+ if (platform === "win32") {
20
+ const id = createHash("sha256").update(dataDir.toLowerCase()).digest("hex").slice(0, 16);
21
+ return `\\\\.\\pipe\\agentnet-${id}`;
22
+ }
23
+ return `${dataDir.replace(/\/+$/, "")}/daemon.sock`;
24
+ }
25
+ /** fs.existsSync is meaningful for Unix-domain sockets but always false for
26
+ * Windows named pipes, so on Windows we connect and let net report. */
27
+ export function ipcSocketSupportsFsExistenceCheck(platform = process.platform) {
28
+ return platform !== "win32";
29
+ }
30
+ export function daemonIsRunning(dataDir) {
31
+ if (!ipcSocketSupportsFsExistenceCheck())
32
+ return true; // can't tell; try it
33
+ return existsSync(ipcSocketPath(dataDir));
34
+ }
35
+ /** One request, one newline-delimited JSON response. */
36
+ export function ipcCall(dataDir, req, timeoutMs = 30_000) {
37
+ const sockPath = ipcSocketPath(dataDir);
38
+ if (ipcSocketSupportsFsExistenceCheck() && !existsSync(sockPath)) {
39
+ return Promise.reject(new Error(`Daemon socket not found at ${sockPath} — is the decentlan daemon running?`));
40
+ }
41
+ return new Promise((resolve, reject) => {
42
+ const sock = createConnection(sockPath);
43
+ let buf = "";
44
+ const timer = setTimeout(() => {
45
+ sock.destroy();
46
+ reject(new Error(`IPC timed out after ${timeoutMs}ms`));
47
+ }, timeoutMs);
48
+ const done = (fn) => {
49
+ clearTimeout(timer);
50
+ sock.destroy();
51
+ fn();
52
+ };
53
+ sock.on("connect", () => sock.write(JSON.stringify(req) + "\n"));
54
+ sock.on("data", (chunk) => {
55
+ buf += chunk.toString("utf-8");
56
+ const nl = buf.indexOf("\n");
57
+ if (nl < 0)
58
+ return;
59
+ const line = buf.slice(0, nl);
60
+ done(() => {
61
+ try {
62
+ resolve(JSON.parse(line));
63
+ }
64
+ catch (error) {
65
+ reject(new Error(`Bad IPC response: ${error.message}`));
66
+ }
67
+ });
68
+ });
69
+ sock.on("error", (error) => done(() => reject(error)));
70
+ sock.on("close", () => {
71
+ clearTimeout(timer);
72
+ if (!buf.includes("\n"))
73
+ reject(new Error("Daemon closed the connection without replying"));
74
+ });
75
+ });
76
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Simple logging utility for Decent AgentNet
3
+ */
4
+ export type LogLevel = "debug" | "info" | "warn" | "error";
5
+ export interface LoggerOptions {
6
+ level?: LogLevel;
7
+ prefix?: string;
8
+ }
9
+ export declare class Logger {
10
+ private levelStr;
11
+ private level;
12
+ private prefix;
13
+ constructor(options?: LoggerOptions);
14
+ debug(message: string, ...args: unknown[]): void;
15
+ info(message: string, ...args: unknown[]): void;
16
+ warn(message: string, ...args: unknown[]): void;
17
+ error(message: string, ...args: unknown[]): void;
18
+ child(prefix: string): Logger;
19
+ }
20
+ export declare const globalLogger: Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Simple logging utility for Decent AgentNet
3
+ */
4
+ const LOG_LEVELS = {
5
+ debug: 0,
6
+ info: 1,
7
+ warn: 2,
8
+ error: 3,
9
+ };
10
+ export class Logger {
11
+ levelStr;
12
+ level;
13
+ prefix;
14
+ constructor(options) {
15
+ this.levelStr = (options?.level || process.env.AGENTNET_LOG_LEVEL || "info");
16
+ this.level = LOG_LEVELS[this.levelStr] ?? LOG_LEVELS.info;
17
+ this.prefix = options?.prefix ? `[${options.prefix}] ` : "";
18
+ }
19
+ debug(message, ...args) {
20
+ if (this.level <= LOG_LEVELS.debug) {
21
+ console.debug(`${this.prefix}DEBUG: ${message}`, ...args);
22
+ }
23
+ }
24
+ info(message, ...args) {
25
+ if (this.level <= LOG_LEVELS.info) {
26
+ console.log(`${this.prefix}INFO: ${message}`, ...args);
27
+ }
28
+ }
29
+ warn(message, ...args) {
30
+ if (this.level <= LOG_LEVELS.warn) {
31
+ console.warn(`${this.prefix}WARN: ${message}`, ...args);
32
+ }
33
+ }
34
+ error(message, ...args) {
35
+ if (this.level <= LOG_LEVELS.error) {
36
+ console.error(`${this.prefix}ERROR: ${message}`, ...args);
37
+ }
38
+ }
39
+ child(prefix) {
40
+ return new Logger({ level: this.levelStr, prefix });
41
+ }
42
+ }
43
+ export const globalLogger = new Logger();
@@ -0,0 +1,104 @@
1
+ /**
2
+ * On-disk chat message store. Replaces the previous in-memory-only chatLog so
3
+ * conversations survive a daemon restart (PRD-DESKTOP-UI §2.1).
4
+ *
5
+ * Format: a single JSON file at <configDir>/messages.json mapping
6
+ * userid -> ChatMessage[] (oldest first)
7
+ * Loaded fully into memory on start; writes are debounced and atomic
8
+ * (write tmp + rename). Text chat is low-volume, so a JSON file is plenty;
9
+ * swap for SQLite later if threads get large (PRD §9).
10
+ */
11
+ export interface ChatMessage {
12
+ dir: "in" | "out";
13
+ text: string;
14
+ ts: number;
15
+ /** Stable per-message id (ts + per-process sequence) for UI keys / dedup. */
16
+ id: string;
17
+ /** Outgoing-text delivery state. "queued" means the peer was offline when the
18
+ * user hit send, so the daemon stored it and will deliver it (in order) the
19
+ * moment the friend reconnects. Cleared once actually sent. */
20
+ status?: "queued" | "sent" | "failed";
21
+ /** How this message traversed the network, for the UI to distinguish at a
22
+ * glance (different colors). "online" = live net_crypto session (direct/relay);
23
+ * "offline" = express store-and-forward (the friend or we were offline). Lets
24
+ * a user SEE when online delivery is silently failing and only offline lands. */
25
+ via?: "online" | "offline";
26
+ /** Present when this entry is a file transfer rather than a text message.
27
+ * `name`/`size` describe the file; received files (dir:"in") are saved to
28
+ * <configDir>/downloads/<name> and downloadable via the UI. For outgoing
29
+ * files, `status` tracks delivery and `sent` is the acked byte count (live
30
+ * progress) — the receiver confirms every byte before status becomes "sent".
31
+ * "queued" mirrors text: the peer was offline, so the bytes live in
32
+ * <configDir>/outbox/<id> until the friend reconnects. */
33
+ file?: {
34
+ name: string;
35
+ size: number;
36
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
37
+ sent?: number;
38
+ durationMs?: number;
39
+ avgKbps?: number;
40
+ kbps?: number;
41
+ };
42
+ }
43
+ export declare class MessageStore {
44
+ private path;
45
+ private byPeer;
46
+ private logger;
47
+ private saveTimer?;
48
+ private dirty;
49
+ private seq;
50
+ constructor(path: string);
51
+ private load;
52
+ /** Append a message and schedule a flush. Returns the stored message.
53
+ * Pass `status: "queued"` for an outgoing text the peer wasn't online to
54
+ * receive — the daemon flushes it on reconnect. */
55
+ append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
56
+ /** Set (or, with undefined, clear) the delivery status on a text message.
57
+ * Used to flip a "queued" message to delivered once it's actually sent.
58
+ * No-op if the id isn't found or is a file entry. Returns true if patched. */
59
+ setStatus(peer: string, id: string, status?: "queued" | "sent" | "failed"): boolean;
60
+ /** Outgoing messages still awaiting delivery (peer was offline), oldest
61
+ * first — both queued text and queued file chips. The daemon drains this
62
+ * on a friend's reconnect. */
63
+ queuedOutgoing(peer: string): ChatMessage[];
64
+ /** Append a file-transfer entry (shown as a file chip in the UI). */
65
+ appendFile(peer: string, dir: "in" | "out", file: {
66
+ name: string;
67
+ size: number;
68
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
69
+ sent?: number;
70
+ }, ts?: number): ChatMessage;
71
+ /** Patch an existing file message's transfer fields (status / sent bytes).
72
+ * No-op if the id isn't found. Returns true if it patched. */
73
+ patchFile(peer: string, id: string, patch: {
74
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
75
+ sent?: number;
76
+ durationMs?: number;
77
+ avgKbps?: number;
78
+ kbps?: number;
79
+ }): boolean;
80
+ /** Look up one persisted message by its stable UI id. */
81
+ get(peer: string, id: string): ChatMessage | undefined;
82
+ /** Remove messages by id. Returns the removed ones so the caller can clean up
83
+ * any on-disk file the chip pointed at. */
84
+ deleteMessages(peer: string, ids: string[]): ChatMessage[];
85
+ private push;
86
+ /**
87
+ * Return history. With no peer, returns every peer's full thread (the legacy
88
+ * chat-history shape). With a peer, supports pagination: `limit` newest
89
+ * messages, optionally those strictly older than `before` (ms) for "load
90
+ * earlier" scrolling.
91
+ */
92
+ history(peer?: string, opts?: {
93
+ before?: number;
94
+ limit?: number;
95
+ }): Record<string, ChatMessage[]>;
96
+ /** Most recent message per peer — for the friend-list preview/sort. */
97
+ lastMessages(): Map<string, ChatMessage>;
98
+ /** Count messages newer than `sinceTs` for a peer (unread badge). */
99
+ unreadCount(peer: string, sinceTs: number): number;
100
+ removePeer(peer: string): void;
101
+ private scheduleSave;
102
+ /** Force a synchronous flush (called on debounce + on daemon shutdown). */
103
+ flush(): void;
104
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * On-disk chat message store. Replaces the previous in-memory-only chatLog so
3
+ * conversations survive a daemon restart (PRD-DESKTOP-UI §2.1).
4
+ *
5
+ * Format: a single JSON file at <configDir>/messages.json mapping
6
+ * userid -> ChatMessage[] (oldest first)
7
+ * Loaded fully into memory on start; writes are debounced and atomic
8
+ * (write tmp + rename). Text chat is low-volume, so a JSON file is plenty;
9
+ * swap for SQLite later if threads get large (PRD §9).
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
12
+ import { Logger } from "./logger.js";
13
+ /** Keep at most this many messages per peer on disk; older ones roll off. */
14
+ const MAX_PER_PEER = 2000;
15
+ /** Debounce window for flushing to disk after a change. */
16
+ const SAVE_DEBOUNCE_MS = 1000;
17
+ export class MessageStore {
18
+ path;
19
+ byPeer = new Map();
20
+ logger = new Logger({ prefix: "MessageStore" });
21
+ saveTimer;
22
+ dirty = false;
23
+ seq = 0;
24
+ constructor(path) {
25
+ this.path = path;
26
+ this.load();
27
+ }
28
+ load() {
29
+ if (!existsSync(this.path))
30
+ return;
31
+ try {
32
+ const raw = JSON.parse(readFileSync(this.path, "utf-8"));
33
+ let total = 0;
34
+ for (const [peer, msgs] of Object.entries(raw)) {
35
+ if (Array.isArray(msgs)) {
36
+ this.byPeer.set(peer, msgs);
37
+ total += msgs.length;
38
+ }
39
+ }
40
+ this.logger.info(`Loaded ${total} messages across ${this.byPeer.size} peers`);
41
+ }
42
+ catch (err) {
43
+ this.logger.warn(`Could not load ${this.path}: ${err}`);
44
+ }
45
+ }
46
+ /** Append a message and schedule a flush. Returns the stored message.
47
+ * Pass `status: "queued"` for an outgoing text the peer wasn't online to
48
+ * receive — the daemon flushes it on reconnect. */
49
+ append(peer, dir, text, ts = Date.now(), status, via) {
50
+ const msg = { dir, text, ts, id: `${ts}-${this.seq++}` };
51
+ if (status)
52
+ msg.status = status;
53
+ if (via)
54
+ msg.via = via;
55
+ return this.push(peer, msg);
56
+ }
57
+ /** Set (or, with undefined, clear) the delivery status on a text message.
58
+ * Used to flip a "queued" message to delivered once it's actually sent.
59
+ * No-op if the id isn't found or is a file entry. Returns true if patched. */
60
+ setStatus(peer, id, status) {
61
+ const arr = this.byPeer.get(peer);
62
+ const msg = arr?.find((m) => m.id === id);
63
+ if (!msg || msg.file)
64
+ return false;
65
+ if (status)
66
+ msg.status = status;
67
+ else
68
+ delete msg.status;
69
+ this.scheduleSave();
70
+ return true;
71
+ }
72
+ /** Outgoing messages still awaiting delivery (peer was offline), oldest
73
+ * first — both queued text and queued file chips. The daemon drains this
74
+ * on a friend's reconnect. */
75
+ queuedOutgoing(peer) {
76
+ const arr = this.byPeer.get(peer) ?? [];
77
+ return arr.filter((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued"));
78
+ }
79
+ /** Append a file-transfer entry (shown as a file chip in the UI). */
80
+ appendFile(peer, dir, file, ts = Date.now()) {
81
+ return this.push(peer, { dir, text: "", ts, id: `${ts}-${this.seq++}`, file });
82
+ }
83
+ /** Patch an existing file message's transfer fields (status / sent bytes).
84
+ * No-op if the id isn't found. Returns true if it patched. */
85
+ patchFile(peer, id, patch) {
86
+ const arr = this.byPeer.get(peer);
87
+ const msg = arr?.find((m) => m.id === id);
88
+ if (!msg || !msg.file)
89
+ return false;
90
+ msg.file = { ...msg.file, ...patch };
91
+ this.scheduleSave();
92
+ return true;
93
+ }
94
+ /** Look up one persisted message by its stable UI id. */
95
+ get(peer, id) {
96
+ return this.byPeer.get(peer)?.find((m) => m.id === id);
97
+ }
98
+ /** Remove messages by id. Returns the removed ones so the caller can clean up
99
+ * any on-disk file the chip pointed at. */
100
+ deleteMessages(peer, ids) {
101
+ const arr = this.byPeer.get(peer);
102
+ if (!arr || !ids.length)
103
+ return [];
104
+ const want = new Set(ids);
105
+ const removed = [];
106
+ for (let i = arr.length - 1; i >= 0; i--) {
107
+ if (want.has(arr[i].id)) {
108
+ removed.push(arr[i]);
109
+ arr.splice(i, 1);
110
+ }
111
+ }
112
+ if (removed.length)
113
+ this.scheduleSave();
114
+ return removed;
115
+ }
116
+ push(peer, msg) {
117
+ let arr = this.byPeer.get(peer);
118
+ if (!arr) {
119
+ arr = [];
120
+ this.byPeer.set(peer, arr);
121
+ }
122
+ arr.push(msg);
123
+ if (arr.length > MAX_PER_PEER)
124
+ arr.splice(0, arr.length - MAX_PER_PEER);
125
+ this.scheduleSave();
126
+ return msg;
127
+ }
128
+ /**
129
+ * Return history. With no peer, returns every peer's full thread (the legacy
130
+ * chat-history shape). With a peer, supports pagination: `limit` newest
131
+ * messages, optionally those strictly older than `before` (ms) for "load
132
+ * earlier" scrolling.
133
+ */
134
+ history(peer, opts = {}) {
135
+ const out = {};
136
+ const peers = peer ? [peer] : [...this.byPeer.keys()];
137
+ for (const p of peers) {
138
+ let arr = this.byPeer.get(p) ?? [];
139
+ if (opts.before !== undefined)
140
+ arr = arr.filter((m) => m.ts < opts.before);
141
+ if (opts.limit !== undefined && arr.length > opts.limit)
142
+ arr = arr.slice(arr.length - opts.limit);
143
+ out[p] = arr;
144
+ }
145
+ return out;
146
+ }
147
+ /** Most recent message per peer — for the friend-list preview/sort. */
148
+ lastMessages() {
149
+ const out = new Map();
150
+ for (const [p, arr] of this.byPeer) {
151
+ if (arr.length)
152
+ out.set(p, arr[arr.length - 1]);
153
+ }
154
+ return out;
155
+ }
156
+ /** Count messages newer than `sinceTs` for a peer (unread badge). */
157
+ unreadCount(peer, sinceTs) {
158
+ const arr = this.byPeer.get(peer);
159
+ if (!arr)
160
+ return 0;
161
+ let n = 0;
162
+ for (let i = arr.length - 1; i >= 0; i--) {
163
+ if (arr[i].ts <= sinceTs)
164
+ break;
165
+ if (arr[i].dir === "in")
166
+ n++;
167
+ }
168
+ return n;
169
+ }
170
+ removePeer(peer) {
171
+ if (this.byPeer.delete(peer))
172
+ this.scheduleSave();
173
+ }
174
+ scheduleSave() {
175
+ this.dirty = true;
176
+ if (this.saveTimer)
177
+ return;
178
+ this.saveTimer = setTimeout(() => {
179
+ this.saveTimer = undefined;
180
+ this.flush();
181
+ }, SAVE_DEBOUNCE_MS);
182
+ this.saveTimer.unref?.();
183
+ }
184
+ /** Force a synchronous flush (called on debounce + on daemon shutdown). */
185
+ flush() {
186
+ if (!this.dirty)
187
+ return;
188
+ this.dirty = false;
189
+ try {
190
+ const obj = {};
191
+ for (const [p, m] of this.byPeer)
192
+ obj[p] = m;
193
+ const tmp = `${this.path}.tmp`;
194
+ writeFileSync(tmp, JSON.stringify(obj), "utf-8");
195
+ renameSync(tmp, this.path);
196
+ }
197
+ catch (err) {
198
+ this.logger.warn(`Could not save ${this.path}: ${err}`);
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,16 @@
1
+ export interface BootstrapNode {
2
+ host: string;
3
+ port: number;
4
+ pk: string;
5
+ }
6
+ export interface ExpressNode extends BootstrapNode {
7
+ tls?: boolean;
8
+ }
9
+ export interface NodeConfig {
10
+ bootstrapNodes: BootstrapNode[];
11
+ expressNodes?: ExpressNode[];
12
+ nickname?: string;
13
+ statusMessage?: string;
14
+ autoAccept: boolean;
15
+ }
16
+ export declare function loadNodeConfig(configDir: string): NodeConfig;
@@ -0,0 +1,53 @@
1
+ // Where beagle gets its network settings.
2
+ //
3
+ // Priority: the user's decentlan config.yaml if present, else shipped
4
+ // defaults. Reading decentlan's file matters — on a machine that has been
5
+ // running decentlan, beagle must join the SAME network with the SAME bootstrap
6
+ // fleet, or it is a node that technically works and reaches nobody.
7
+ //
8
+ // The bootstrap list itself is data, not code, so it lives in config/ and can
9
+ // be refreshed without a release (decentlan's `agentnet bootstrap update`
10
+ // pulls the published feed the same way).
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, resolve } from "node:path";
14
+ import yaml from "js-yaml";
15
+ function shippedBootstraps() {
16
+ try {
17
+ const file = resolve(dirname(fileURLToPath(import.meta.url)), "..", "config", "bootstrap-nodes.yaml");
18
+ const parsed = yaml.load(readFileSync(file, "utf-8"));
19
+ return (parsed?.bootstrapNodes ?? []).filter((n) => n?.host && n?.pk);
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
25
+ function valid(nodes) {
26
+ return Array.isArray(nodes) && nodes.length > 0 && nodes.every((n) => n && typeof n.host === "string" && typeof n.pk === "string");
27
+ }
28
+ export function loadNodeConfig(configDir) {
29
+ const shipped = shippedBootstraps();
30
+ const cfgPath = resolve(configDir, "config.yaml");
31
+ if (existsSync(cfgPath)) {
32
+ try {
33
+ const cfg = (yaml.load(readFileSync(cfgPath, "utf-8")) ?? {});
34
+ const fromFile = cfg.carrier?.bootstrapNodes;
35
+ // decentlan appends `extraBootstrapNodes` (nodes adopted from the
36
+ // published feed) to its built-in defaults at load time; mirror that so
37
+ // beagle sees the same fleet the daemon would.
38
+ const extra = cfg.carrier?.extraBootstrapNodes ?? [];
39
+ const merged = valid(fromFile) ? [...fromFile, ...extra] : shipped;
40
+ return {
41
+ bootstrapNodes: merged.length ? merged : shipped,
42
+ expressNodes: cfg.carrier?.expressNodes,
43
+ nickname: cfg.node?.name,
44
+ statusMessage: cfg.node?.statusMessage,
45
+ autoAccept: cfg.friends?.autoAccept ?? true,
46
+ };
47
+ }
48
+ catch {
49
+ // Malformed config must not stop the app — fall through to defaults.
50
+ }
51
+ }
52
+ return { bootstrapNodes: shipped, autoAccept: true };
53
+ }
@@ -0,0 +1,49 @@
1
+ import { type IpcRequest, type IpcResponse } from "./ipc.js";
2
+ export type BackendKind = "daemon" | "embedded";
3
+ export interface PeerHost {
4
+ readonly kind: BackendKind;
5
+ /** True when this backend also provides the virtual LAN (TUN + exits), i.e.
6
+ * when the network panel has anything to show. */
7
+ readonly hasVirtualLan: boolean;
8
+ call(req: IpcRequest): Promise<IpcResponse>;
9
+ stop(): Promise<void>;
10
+ }
11
+ /** Where decentlan keeps its Carrier identity and daemon socket. Beagle reuses
12
+ * this identity when it exists so a user's friends carry over — the whole
13
+ * point of the split is that it is the same node, not a new one. */
14
+ export declare function decentlanCarrierDir(configDir: string): string;
15
+ export declare function defaultConfigDir(): string;
16
+ export interface OpenPeerHostResult {
17
+ host: PeerHost;
18
+ /** Human-readable reason for the choice, for the startup banner. */
19
+ why: string;
20
+ }
21
+ export interface OpenPeerHostOptions {
22
+ configDir: string;
23
+ bootstrapNodes: {
24
+ host: string;
25
+ port: number;
26
+ pk: string;
27
+ }[];
28
+ expressNodes?: {
29
+ host: string;
30
+ port: number;
31
+ pk: string;
32
+ tls?: boolean;
33
+ }[];
34
+ nickname?: string;
35
+ statusMessage?: string;
36
+ autoAcceptFriends?: boolean;
37
+ /** Force a backend instead of auto-detecting. Mainly for testing the
38
+ * embedded path on a machine that also runs a daemon. */
39
+ force?: BackendKind;
40
+ }
41
+ /**
42
+ * Pick a backend.
43
+ *
44
+ * Daemon-first when one is running: it already holds this identity's Carrier
45
+ * peer, and starting a second peer on the same keypair is the one thing that
46
+ * must never happen. If there's no daemon, run the peer ourselves — no root,
47
+ * no TUN, just messaging.
48
+ */
49
+ export declare function openPeerHost(opts: OpenPeerHostOptions): Promise<OpenPeerHostResult>;