@ours.network/notifications 0.1.1
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/LICENSE +98 -0
- package/README.md +231 -0
- package/dist/cli.js +446 -0
- package/dist/config.js +104 -0
- package/dist/daemon.js +353 -0
- package/dist/http.js +31 -0
- package/dist/log-store.js +48 -0
- package/dist/presence.js +157 -0
- package/dist/provision.js +29 -0
- package/dist/push.js +6 -0
- package/dist/web-push-sender.js +41 -0
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +21 -0
- package/mufl_code/core/.github/FUNDING.yml +3 -0
- package/mufl_code/core/.github/workflows/docs.yml +39 -0
- package/mufl_code/core/.github/workflows/tests.yml +66 -0
- package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
- package/mufl_code/core/COMPATIBILITY.md +221 -0
- package/mufl_code/core/CONTRIBUTING.md +37 -0
- package/mufl_code/core/LICENSE +98 -0
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
- package/mufl_code/core/README.md +91 -0
- package/mufl_code/core/SECURITY.md +40 -0
- package/mufl_code/core/TRADEMARKS.md +24 -0
- package/mufl_code/core/a2a_capabilities.mm +449 -0
- package/mufl_code/core/a2a_cluster.mm +601 -0
- package/mufl_code/core/a2a_control.mm +90 -0
- package/mufl_code/core/a2a_messaging.mm +4817 -0
- package/mufl_code/core/a2a_monitoring.mm +94 -0
- package/mufl_code/core/a2a_notification_integration.mm +144 -0
- package/mufl_code/core/a2a_notifications.mm +1127 -0
- package/mufl_code/core/a2a_protocol.mm +246 -0
- package/mufl_code/core/a2a_versions.mm +801 -0
- package/mufl_code/core/config.mufl +29 -0
- package/mufl_code/core/docs/.vitepress/config.mts +58 -0
- package/mufl_code/core/docs/citations.json +688 -0
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
- package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
- package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
- package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
- package/mufl_code/core/docs/guide/index.md +63 -0
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
- package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
- package/mufl_code/core/docs/how-it-works/identity.md +56 -0
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
- package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
- package/mufl_code/core/docs/how-it-works/overview.md +45 -0
- package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
- package/mufl_code/core/docs/index.md +27 -0
- package/mufl_code/core/docs/package-lock.json +3467 -0
- package/mufl_code/core/docs/package.json +15 -0
- package/mufl_code/core/docs/reference/contributing.md +26 -0
- package/mufl_code/core/docs/reference/glossary.md +108 -0
- package/mufl_code/core/docs/reference/implementations.md +23 -0
- package/mufl_code/core/docs/reference/modules.md +19 -0
- package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
- package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
- package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
- package/mufl_code/core/docs/workflows/cluster.md +89 -0
- package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
- package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
- package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
- package/mufl_code/core/docs/workflows/index.md +57 -0
- package/mufl_code/core/docs/workflows/introductions.md +60 -0
- package/mufl_code/core/docs/workflows/messaging.md +75 -0
- package/mufl_code/core/docs/workflows/monitoring.md +84 -0
- package/mufl_code/core/docs/workflows/notifications.md +109 -0
- package/mufl_code/core/release-notes/0.1.md +60 -0
- package/mufl_code/core/release-notes/0.3.md +22 -0
- package/mufl_code/core/release-notes/0.4.md +97 -0
- package/mufl_code/core/tests/README.md +83 -0
- package/mufl_code/core/tests/compat/README.md +82 -0
- package/mufl_code/core/tests/compat/compat.mjs +245 -0
- package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
- package/mufl_code/core/tests/compat/run_compat.sh +96 -0
- package/mufl_code/core/tests/corpus.mjs +139 -0
- package/mufl_code/core/tests/corpus_actor.mu +242 -0
- package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
- package/mufl_code/core/tests/mig.mjs +805 -0
- package/mufl_code/core/tests/mig_actor.mu +518 -0
- package/mufl_code/core/tests/migapp.mjs +431 -0
- package/mufl_code/core/tests/migapp_actor.mu +294 -0
- package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
- package/mufl_code/core/tests/notif.mjs +755 -0
- package/mufl_code/core/tests/notif_actor.mu +450 -0
- package/mufl_code/core/tests/protocol_container.mm +18 -0
- package/mufl_code/core/tests/run.sh +65 -0
- package/mufl_code/core/tests/run_corpus.sh +51 -0
- package/mufl_code/core/tests/run_fatcompile.sh +40 -0
- package/mufl_code/core/tests/run_mig.sh +51 -0
- package/mufl_code/core/tests/run_migapp.sh +51 -0
- package/mufl_code/core/tests/run_notif.sh +57 -0
- package/mufl_code/core/tests/test.mjs +750 -0
- package/mufl_code/core/tests/test_actor.mu +564 -0
- package/mufl_code/core/tests/test_common.mjs +93 -0
- package/mufl_code/core/version.mm +24 -0
- package/mufl_code/packet.mu +170 -0
- package/mufl_code/protocol_container.mm +18 -0
- package/package.json +43 -0
- package/scripts/gen-vapid.mjs +16 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ours-notify — CLI + lifecycle for the ours.network notifications daemon.
|
|
3
|
+
// Mirrors ours-mcp / ours-tg-connector: pidfile-based start/stop/restart/status,
|
|
4
|
+
// a foreground `serve`, and install-service (systemd user unit + linger on Linux,
|
|
5
|
+
// launchd on macOS) that bakes the resolved config into the unit. The daemon's
|
|
6
|
+
// protocol logic (WebPush egress, scoped-token validation, registrations,
|
|
7
|
+
// discovery) is unchanged — this only wraps it in a proper service skeleton.
|
|
8
|
+
//
|
|
9
|
+
// ours-notify start | stop | restart | status | serve
|
|
10
|
+
// ours-notify install-service | uninstall-service | version | help
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
13
|
+
import { connect } from 'node:net';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { homedir, userInfo } from 'node:os';
|
|
18
|
+
import { resolveConfig, loadConfig } from './config.js';
|
|
19
|
+
import { provision } from './provision.js';
|
|
20
|
+
// NOTE: ./daemon.js (which loads the native ADAPT SDK) is imported lazily inside
|
|
21
|
+
// cmdServe so help/version/status/stop stay light and don't spin up the SDK.
|
|
22
|
+
const DEFAULT_DISCOVERY_PORT = 8890;
|
|
23
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
24
|
+
const out = (...p) => process.stdout.write(`${p.join(' ')}\n`);
|
|
25
|
+
const err = (...p) => process.stderr.write(`${p.join(' ')}\n`);
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
function cliVersion() {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(fs.readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8'));
|
|
30
|
+
return String(pkg.version ?? '0.0.0-dev');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '0.0.0-dev';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Resolved once — the port the CLI operates on (config port, else product default).
|
|
37
|
+
const R0 = resolveConfig();
|
|
38
|
+
const STATE_DIR = R0.stateDir;
|
|
39
|
+
const PORT = R0.discoveryPort ?? DEFAULT_DISCOVERY_PORT;
|
|
40
|
+
const PID_PATH = join(STATE_DIR, 'daemon.pid');
|
|
41
|
+
const LOG_PATH = join(STATE_DIR, 'daemon.log');
|
|
42
|
+
function readPid() {
|
|
43
|
+
try {
|
|
44
|
+
const n = parseInt(fs.readFileSync(PID_PATH, 'utf8').trim(), 10);
|
|
45
|
+
return Number.isFinite(n) ? n : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isAlive(pid) {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function runningPid() {
|
|
61
|
+
const pid = readPid();
|
|
62
|
+
if (pid && isAlive(pid))
|
|
63
|
+
return pid;
|
|
64
|
+
if (pid) {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(PID_PATH, { force: true });
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function portOpen(port, timeoutMs = 1000) {
|
|
73
|
+
return new Promise((res) => {
|
|
74
|
+
const sock = connect({ host: '127.0.0.1', port });
|
|
75
|
+
const done = (ok) => { sock.destroy(); res(ok); };
|
|
76
|
+
sock.setTimeout(timeoutMs);
|
|
77
|
+
sock.once('connect', () => done(true));
|
|
78
|
+
sock.once('timeout', () => done(false));
|
|
79
|
+
sock.once('error', () => done(false));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async function waitForPort(port, totalMs = 30_000) {
|
|
83
|
+
const deadline = Date.now() + totalMs;
|
|
84
|
+
while (Date.now() < deadline) {
|
|
85
|
+
if (await portOpen(port))
|
|
86
|
+
return true;
|
|
87
|
+
await sleep(400);
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
async function fetchDiscovery() {
|
|
92
|
+
try {
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
const t = setTimeout(() => ctrl.abort(), 1500);
|
|
95
|
+
const resp = await fetch(`http://127.0.0.1:${PORT}/v1/discovery`, { signal: ctrl.signal });
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
if (resp.ok)
|
|
98
|
+
return (await resp.json());
|
|
99
|
+
}
|
|
100
|
+
catch { /* unreachable */ }
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
// ---- serve (foreground) -----------------------------------------------------
|
|
104
|
+
async function cmdServe() {
|
|
105
|
+
const r = resolveConfig();
|
|
106
|
+
if (!r.brokerUrl) {
|
|
107
|
+
err('BROKER_URL is required (env BROKER_URL or config.json "brokerUrl").');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
provision(r); // mkdir state dir + generate VAPID if absent (identity.key self-provisions in startDaemon)
|
|
111
|
+
if (r.discoveryPort === undefined && !process.env.DISCOVERY_PORT) {
|
|
112
|
+
process.env.DISCOVERY_PORT = String(DEFAULT_DISCOVERY_PORT); // product default
|
|
113
|
+
}
|
|
114
|
+
const cfg = loadConfig();
|
|
115
|
+
const { startDaemon } = await import('./daemon.js');
|
|
116
|
+
const daemon = await startDaemon(cfg);
|
|
117
|
+
const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
118
|
+
emit({ event: 'ready', cid: daemon.cid, version: daemon.version, port: cfg.discoveryPort, stateDir: cfg.stateDir });
|
|
119
|
+
daemon.onEvent((e) => emit(e));
|
|
120
|
+
// Optional JSON-lines control on stdin (handy for `serve` in the foreground).
|
|
121
|
+
const rl = createInterface({ input: process.stdin });
|
|
122
|
+
rl.on('line', (line) => {
|
|
123
|
+
void (async () => {
|
|
124
|
+
let msg;
|
|
125
|
+
try {
|
|
126
|
+
msg = JSON.parse(line);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (msg.cmd === 'invite')
|
|
133
|
+
emit({ reply: await daemon.invite(msg.name ?? '') });
|
|
134
|
+
else if (msg.cmd === 'contacts')
|
|
135
|
+
emit({ reply: daemon.contacts() });
|
|
136
|
+
else if (msg.cmd === 'status')
|
|
137
|
+
emit({ reply: { cid: daemon.cid, version: daemon.version } });
|
|
138
|
+
else if (msg.cmd === 'notifications')
|
|
139
|
+
emit({ reply: daemon.logStore.list() });
|
|
140
|
+
else
|
|
141
|
+
emit({ reply: { error: `unknown cmd ${msg.cmd}` } });
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
emit({ reply: { error: String(e) } });
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
});
|
|
148
|
+
const shutdown = () => { void daemon.stop().then(() => process.exit(0)); };
|
|
149
|
+
process.on('SIGTERM', shutdown);
|
|
150
|
+
process.on('SIGINT', shutdown);
|
|
151
|
+
}
|
|
152
|
+
// ---- start / stop / restart / status ---------------------------------------
|
|
153
|
+
async function cmdStart() {
|
|
154
|
+
const existing = runningPid();
|
|
155
|
+
if (existing) {
|
|
156
|
+
out(`ours-notify is already running (pid ${existing}, port ${PORT}).`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const r = resolveConfig();
|
|
160
|
+
if (!r.brokerUrl) {
|
|
161
|
+
err('BROKER_URL is required (env BROKER_URL or config.json "brokerUrl"), e.g. ws://127.0.0.1:9000');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
165
|
+
const logFd = fs.openSync(LOG_PATH, 'a');
|
|
166
|
+
const child = spawn(process.execPath, [SELF, 'serve'], {
|
|
167
|
+
detached: true,
|
|
168
|
+
stdio: ['ignore', logFd, logFd],
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
BROKER_URL: r.brokerUrl,
|
|
172
|
+
STATE_DIR,
|
|
173
|
+
DISCOVERY_PORT: String(PORT),
|
|
174
|
+
DISCOVERY_HOST: r.discoveryHost,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
child.unref();
|
|
178
|
+
if (!child.pid) {
|
|
179
|
+
err('failed to spawn the daemon.');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
fs.writeFileSync(PID_PATH, String(child.pid));
|
|
183
|
+
out(`starting ours-notify (pid ${child.pid})…`);
|
|
184
|
+
if (await waitForPort(PORT)) {
|
|
185
|
+
const d = await fetchDiscovery();
|
|
186
|
+
out(`ours-notify is up on http://${r.discoveryHost}:${PORT}/v1/discovery`);
|
|
187
|
+
if (d?.service_cid)
|
|
188
|
+
out(` cid: ${d.service_cid}`);
|
|
189
|
+
out(` broker: ${r.brokerUrl}`);
|
|
190
|
+
out(` state: ${STATE_DIR}`);
|
|
191
|
+
out(` logs: ${LOG_PATH}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
err(`daemon started (pid ${child.pid}) but port ${PORT} did not open within 30s — check ${LOG_PATH}.`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function cmdStop() {
|
|
199
|
+
const pid = runningPid();
|
|
200
|
+
if (!pid) {
|
|
201
|
+
out('ours-notify is not running.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
out(`stopping ours-notify (pid ${pid})…`);
|
|
205
|
+
try {
|
|
206
|
+
process.kill(pid, 'SIGTERM');
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
err(`failed to signal pid ${pid}: ${String(e)}`);
|
|
210
|
+
}
|
|
211
|
+
for (let i = 0; i < 25 && isAlive(pid); i++)
|
|
212
|
+
await sleep(200);
|
|
213
|
+
if (isAlive(pid)) {
|
|
214
|
+
err(`pid ${pid} did not exit; sending SIGKILL.`);
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, 'SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
catch { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
fs.rmSync(PID_PATH, { force: true });
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore */ }
|
|
224
|
+
out('stopped.');
|
|
225
|
+
}
|
|
226
|
+
async function cmdStatus() {
|
|
227
|
+
const pid = runningPid();
|
|
228
|
+
const reachable = await portOpen(PORT);
|
|
229
|
+
if (pid || reachable) {
|
|
230
|
+
out(pid ? `ours-notify: running (pid ${pid})` : 'ours-notify: running (no pidfile — external launcher or stale process)');
|
|
231
|
+
out(` url: http://127.0.0.1:${PORT}/v1/discovery${reachable ? ' (reachable)' : ' (not reachable yet)'}`);
|
|
232
|
+
const d = reachable ? await fetchDiscovery() : null;
|
|
233
|
+
if (d?.service_cid)
|
|
234
|
+
out(` cid: ${d.service_cid}`);
|
|
235
|
+
if (d?.broker_url)
|
|
236
|
+
out(` broker: ${d.broker_url}`);
|
|
237
|
+
if (d)
|
|
238
|
+
out(` vapid: ${d.vapid_public_key ? 'advertised' : 'absent'}`);
|
|
239
|
+
out(` state: ${STATE_DIR}`);
|
|
240
|
+
out(` cli: v${cliVersion()}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
out('ours-notify: stopped');
|
|
244
|
+
out(` state: ${STATE_DIR}`);
|
|
245
|
+
out(` cli: v${cliVersion()}`);
|
|
246
|
+
}
|
|
247
|
+
// ---- install-service --------------------------------------------------------
|
|
248
|
+
const SYSTEMD_UNIT = process.env.OURS_NOTIFY_SERVICE_NAME ?? 'ours-notify.service';
|
|
249
|
+
const LAUNCHD_LABEL = 'solutions.adaptframework.ours-notify';
|
|
250
|
+
function systemdUnitPath() { return join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT); }
|
|
251
|
+
function launchdPlistPath() { return join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); }
|
|
252
|
+
function runCmd(cmd, args) { return spawnSync(cmd, args, { stdio: 'inherit' }).status === 0; }
|
|
253
|
+
// Exported so install-service can be validated (rendered + systemd-analyze verify)
|
|
254
|
+
// without enabling a boot unit that competes with the running fleet.
|
|
255
|
+
export function renderSystemdUnit() {
|
|
256
|
+
const r = resolveConfig();
|
|
257
|
+
const port = r.discoveryPort ?? DEFAULT_DISCOVERY_PORT;
|
|
258
|
+
return `[Unit]
|
|
259
|
+
Description=ours.network notifications daemon (WebPush + a2a_notifications over ADAPT)
|
|
260
|
+
After=network-online.target
|
|
261
|
+
Wants=network-online.target
|
|
262
|
+
|
|
263
|
+
[Service]
|
|
264
|
+
Type=simple
|
|
265
|
+
ExecStart=${process.execPath} ${SELF} serve
|
|
266
|
+
Environment=STATE_DIR=${STATE_DIR}
|
|
267
|
+
Environment=BROKER_URL=${r.brokerUrl ?? ''}
|
|
268
|
+
Environment=DISCOVERY_PORT=${port}
|
|
269
|
+
Environment=DISCOVERY_HOST=${r.discoveryHost}
|
|
270
|
+
Environment=VAPID_SUBJECT=${r.vapidSubject}
|
|
271
|
+
Restart=on-failure
|
|
272
|
+
RestartSec=2
|
|
273
|
+
|
|
274
|
+
[Install]
|
|
275
|
+
WantedBy=default.target
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
export function renderLaunchdPlist() {
|
|
279
|
+
const r = resolveConfig();
|
|
280
|
+
const port = r.discoveryPort ?? DEFAULT_DISCOVERY_PORT;
|
|
281
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
282
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
283
|
+
<plist version="1.0">
|
|
284
|
+
<dict>
|
|
285
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
286
|
+
<key>ProgramArguments</key>
|
|
287
|
+
<array>
|
|
288
|
+
<string>${process.execPath}</string>
|
|
289
|
+
<string>${SELF}</string>
|
|
290
|
+
<string>serve</string>
|
|
291
|
+
</array>
|
|
292
|
+
<key>EnvironmentVariables</key>
|
|
293
|
+
<dict>
|
|
294
|
+
<key>STATE_DIR</key><string>${STATE_DIR}</string>
|
|
295
|
+
<key>BROKER_URL</key><string>${r.brokerUrl ?? ''}</string>
|
|
296
|
+
<key>DISCOVERY_PORT</key><string>${port}</string>
|
|
297
|
+
<key>DISCOVERY_HOST</key><string>${r.discoveryHost}</string>
|
|
298
|
+
<key>VAPID_SUBJECT</key><string>${r.vapidSubject}</string>
|
|
299
|
+
</dict>
|
|
300
|
+
<key>RunAtLoad</key><true/>
|
|
301
|
+
<key>KeepAlive</key><true/>
|
|
302
|
+
<key>StandardOutPath</key><string>${LOG_PATH}</string>
|
|
303
|
+
<key>StandardErrorPath</key><string>${LOG_PATH}</string>
|
|
304
|
+
</dict>
|
|
305
|
+
</plist>
|
|
306
|
+
`;
|
|
307
|
+
}
|
|
308
|
+
function installSystemd() {
|
|
309
|
+
const r = resolveConfig();
|
|
310
|
+
if (!r.brokerUrl) {
|
|
311
|
+
err('BROKER_URL is required to install the service (it is baked into the unit).');
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
const unitPath = systemdUnitPath();
|
|
315
|
+
fs.mkdirSync(dirname(unitPath), { recursive: true });
|
|
316
|
+
fs.writeFileSync(unitPath, renderSystemdUnit());
|
|
317
|
+
out(`wrote ${unitPath}`);
|
|
318
|
+
runCmd('systemctl', ['--user', 'daemon-reload']);
|
|
319
|
+
if (!runCmd('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT])) {
|
|
320
|
+
err('failed to enable/start the service via systemctl --user.');
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
if (!runCmd('loginctl', ['enable-linger', userInfo().username])) {
|
|
324
|
+
err(`warning: could not enable linger — run manually: loginctl enable-linger ${userInfo().username}`);
|
|
325
|
+
}
|
|
326
|
+
out('');
|
|
327
|
+
out('ours-notify installed as a systemd user service and started.');
|
|
328
|
+
out(` status: systemctl --user status ${SYSTEMD_UNIT}`);
|
|
329
|
+
out(` logs: journalctl --user -u ${SYSTEMD_UNIT} -f`);
|
|
330
|
+
out(' remove: ours-notify uninstall-service');
|
|
331
|
+
}
|
|
332
|
+
function uninstallSystemd() {
|
|
333
|
+
runCmd('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
|
|
334
|
+
const unitPath = systemdUnitPath();
|
|
335
|
+
try {
|
|
336
|
+
fs.rmSync(unitPath, { force: true });
|
|
337
|
+
out(`removed ${unitPath}`);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
err(`failed to remove ${unitPath}: ${String(e)}`);
|
|
341
|
+
}
|
|
342
|
+
runCmd('systemctl', ['--user', 'daemon-reload']);
|
|
343
|
+
out('ours-notify service uninstalled.');
|
|
344
|
+
}
|
|
345
|
+
function installLaunchd() {
|
|
346
|
+
const plistPath = launchdPlistPath();
|
|
347
|
+
fs.mkdirSync(dirname(plistPath), { recursive: true });
|
|
348
|
+
fs.writeFileSync(plistPath, renderLaunchdPlist());
|
|
349
|
+
out(`wrote ${plistPath}`);
|
|
350
|
+
const domain = `gui/${process.getuid()}`;
|
|
351
|
+
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
352
|
+
if (!runCmd('launchctl', ['bootstrap', domain, plistPath])) {
|
|
353
|
+
err('failed to load the launchd agent.');
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
out('');
|
|
357
|
+
out('ours-notify installed as a launchd agent and started.');
|
|
358
|
+
out(' remove: ours-notify uninstall-service');
|
|
359
|
+
}
|
|
360
|
+
function uninstallLaunchd() {
|
|
361
|
+
const plistPath = launchdPlistPath();
|
|
362
|
+
const domain = `gui/${process.getuid()}`;
|
|
363
|
+
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
364
|
+
try {
|
|
365
|
+
fs.rmSync(plistPath, { force: true });
|
|
366
|
+
out(`removed ${plistPath}`);
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
err(`failed to remove ${plistPath}: ${String(e)}`);
|
|
370
|
+
}
|
|
371
|
+
out('ours-notify service uninstalled.');
|
|
372
|
+
}
|
|
373
|
+
async function cmdInstallService() {
|
|
374
|
+
await cmdStop();
|
|
375
|
+
if (process.platform === 'linux')
|
|
376
|
+
return installSystemd();
|
|
377
|
+
if (process.platform === 'darwin')
|
|
378
|
+
return installLaunchd();
|
|
379
|
+
err(`install-service: unsupported platform "${process.platform}" (only linux/systemd and macOS/launchd).`);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
function cmdUninstallService() {
|
|
383
|
+
if (process.platform === 'linux')
|
|
384
|
+
return uninstallSystemd();
|
|
385
|
+
if (process.platform === 'darwin')
|
|
386
|
+
return uninstallLaunchd();
|
|
387
|
+
err(`uninstall-service: unsupported platform "${process.platform}".`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
function usage() {
|
|
391
|
+
out('ours-notify — ours.network notifications daemon');
|
|
392
|
+
out('');
|
|
393
|
+
out('Usage: ours-notify <command>');
|
|
394
|
+
out(' start start the daemon in the background');
|
|
395
|
+
out(' stop stop the running daemon');
|
|
396
|
+
out(' restart stop then start');
|
|
397
|
+
out(' status show running state, port, cid, state dir, version');
|
|
398
|
+
out(' serve run in the foreground (used by start; handy for debugging)');
|
|
399
|
+
out(' install-service install + start a boot-persistent service (systemd/launchd)');
|
|
400
|
+
out(' uninstall-service stop + remove that service');
|
|
401
|
+
out(' version print the CLI version');
|
|
402
|
+
out('');
|
|
403
|
+
out('Config precedence (per field): env > config.json > default.');
|
|
404
|
+
out(' STATE_DIR (~/.ours-notifications) · BROKER_URL (required) · DISCOVERY_PORT (8890)');
|
|
405
|
+
out(' DISCOVERY_HOST (127.0.0.1) · VAPID_SUBJECT · config.json at $OURS_NOTIFY_CONFIG');
|
|
406
|
+
out('VAPID keys + identity self-provision into the state dir on first start.');
|
|
407
|
+
}
|
|
408
|
+
async function main() {
|
|
409
|
+
const cmd = process.argv[2];
|
|
410
|
+
switch (cmd) {
|
|
411
|
+
case 'serve': return cmdServe();
|
|
412
|
+
case 'start': return cmdStart();
|
|
413
|
+
case 'stop': return cmdStop();
|
|
414
|
+
case 'restart':
|
|
415
|
+
await cmdStop();
|
|
416
|
+
return cmdStart();
|
|
417
|
+
case 'status': return cmdStatus();
|
|
418
|
+
case 'install-service': return cmdInstallService();
|
|
419
|
+
case 'uninstall-service': return cmdUninstallService();
|
|
420
|
+
case 'version':
|
|
421
|
+
out(`ours-notify v${cliVersion()}`);
|
|
422
|
+
return;
|
|
423
|
+
case undefined:
|
|
424
|
+
case 'help':
|
|
425
|
+
case '--help':
|
|
426
|
+
case '-h': return usage();
|
|
427
|
+
default:
|
|
428
|
+
err(`unknown command: ${cmd}`);
|
|
429
|
+
usage();
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Run only as a CLI entrypoint (not when imported by tests). Compare via realpath
|
|
434
|
+
// so the npm-installed `ours-notify` bin SYMLINK (argv[1]) still matches this
|
|
435
|
+
// module's resolved path (import.meta.url) — a plain path compare would not.
|
|
436
|
+
function invokedAsMain() {
|
|
437
|
+
try {
|
|
438
|
+
return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (invokedAsMain()) {
|
|
445
|
+
main().catch((e) => { err(String(e?.stack ?? e)); process.exit(1); });
|
|
446
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Daemon configuration. Portable and not host-bound: every field resolves by
|
|
2
|
+
// precedence env > config.json > built-in default, so the same build runs as
|
|
3
|
+
// the public instance or any self-hosted one (WebPush needs no vendor creds).
|
|
4
|
+
//
|
|
5
|
+
// config.json lives at $OURS_NOTIFY_CONFIG, else <HOME>/.ours-notifications/
|
|
6
|
+
// config.json (a fixed home path, independent of a custom STATE_DIR — mirrors
|
|
7
|
+
// ours-mcp). STATE_DIR defaults to <HOME>/.ours-notifications. VAPID keys are
|
|
8
|
+
// read from <STATE_DIR>/vapid.{public,private} by default and are
|
|
9
|
+
// self-provisioned on first boot (see provision.ts); inline VAPID_* env still
|
|
10
|
+
// wins. DISCOVERY_PORT has no config-layer default — the CLI supplies the 8890
|
|
11
|
+
// product default so running `dist/daemon.js` directly (and the test suite)
|
|
12
|
+
// never binds a port unless asked.
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import { resolve, dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
function homeBase(env) {
|
|
18
|
+
return env.HOME ?? env.USERPROFILE ?? homedir();
|
|
19
|
+
}
|
|
20
|
+
export function defaultStateDir(env = process.env) {
|
|
21
|
+
return join(homeBase(env), '.ours-notifications');
|
|
22
|
+
}
|
|
23
|
+
function configFilePath(env) {
|
|
24
|
+
return env.OURS_NOTIFY_CONFIG ?? join(homeBase(env), '.ours-notifications', 'config.json');
|
|
25
|
+
}
|
|
26
|
+
function readConfigFile(env) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(configFilePath(env), 'utf8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {}; // missing or invalid config.json → no overrides
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parsePort(raw, source) {
|
|
37
|
+
if (raw === undefined || raw === null || raw === '')
|
|
38
|
+
return undefined;
|
|
39
|
+
const port = Number(raw);
|
|
40
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
41
|
+
throw new Error(`${source} must be an integer in 1..65535, got: ${raw}`);
|
|
42
|
+
}
|
|
43
|
+
return port;
|
|
44
|
+
}
|
|
45
|
+
// Resolve every field by env > config.json > default. Never throws on VAPID
|
|
46
|
+
// (files may not exist yet — provisioning creates them). Broker is left
|
|
47
|
+
// undefined when unset; loadConfig enforces it.
|
|
48
|
+
export function resolveConfig(env = process.env) {
|
|
49
|
+
const file = readConfigFile(env);
|
|
50
|
+
const pick = (envKey, fileKey, def) => env[envKey] ?? (file[fileKey] != null ? String(file[fileKey]) : undefined) ?? def;
|
|
51
|
+
const flag = (envKey, fileKey) => env[envKey] === '1' || file[fileKey] === true || file[fileKey] === '1';
|
|
52
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
53
|
+
const stateDir = resolve(pick('STATE_DIR', 'stateDir', defaultStateDir(env)));
|
|
54
|
+
const unitDir = resolve(pick('UNIT_DIR', 'unitDir', join(repoRoot, 'mufl_code')));
|
|
55
|
+
const vapidPublicFile = resolve(pick('VAPID_PUBLIC_KEY_FILE', 'vapidPublicFile', join(stateDir, 'vapid.public')));
|
|
56
|
+
const vapidPrivateFile = resolve(pick('VAPID_PRIVATE_KEY_FILE', 'vapidPrivateFile', join(stateDir, 'vapid.private')));
|
|
57
|
+
return {
|
|
58
|
+
brokerUrl: pick('BROKER_URL', 'brokerUrl'),
|
|
59
|
+
stateDir,
|
|
60
|
+
unitDir,
|
|
61
|
+
vapidPublicFile,
|
|
62
|
+
vapidPrivateFile,
|
|
63
|
+
vapidSubject: pick('VAPID_SUBJECT', 'vapidSubject', 'mailto:ops@ours.network'),
|
|
64
|
+
pushDisabled: flag('PUSH_DISABLED', 'pushDisabled'),
|
|
65
|
+
pushTlsInsecure: flag('PUSH_TLS_INSECURE', 'pushTlsInsecure'),
|
|
66
|
+
discoveryPort: parsePort(env.DISCOVERY_PORT ?? file.discoveryPort, 'DISCOVERY_PORT'),
|
|
67
|
+
discoveryHost: pick('DISCOVERY_HOST', 'discoveryHost', '127.0.0.1'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Full config with VAPID key CONTENTS. Inline VAPID_PUBLIC_KEY/PRIVATE_KEY env
|
|
71
|
+
// wins over the files. Throws if the broker is unset or VAPID is still missing
|
|
72
|
+
// (provisioning should have created it — run `ours-notify start`).
|
|
73
|
+
export function loadConfig(env = process.env) {
|
|
74
|
+
const r = resolveConfig(env);
|
|
75
|
+
if (!r.brokerUrl) {
|
|
76
|
+
throw new Error('BROKER_URL is required (env BROKER_URL or config.json "brokerUrl"), e.g. ws://127.0.0.1:9000');
|
|
77
|
+
}
|
|
78
|
+
const readFileTrim = (p) => {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(p, 'utf8').trim();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const vapidPublicKey = (env.VAPID_PUBLIC_KEY?.trim()) || readFileTrim(r.vapidPublicFile);
|
|
87
|
+
const vapidPrivateKey = (env.VAPID_PRIVATE_KEY?.trim()) || readFileTrim(r.vapidPrivateFile);
|
|
88
|
+
if (!vapidPublicKey || !vapidPrivateKey) {
|
|
89
|
+
throw new Error(`VAPID keys missing under ${r.stateDir} — run \`ours-notify start\` (auto-provisions) ` +
|
|
90
|
+
`or generate them with scripts/gen-vapid.mjs`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
brokerUrl: r.brokerUrl,
|
|
94
|
+
stateDir: r.stateDir,
|
|
95
|
+
unitDir: r.unitDir,
|
|
96
|
+
vapidPublicKey,
|
|
97
|
+
vapidPrivateKey,
|
|
98
|
+
vapidSubject: r.vapidSubject,
|
|
99
|
+
pushDisabled: r.pushDisabled,
|
|
100
|
+
pushTlsInsecure: r.pushTlsInsecure,
|
|
101
|
+
discoveryPort: r.discoveryPort,
|
|
102
|
+
discoveryHost: r.discoveryHost,
|
|
103
|
+
};
|
|
104
|
+
}
|