pi-lxmf 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/src/bin.js ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file bin.js
5
+ *
6
+ * The `pi-lxmf` daemon entry point: loads configuration, starts the mesh
7
+ * side, spawns `pi --mode rpc`, wires the bridge, and stays alive until
8
+ * signalled or told to quit over LXMF.
9
+ */
10
+
11
+ import { basename } from "node:path";
12
+ import { Bridge } from "./bridge.js";
13
+ import {
14
+ loadConfig,
15
+ readSessionPointer,
16
+ writeSessionPointer,
17
+ } from "./config.js";
18
+ import { deriveLxmfDestinationHash } from "./identity.js";
19
+ import { startLxmf } from "./lxmf.js";
20
+ import { GlmQuotaWatcher, readZaiKey } from "./quota.js";
21
+ import { PiRpcClient } from "./rpc.js";
22
+
23
+ const USAGE = `pi-lxmf — drive Pi over LXMF messaging
24
+
25
+ Usage: pi-lxmf [--config <path>] [--version]
26
+
27
+ Options:
28
+ --config, -c <path> Configuration file (default: $PI_LXMF_CONFIG or
29
+ ~/.config/pi-lxmf/config.json)
30
+ --version, -v Print version and exit
31
+ --help, -h Show this help
32
+
33
+ The daemon announces itself on the Reticulum mesh and relays messages
34
+ between the paired owner and a supervised \`pi --mode rpc\` child process.
35
+ See SPEC.md for details.`;
36
+
37
+ /**
38
+ * @param {string[]} argv
39
+ * @returns {{config: string|null, version: boolean, help: boolean, error: string|null}}
40
+ */
41
+ function parseArgv(argv) {
42
+ /** @type {{config: string|null, version: boolean, help: boolean, error: string|null}} */
43
+ const out = { config: null, version: false, help: false, error: null };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const arg = argv[i];
46
+ if (arg === "--config" || arg === "-c") {
47
+ const value = argv[++i];
48
+ if (!value) {
49
+ out.error = `${arg} requires a path`;
50
+ return out;
51
+ }
52
+ out.config = value;
53
+ } else if (arg === "--version" || arg === "-v") {
54
+ out.version = true;
55
+ } else if (arg === "--help" || arg === "-h") {
56
+ out.help = true;
57
+ } else {
58
+ out.error = `unknown argument: ${arg}`;
59
+ return out;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * @param {string} label
67
+ * @param {string} value
68
+ */
69
+ function bannerLine(label, value) {
70
+ console.log(` ${label.padEnd(10)} ${value}`);
71
+ }
72
+
73
+ /** @type {boolean} */
74
+ let shuttingDown = false;
75
+
76
+ /**
77
+ * @param {unknown} e
78
+ * @returns {string}
79
+ */
80
+ function errText(e) {
81
+ if (e instanceof Error) return e.message;
82
+ return String(e);
83
+ }
84
+
85
+ /**
86
+ * @param {string} reason
87
+ * @param {object} parts
88
+ * @param {import("./rpc.js").PiRpcClient|null} parts.rpc
89
+ * @param {{stop: () => void}|null} parts.mesh
90
+ * @param {Bridge|null} parts.bridge
91
+ * @param {import("./config.js").PiLxmfConfig|null} [parts.config]
92
+ */
93
+ function gracefulShutdown(reason, parts) {
94
+ if (shuttingDown) return;
95
+ shuttingDown = true;
96
+ console.log(`pi-lxmf: shutting down (${reason})`);
97
+ const finish = () => {
98
+ parts.rpc?.stop();
99
+ parts.mesh?.stop();
100
+ process.exit(0);
101
+ };
102
+ // Best effort: persist the current session pointer so the next start
103
+ // resumes this session.
104
+ const persist =
105
+ parts.bridge && parts.rpc && parts.config
106
+ ? parts.rpc
107
+ .getState()
108
+ .then((/** @type {any} */ state) => {
109
+ if (state?.sessionFile) {
110
+ writeSessionPointer(
111
+ parts.config?.dataDir ?? "",
112
+ parts.config?.workdir ?? "",
113
+ state.sessionFile,
114
+ );
115
+ }
116
+ })
117
+ .catch(() => {})
118
+ : Promise.resolve();
119
+ persist.then(finish, finish);
120
+ }
121
+
122
+ /**
123
+ * @returns {Promise<void>}
124
+ */
125
+ async function main() {
126
+ const args = parseArgv(process.argv.slice(2));
127
+ if (args.error) {
128
+ console.error(`pi-lxmf: ${args.error}\n\n${USAGE}`);
129
+ process.exit(2);
130
+ }
131
+ if (args.help) {
132
+ console.log(USAGE);
133
+ process.exit(0);
134
+ }
135
+
136
+ const config = await loadConfig({ path: args.config ?? undefined });
137
+ if (args.version) {
138
+ console.log(config.name);
139
+ process.exit(0);
140
+ }
141
+
142
+ process.on("unhandledRejection", (e) => {
143
+ console.error("pi-lxmf: unhandled rejection:", e);
144
+ });
145
+
146
+ console.log(`pi-lxmf: ${config.name} starting`);
147
+ bannerLine("workdir", config.workdir);
148
+ bannerLine("config", config.configPath ?? "(defaults)");
149
+ bannerLine("data", config.dataDir);
150
+
151
+ // Mesh first: the identity and delivery destination are needed for the
152
+ // banner, and inbound messages queue in the bridge until Pi is ready.
153
+ const mesh = await startLxmf(config);
154
+ bannerLine("identity", mesh.identityHash);
155
+ bannerLine("lxmf", mesh.deliveryHash);
156
+ bannerLine("interfaces", mesh.interfaceNames.join(", "));
157
+ bannerLine("announce", config.name);
158
+
159
+ bannerLine("owner (identity)", config.owner);
160
+
161
+ const sessionPointer = readSessionPointer(config.dataDir, config.workdir);
162
+ if (sessionPointer) {
163
+ bannerLine("resume", basename(sessionPointer.sessionFile));
164
+ }
165
+
166
+ /** @type {import("./rpc.js").PiRpcClient|null} */
167
+ let rpc = null;
168
+ /** @type {Bridge|null} */
169
+ let bridge = null;
170
+ const shutdown = (/** @type {string} */ reason) =>
171
+ gracefulShutdown(reason, { rpc, mesh, bridge, config });
172
+
173
+ rpc = new PiRpcClient({
174
+ piBin: config.piBin,
175
+ model: config.model,
176
+ cwd: config.workdir,
177
+ sessionPath: sessionPointer?.sessionFile ?? null,
178
+ });
179
+
180
+ bridge = new Bridge({
181
+ config,
182
+ rpc,
183
+ mesh,
184
+ state: {
185
+ loadSession: () => readSessionPointer(config.dataDir, config.workdir),
186
+ saveSession: (file) =>
187
+ writeSessionPointer(config.dataDir, config.workdir, file),
188
+ },
189
+ quotaWatcher: new GlmQuotaWatcher({
190
+ ownerDestinationHash: deriveLxmfDestinationHash(config.owner),
191
+ sendText: (destHex, text) =>
192
+ mesh.sendText(destHex, text, { title: config.name }),
193
+ log: console,
194
+ apiKey: readZaiKey(),
195
+ }),
196
+ onShutdown: shutdown,
197
+ });
198
+ bridge.start();
199
+
200
+ try {
201
+ await rpc.start();
202
+ } catch (e) {
203
+ console.error(`pi-lxmf: ${errText(e)}`);
204
+ mesh.stop();
205
+ process.exit(1);
206
+ }
207
+ bridge.setRpcReady(true);
208
+ console.log("pi-lxmf: ready — listening for LXMF messages");
209
+
210
+ process.on("SIGINT", () => shutdown("SIGINT"));
211
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
212
+
213
+ // Run until signalled or shut down over LXMF.
214
+ await new Promise(() => {});
215
+ }
216
+
217
+ main().catch((e) => {
218
+ console.error("pi-lxmf: fatal:", e instanceof Error ? e.stack : e);
219
+ process.exit(1);
220
+ });