@chipallen2/snazi 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 +281 -0
- package/com.soup-nazi.snazi-serve.plist +46 -0
- package/dist/address.js +61 -0
- package/dist/api.js +101 -0
- package/dist/cache.js +173 -0
- package/dist/channels/imessage.js +47 -0
- package/dist/channels/index.js +39 -0
- package/dist/channels/types.js +16 -0
- package/dist/chatdb.js +202 -0
- package/dist/cli.js +516 -0
- package/dist/client.js +106 -0
- package/dist/config.js +110 -0
- package/dist/daemon.js +89 -0
- package/dist/doctor.js +99 -0
- package/dist/init.js +155 -0
- package/dist/server.js +466 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
/**
|
|
5
|
+
* snazi — on-demand message gate.
|
|
6
|
+
*
|
|
7
|
+
* "No messages for you."
|
|
8
|
+
*
|
|
9
|
+
* This CLI is the LOCAL gate. It runs on demand (NOT a daemon).
|
|
10
|
+
* - init : create/update ~/.snazi/config.json (interactive or via flags).
|
|
11
|
+
* - doctor : diagnose Node, config, connectivity, and per-channel access.
|
|
12
|
+
* - list-new : reveals WHO sent recent messages + their approval status. Never WHAT.
|
|
13
|
+
* - read : reveals message TEXT for ONE sender, but ONLY if approved by the server.
|
|
14
|
+
* - check : prints a single sender's approval status.
|
|
15
|
+
* - channels : list/add configured channels + show adapter availability here.
|
|
16
|
+
* - status : prints config + platform + server connectivity.
|
|
17
|
+
*
|
|
18
|
+
* Local message sources are pluggable CHANNEL ADAPTERS (see src/channels). The
|
|
19
|
+
* CLI itself is cross-platform; a channel that can't run on this OS (e.g.
|
|
20
|
+
* iMessage off macOS) reports itself unavailable instead of crashing.
|
|
21
|
+
*
|
|
22
|
+
* Approvals are READ-ONLY here: a sender is approved/denied in the web
|
|
23
|
+
* dashboard or via a signed /decide link, never from this CLI. The token in
|
|
24
|
+
* ~/.snazi/config.json is a per-account READ token that cannot mutate the list.
|
|
25
|
+
*
|
|
26
|
+
* The server stores no messages. This CLI stores no message content; it keeps
|
|
27
|
+
* only a short-lived approval-STATUS cache (~/.snazi/check-cache.json) so it
|
|
28
|
+
* needn't re-check every call. Content is read live from the local Messages
|
|
29
|
+
* database and printed only when the gate opens.
|
|
30
|
+
*/
|
|
31
|
+
const config_1 = require("./config");
|
|
32
|
+
const address_1 = require("./address");
|
|
33
|
+
const api_1 = require("./api");
|
|
34
|
+
const cache_1 = require("./cache");
|
|
35
|
+
const channels_1 = require("./channels");
|
|
36
|
+
const server_1 = require("./server");
|
|
37
|
+
const client_1 = require("./client");
|
|
38
|
+
const daemon_1 = require("./daemon");
|
|
39
|
+
const init_1 = require("./init");
|
|
40
|
+
const doctor_1 = require("./doctor");
|
|
41
|
+
const DEFAULT_CHANNEL = 'imessage';
|
|
42
|
+
function parseSince(args, def = 60) {
|
|
43
|
+
const i = args.indexOf('--since');
|
|
44
|
+
if (i !== -1 && args[i + 1]) {
|
|
45
|
+
const n = parseInt(args[i + 1], 10);
|
|
46
|
+
if (!Number.isNaN(n) && n > 0)
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
return def;
|
|
50
|
+
}
|
|
51
|
+
/** Read a named --flag's value. */
|
|
52
|
+
function flag(args, name) {
|
|
53
|
+
const i = args.indexOf(name);
|
|
54
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
55
|
+
return args[i + 1];
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/** True if a boolean --flag is present. */
|
|
60
|
+
function hasFlag(args, name) {
|
|
61
|
+
return args.includes(name);
|
|
62
|
+
}
|
|
63
|
+
function parsePort(args) {
|
|
64
|
+
const v = flag(args, '--port');
|
|
65
|
+
if (v == null)
|
|
66
|
+
return undefined;
|
|
67
|
+
const n = parseInt(v, 10);
|
|
68
|
+
if (Number.isNaN(n))
|
|
69
|
+
return undefined;
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
function out(obj) {
|
|
73
|
+
console.log(JSON.stringify(obj, null, 2));
|
|
74
|
+
}
|
|
75
|
+
async function cmdListNew(args) {
|
|
76
|
+
const since = parseSince(args, 60);
|
|
77
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
78
|
+
const fresh = hasFlag(args, '--fresh');
|
|
79
|
+
const cfg = (0, config_1.loadConfig)();
|
|
80
|
+
const { adapter, error } = (0, channels_1.resolveReadableAdapter)(channel);
|
|
81
|
+
if (!adapter) {
|
|
82
|
+
out({ error });
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const senders = adapter.listInboundSenders(since);
|
|
86
|
+
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const s of senders) {
|
|
89
|
+
let status = 'unknown';
|
|
90
|
+
let checkError;
|
|
91
|
+
try {
|
|
92
|
+
status = await (0, cache_1.checkSenderCached)(cfg, channel, s.sender, { fresh });
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
checkError = String(e instanceof Error ? e.message : e);
|
|
96
|
+
}
|
|
97
|
+
const entry = {
|
|
98
|
+
sender: s.sender,
|
|
99
|
+
message_count: s.message_count,
|
|
100
|
+
latest_at: s.latest_at,
|
|
101
|
+
status,
|
|
102
|
+
label: labels.get((0, address_1.normalizeAddress)(s.sender)) ?? null,
|
|
103
|
+
};
|
|
104
|
+
if (checkError)
|
|
105
|
+
entry.error = checkError;
|
|
106
|
+
results.push(entry);
|
|
107
|
+
}
|
|
108
|
+
out(results);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
async function cmdRead(args) {
|
|
112
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
113
|
+
const target = (0, address_1.normalizeAddress)(positionals[0]);
|
|
114
|
+
if (!target) {
|
|
115
|
+
out({ error: 'Usage: snazi read <sender> [--channel <id>] [--since <minutes>]' });
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
const since = parseSince(args, 60);
|
|
119
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
120
|
+
const fresh = hasFlag(args, '--fresh');
|
|
121
|
+
const cfg = (0, config_1.loadConfig)();
|
|
122
|
+
// Resolve the local source first so an unsupported channel/platform fails
|
|
123
|
+
// with a clear message (and never touches the network or any message text).
|
|
124
|
+
const { adapter, error } = (0, channels_1.resolveReadableAdapter)(channel);
|
|
125
|
+
if (!adapter) {
|
|
126
|
+
out({ error });
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
// GATE: check approval BEFORE touching any message text.
|
|
130
|
+
let status;
|
|
131
|
+
try {
|
|
132
|
+
status = await (0, cache_1.checkSenderCached)(cfg, channel, target, { fresh });
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
out({ error: `Approval check failed: ${String(e)}` });
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
if (status !== 'approved') {
|
|
139
|
+
out({ error: 'Sender not approved. No messages for you.', status });
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
const messages = adapter.readMessagesFrom(target, since);
|
|
143
|
+
out({ sender: target, status, since_minutes: since, messages });
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
async function cmdCheck(args) {
|
|
147
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
148
|
+
const target = (0, address_1.normalizeAddress)(positionals[0]);
|
|
149
|
+
if (!target) {
|
|
150
|
+
out({ error: 'Usage: snazi check <sender> --channel <id>' });
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
154
|
+
const fresh = hasFlag(args, '--fresh');
|
|
155
|
+
const cfg = (0, config_1.loadConfig)();
|
|
156
|
+
try {
|
|
157
|
+
const status = await (0, cache_1.checkSenderCached)(cfg, channel, target, { fresh });
|
|
158
|
+
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
159
|
+
const label = labels.get(target) ?? null;
|
|
160
|
+
out({ channel, sender: target, status, label });
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function cmdCache(args) {
|
|
169
|
+
const sub = args[0];
|
|
170
|
+
if (sub === 'clear') {
|
|
171
|
+
(0, cache_1.clearCache)();
|
|
172
|
+
out({ ok: true, cleared: true });
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
out({ error: `Unknown cache subcommand: ${sub ?? '(none)'}. Use 'clear'.` });
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
async function cmdChannels(args) {
|
|
179
|
+
const sub = args[0];
|
|
180
|
+
if (sub === 'list' || sub === undefined) {
|
|
181
|
+
// Works even before `snazi init` (read config leniently, don't exit).
|
|
182
|
+
const cfg = (0, config_1.readConfigIfPresent)();
|
|
183
|
+
const adapters = (0, channels_1.listAdapters)().map((a) => {
|
|
184
|
+
const av = a.availability();
|
|
185
|
+
return {
|
|
186
|
+
id: a.id,
|
|
187
|
+
display_name: a.displayName,
|
|
188
|
+
platforms: a.platforms,
|
|
189
|
+
available_here: av.available,
|
|
190
|
+
reason: av.reason ?? null,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
out({ configured: cfg?.channels ?? [], adapters });
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
if (sub === 'add') {
|
|
197
|
+
const channel = args[1];
|
|
198
|
+
if (!channel) {
|
|
199
|
+
out({ error: 'Usage: snazi channels add <channel>' });
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
const cfg = (0, config_1.loadConfig)();
|
|
203
|
+
const channels = new Set(cfg.channels ?? []);
|
|
204
|
+
channels.add(channel);
|
|
205
|
+
cfg.channels = [...channels];
|
|
206
|
+
(0, config_1.saveConfig)(cfg);
|
|
207
|
+
const known = (0, channels_1.getAdapter)(channel);
|
|
208
|
+
out({
|
|
209
|
+
ok: true,
|
|
210
|
+
channels: cfg.channels,
|
|
211
|
+
note: known
|
|
212
|
+
? undefined
|
|
213
|
+
: `'${channel}' has no local adapter yet; it can still be used with remote-* against a host that supports it.`,
|
|
214
|
+
});
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
out({ error: `Unknown channels subcommand: ${sub}. Use 'list' or 'add'.` });
|
|
218
|
+
return 2;
|
|
219
|
+
}
|
|
220
|
+
async function cmdInit(args) {
|
|
221
|
+
const { code, result } = await (0, init_1.runInit)({
|
|
222
|
+
apiUrl: flag(args, '--api-url'),
|
|
223
|
+
token: flag(args, '--token'),
|
|
224
|
+
channel: flag(args, '--channel'),
|
|
225
|
+
force: hasFlag(args, '--force'),
|
|
226
|
+
yes: hasFlag(args, '--yes') || hasFlag(args, '-y'),
|
|
227
|
+
});
|
|
228
|
+
out(result);
|
|
229
|
+
return code;
|
|
230
|
+
}
|
|
231
|
+
async function cmdDoctor() {
|
|
232
|
+
const { code, report } = await (0, doctor_1.runDoctor)();
|
|
233
|
+
out(report);
|
|
234
|
+
return code;
|
|
235
|
+
}
|
|
236
|
+
async function cmdServe(args) {
|
|
237
|
+
const cfg = (0, config_1.loadConfig)();
|
|
238
|
+
const bind = flag(args, '--bind');
|
|
239
|
+
const port = parsePort(args);
|
|
240
|
+
if (hasFlag(args, '--install-daemon')) {
|
|
241
|
+
if (process.platform !== 'darwin') {
|
|
242
|
+
out({
|
|
243
|
+
error: 'serve --install-daemon is macOS-only (it installs a launchd LaunchAgent). ' +
|
|
244
|
+
'On Windows/Linux, run `snazi serve` under your own process manager (e.g. a ' +
|
|
245
|
+
'Windows Service, systemd unit, or pm2).',
|
|
246
|
+
});
|
|
247
|
+
return 2;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const r = (0, daemon_1.installDaemon)(cfg, { bind, port });
|
|
251
|
+
out({
|
|
252
|
+
ok: true,
|
|
253
|
+
installed: r.plistPath,
|
|
254
|
+
label: daemon_1.LABEL,
|
|
255
|
+
bind: r.bind,
|
|
256
|
+
port: r.port,
|
|
257
|
+
node: r.node,
|
|
258
|
+
cli: r.cli,
|
|
259
|
+
next_steps: [
|
|
260
|
+
`launchctl load -w ${r.plistPath}`,
|
|
261
|
+
`# stop: launchctl unload -w ${r.plistPath}`,
|
|
262
|
+
`# Grant Full Disk Access to the node binary: ${r.node}`,
|
|
263
|
+
`# System Settings > Privacy & Security > Full Disk Access`,
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const { server } = await (0, server_1.startServer)(cfg, { bind, port });
|
|
275
|
+
// Keep the process alive until signalled; shut down cleanly.
|
|
276
|
+
return await new Promise((resolve) => {
|
|
277
|
+
const shutdown = () => {
|
|
278
|
+
server.close(() => resolve(0));
|
|
279
|
+
};
|
|
280
|
+
process.on('SIGINT', shutdown);
|
|
281
|
+
process.on('SIGTERM', shutdown);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function cmdRemoteListNew(args) {
|
|
290
|
+
const since = parseSince(args, 60);
|
|
291
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
292
|
+
const cfg = (0, config_1.loadConfig)();
|
|
293
|
+
try {
|
|
294
|
+
const { status, json } = await (0, client_1.remoteListNew)(cfg, channel, since);
|
|
295
|
+
out(json);
|
|
296
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function cmdRemoteRead(args) {
|
|
304
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
305
|
+
const target = (0, address_1.normalizeAddress)(positionals[0]);
|
|
306
|
+
if (!target) {
|
|
307
|
+
out({ error: 'Usage: snazi remote-read <sender> [--channel <id>] [--since <minutes>]' });
|
|
308
|
+
return 2;
|
|
309
|
+
}
|
|
310
|
+
const since = parseSince(args, 60);
|
|
311
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
312
|
+
const cfg = (0, config_1.loadConfig)();
|
|
313
|
+
try {
|
|
314
|
+
const { status, json } = await (0, client_1.remoteRead)(cfg, target, channel, since);
|
|
315
|
+
out(json);
|
|
316
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function cmdRemoteCheck(args) {
|
|
324
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
325
|
+
const target = (0, address_1.normalizeAddress)(positionals[0]);
|
|
326
|
+
if (!target) {
|
|
327
|
+
out({ error: 'Usage: snazi remote-check <sender> --channel <id>' });
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
331
|
+
const cfg = (0, config_1.loadConfig)();
|
|
332
|
+
try {
|
|
333
|
+
const { status, json } = await (0, client_1.remoteCheck)(cfg, target, channel);
|
|
334
|
+
out(json);
|
|
335
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function cmdRemoteResolve(args) {
|
|
343
|
+
// Name may be empty (-> whole address book). Treat the first positional as
|
|
344
|
+
// the query; absent -> ''.
|
|
345
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
346
|
+
const name = positionals[0] ?? '';
|
|
347
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
348
|
+
const cfg = (0, config_1.loadConfig)();
|
|
349
|
+
try {
|
|
350
|
+
const { status, json } = await (0, client_1.remoteResolve)(cfg, name, channel);
|
|
351
|
+
out(json);
|
|
352
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function cmdRemoteLabel(args) {
|
|
360
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
361
|
+
const target = (0, address_1.normalizeAddress)(positionals[0]);
|
|
362
|
+
const name = flag(args, '--name');
|
|
363
|
+
if (!target || !name) {
|
|
364
|
+
out({
|
|
365
|
+
error: 'Usage: snazi remote-label <sender> --name <name> [--channel <id>]',
|
|
366
|
+
});
|
|
367
|
+
return 2;
|
|
368
|
+
}
|
|
369
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
370
|
+
const cfg = (0, config_1.loadConfig)();
|
|
371
|
+
try {
|
|
372
|
+
const { status, json } = await (0, client_1.remoteLabel)(cfg, target, channel, name);
|
|
373
|
+
out(json);
|
|
374
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function cmdRemoteStatus() {
|
|
382
|
+
const cfg = (0, config_1.loadConfig)();
|
|
383
|
+
try {
|
|
384
|
+
const { status, json } = await (0, client_1.remoteHealth)(cfg);
|
|
385
|
+
out({ remoteUrl: cfg.remoteUrl ?? null, health_status: status, health: json });
|
|
386
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function cmdStatus() {
|
|
394
|
+
const cfg = (0, config_1.loadConfig)();
|
|
395
|
+
const reachable = await (0, api_1.ping)(cfg);
|
|
396
|
+
out({
|
|
397
|
+
config_path: config_1.CONFIG_PATH,
|
|
398
|
+
platform: `${process.platform}/${process.arch}`,
|
|
399
|
+
node: process.versions.node,
|
|
400
|
+
apiUrl: cfg.apiUrl,
|
|
401
|
+
apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 6)}…(${cfg.apiKey.length})` : null,
|
|
402
|
+
channels: cfg.channels ?? [],
|
|
403
|
+
server_reachable: reachable,
|
|
404
|
+
});
|
|
405
|
+
return reachable ? 0 : 1;
|
|
406
|
+
}
|
|
407
|
+
function usage() {
|
|
408
|
+
console.log(`snazi — on-demand message gate ("No messages for you.")
|
|
409
|
+
|
|
410
|
+
Setup:
|
|
411
|
+
snazi init [--api-url <url>] [--token <tok>] [--channel <id>] [--force] [--yes]
|
|
412
|
+
Create/update ~/.snazi/config.json (interactive if a TTY)
|
|
413
|
+
snazi doctor Diagnose Node, config, connectivity, and channel access
|
|
414
|
+
|
|
415
|
+
Usage:
|
|
416
|
+
snazi list-new [--channel <id>] [--since <minutes>] Show WHO messaged + approval status (default 60m)
|
|
417
|
+
snazi read <sender> [--channel <id>] [--since <min>] Show message text — only if sender is approved
|
|
418
|
+
snazi check <sender> --channel <id> Print one sender's approval status
|
|
419
|
+
snazi channels list List configured channels + adapter availability here
|
|
420
|
+
snazi channels add <channel> Add a channel (e.g. imessage)
|
|
421
|
+
snazi cache clear Drop the cached approval statuses (force fresh checks)
|
|
422
|
+
snazi status Show config + platform + server connectivity
|
|
423
|
+
|
|
424
|
+
Approval status is cached on disk for a short TTL (default 5m; set
|
|
425
|
+
checkCacheTtlMs in config.json or SNAZI_CHECK_CACHE_TTL_MS) so repeated calls
|
|
426
|
+
don't re-hit the API. Pass --fresh to read/check/list-new to bypass it, or run
|
|
427
|
+
'snazi cache clear' right after you revoke someone.
|
|
428
|
+
|
|
429
|
+
Approvals are READ-ONLY here: approve/deny a sender in the web dashboard or via
|
|
430
|
+
a signed /decide link. The config token is a per-account READ token.
|
|
431
|
+
|
|
432
|
+
Serve mode (least-privilege HTTP gate for a remote agent over a tailnet):
|
|
433
|
+
snazi serve [--bind <ip>] [--port <n>] Start read-only HTTP gate (/health,/list-new,/check,/read)
|
|
434
|
+
snazi serve --install-daemon [--bind <ip>] [--port <n>] Install the launchd LaunchAgent (RunAtLoad/KeepAlive)
|
|
435
|
+
|
|
436
|
+
Remote client (the trusted agent side, calls a remote 'snazi serve'):
|
|
437
|
+
snazi remote-status Probe remoteUrl /health
|
|
438
|
+
snazi remote-list-new [--channel <id>] [--since <min>] WHO messaged on the remote host + status
|
|
439
|
+
snazi remote-check <sender> --channel <id> One sender's status (remote)
|
|
440
|
+
snazi remote-read <sender> [--channel <id>] [--since <min>] Message text (remote) — only if approved
|
|
441
|
+
snazi remote-resolve [<name>] [--channel <id>] Resolve a name → sender address(es) (empty = address book)
|
|
442
|
+
snazi remote-label <sender> --name <name> [--channel <id>] Set a sender's display name (label only; cannot open the gate)
|
|
443
|
+
|
|
444
|
+
The server manages an approve/deny list only. It stores no messages.
|
|
445
|
+
serve is READ-ONLY (no approve/deny over HTTP), bearer-token protected, and
|
|
446
|
+
binds the tailnet IP (100.x) or 127.0.0.1 — never 0.0.0.0.`);
|
|
447
|
+
}
|
|
448
|
+
async function main() {
|
|
449
|
+
const argv = process.argv.slice(2);
|
|
450
|
+
const cmd = argv[0];
|
|
451
|
+
const rest = argv.slice(1);
|
|
452
|
+
let code = 0;
|
|
453
|
+
switch (cmd) {
|
|
454
|
+
case 'init':
|
|
455
|
+
code = await cmdInit(rest);
|
|
456
|
+
break;
|
|
457
|
+
case 'doctor':
|
|
458
|
+
code = await cmdDoctor();
|
|
459
|
+
break;
|
|
460
|
+
case 'list-new':
|
|
461
|
+
code = await cmdListNew(rest);
|
|
462
|
+
break;
|
|
463
|
+
case 'read':
|
|
464
|
+
code = await cmdRead(rest);
|
|
465
|
+
break;
|
|
466
|
+
case 'check':
|
|
467
|
+
code = await cmdCheck(rest);
|
|
468
|
+
break;
|
|
469
|
+
case 'channels':
|
|
470
|
+
code = await cmdChannels(rest);
|
|
471
|
+
break;
|
|
472
|
+
case 'cache':
|
|
473
|
+
code = await cmdCache(rest);
|
|
474
|
+
break;
|
|
475
|
+
case 'status':
|
|
476
|
+
code = await cmdStatus();
|
|
477
|
+
break;
|
|
478
|
+
case 'serve':
|
|
479
|
+
code = await cmdServe(rest);
|
|
480
|
+
break;
|
|
481
|
+
case 'remote-list-new':
|
|
482
|
+
code = await cmdRemoteListNew(rest);
|
|
483
|
+
break;
|
|
484
|
+
case 'remote-read':
|
|
485
|
+
code = await cmdRemoteRead(rest);
|
|
486
|
+
break;
|
|
487
|
+
case 'remote-check':
|
|
488
|
+
code = await cmdRemoteCheck(rest);
|
|
489
|
+
break;
|
|
490
|
+
case 'remote-resolve':
|
|
491
|
+
code = await cmdRemoteResolve(rest);
|
|
492
|
+
break;
|
|
493
|
+
case 'remote-label':
|
|
494
|
+
code = await cmdRemoteLabel(rest);
|
|
495
|
+
break;
|
|
496
|
+
case 'remote-status':
|
|
497
|
+
code = await cmdRemoteStatus();
|
|
498
|
+
break;
|
|
499
|
+
case undefined:
|
|
500
|
+
case '-h':
|
|
501
|
+
case '--help':
|
|
502
|
+
case 'help':
|
|
503
|
+
usage();
|
|
504
|
+
code = 0;
|
|
505
|
+
break;
|
|
506
|
+
default:
|
|
507
|
+
out({ error: `Unknown command: ${cmd}` });
|
|
508
|
+
usage();
|
|
509
|
+
code = 2;
|
|
510
|
+
}
|
|
511
|
+
process.exit(code);
|
|
512
|
+
}
|
|
513
|
+
main().catch((e) => {
|
|
514
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
515
|
+
process.exit(1);
|
|
516
|
+
});
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.remoteListNew = remoteListNew;
|
|
4
|
+
exports.remoteRead = remoteRead;
|
|
5
|
+
exports.remoteResolve = remoteResolve;
|
|
6
|
+
exports.remoteLabel = remoteLabel;
|
|
7
|
+
exports.remoteCheck = remoteCheck;
|
|
8
|
+
exports.remoteHealth = remoteHealth;
|
|
9
|
+
const NO_STORE = { cache: 'no-store' };
|
|
10
|
+
function remoteBase(cfg) {
|
|
11
|
+
if (!cfg.remoteUrl) {
|
|
12
|
+
throw new Error('remoteUrl not set in ~/.snazi/config.json. Add the remote serve base URL ' +
|
|
13
|
+
'(e.g. "http://100.x.y.z:8787").');
|
|
14
|
+
}
|
|
15
|
+
if (!cfg.remoteToken) {
|
|
16
|
+
throw new Error('remoteToken not set in ~/.snazi/config.json. Add the bearer token that ' +
|
|
17
|
+
'matches the remote host\'s serveToken.');
|
|
18
|
+
}
|
|
19
|
+
return { url: cfg.remoteUrl.replace(/\/+$/, ''), token: cfg.remoteToken };
|
|
20
|
+
}
|
|
21
|
+
async function getJson(base, token, pathAndQuery) {
|
|
22
|
+
const res = await fetch(`${base}${pathAndQuery}`, {
|
|
23
|
+
headers: { authorization: `Bearer ${token}` },
|
|
24
|
+
...NO_STORE,
|
|
25
|
+
});
|
|
26
|
+
let json;
|
|
27
|
+
try {
|
|
28
|
+
json = await res.json();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
json = { error: `Non-JSON response: HTTP ${res.status}` };
|
|
32
|
+
}
|
|
33
|
+
return { status: res.status, json };
|
|
34
|
+
}
|
|
35
|
+
async function postJson(base, token, path, body) {
|
|
36
|
+
const res = await fetch(`${base}${path}`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
authorization: `Bearer ${token}`,
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
...NO_STORE,
|
|
44
|
+
});
|
|
45
|
+
let json;
|
|
46
|
+
try {
|
|
47
|
+
json = await res.json();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
json = { error: `Non-JSON response: HTTP ${res.status}` };
|
|
51
|
+
}
|
|
52
|
+
return { status: res.status, json };
|
|
53
|
+
}
|
|
54
|
+
/** Remote equivalent of `snazi list-new`. */
|
|
55
|
+
async function remoteListNew(cfg, channel, since) {
|
|
56
|
+
const { url, token } = remoteBase(cfg);
|
|
57
|
+
const q = `/list-new?channel=${encodeURIComponent(channel)}&since=${since}`;
|
|
58
|
+
return getJson(url, token, q);
|
|
59
|
+
}
|
|
60
|
+
/** Remote equivalent of `snazi read` (gate enforced server-side). */
|
|
61
|
+
async function remoteRead(cfg, sender, channel, since) {
|
|
62
|
+
const { url, token } = remoteBase(cfg);
|
|
63
|
+
const q = `/read?sender=${encodeURIComponent(sender)}&channel=${encodeURIComponent(channel)}&since=${since}`;
|
|
64
|
+
return getJson(url, token, q);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a name to sender address(es) via the remote serve /resolve endpoint.
|
|
68
|
+
* Empty/omitted name returns the whole address book (every labelled sender).
|
|
69
|
+
* Returns address+label+status only — never message text.
|
|
70
|
+
*/
|
|
71
|
+
async function remoteResolve(cfg, name, channel) {
|
|
72
|
+
const { url, token } = remoteBase(cfg);
|
|
73
|
+
const q = `/resolve?name=${encodeURIComponent(name)}&channel=${encodeURIComponent(channel)}`;
|
|
74
|
+
return getJson(url, token, q);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Set a sender's display label via the remote serve POST /label endpoint.
|
|
78
|
+
* The serve host performs an UPDATE-only write — it cannot create a row or
|
|
79
|
+
* change approval status, so this can never open the gate.
|
|
80
|
+
*/
|
|
81
|
+
async function remoteLabel(cfg, sender, channel, name) {
|
|
82
|
+
const { url, token } = remoteBase(cfg);
|
|
83
|
+
return postJson(url, token, '/label', { sender, channel, name });
|
|
84
|
+
}
|
|
85
|
+
/** Remote equivalent of `snazi check`. */
|
|
86
|
+
async function remoteCheck(cfg, sender, channel) {
|
|
87
|
+
const { url, token } = remoteBase(cfg);
|
|
88
|
+
const q = `/check?sender=${encodeURIComponent(sender)}&channel=${encodeURIComponent(channel)}`;
|
|
89
|
+
return getJson(url, token, q);
|
|
90
|
+
}
|
|
91
|
+
/** Connectivity probe against a remote serve `/health`. */
|
|
92
|
+
async function remoteHealth(cfg) {
|
|
93
|
+
if (!cfg.remoteUrl) {
|
|
94
|
+
throw new Error('remoteUrl not set in ~/.snazi/config.json.');
|
|
95
|
+
}
|
|
96
|
+
const base = cfg.remoteUrl.replace(/\/+$/, '');
|
|
97
|
+
const res = await fetch(`${base}/health`, { ...NO_STORE });
|
|
98
|
+
let json;
|
|
99
|
+
try {
|
|
100
|
+
json = await res.json();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
json = { error: `Non-JSON response: HTTP ${res.status}` };
|
|
104
|
+
}
|
|
105
|
+
return { status: res.status, json };
|
|
106
|
+
}
|