wazap-mcp 0.9.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/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/auth-state.js +116 -0
- package/dist/banner.js +7 -0
- package/dist/cli.js +372 -0
- package/dist/config.js +138 -0
- package/dist/connect.js +222 -0
- package/dist/doctor.js +114 -0
- package/dist/errors.js +48 -0
- package/dist/ids.js +50 -0
- package/dist/index.js +88 -0
- package/dist/lock.js +42 -0
- package/dist/logger.js +16 -0
- package/dist/messages.js +281 -0
- package/dist/ratelimit.js +35 -0
- package/dist/server.js +133 -0
- package/dist/settings.js +74 -0
- package/dist/tools.js +591 -0
- package/dist/wa-types.js +2 -0
- package/dist/whatsapp.js +1514 -0
- package/package.json +60 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import makeWASocket, { DisconnectReason, } from "baileys";
|
|
5
|
+
import qrcode from "qrcode";
|
|
6
|
+
import qrcodeTerminal from "qrcode-terminal";
|
|
7
|
+
import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
8
|
+
import { BANNER } from "./banner.js";
|
|
9
|
+
import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
|
|
10
|
+
import { CONNECT_HINT } from "./connect.js";
|
|
11
|
+
import { checkLine, runChecks } from "./doctor.js";
|
|
12
|
+
import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
|
|
13
|
+
import { normalizePhone } from "./ids.js";
|
|
14
|
+
import { lockHolder, releaseLock, writeLock } from "./lock.js";
|
|
15
|
+
import { log, logError, say } from "./logger.js";
|
|
16
|
+
import { formatAge } from "./messages.js";
|
|
17
|
+
import { runHttp, runStdio } from "./server.js";
|
|
18
|
+
import { applyWrites } from "./settings.js";
|
|
19
|
+
import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
|
|
20
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
21
|
+
const LIVE_TIMEOUT_MS = 15_000;
|
|
22
|
+
/** Connection states the probe stops waiting on. */
|
|
23
|
+
const SETTLED_STATUSES = [
|
|
24
|
+
"connected",
|
|
25
|
+
"not_linked",
|
|
26
|
+
"logged_out",
|
|
27
|
+
"session_corrupt",
|
|
28
|
+
"auth_failure",
|
|
29
|
+
];
|
|
30
|
+
const LOGOUT_TIMEOUT_MS = 10_000;
|
|
31
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
|
|
32
|
+
const SILENT_LOGGER = {
|
|
33
|
+
level: "silent",
|
|
34
|
+
child: () => SILENT_LOGGER,
|
|
35
|
+
trace: () => { },
|
|
36
|
+
debug: () => { },
|
|
37
|
+
info: () => { },
|
|
38
|
+
warn: () => { },
|
|
39
|
+
error: () => { },
|
|
40
|
+
};
|
|
41
|
+
export async function runStatus(config) {
|
|
42
|
+
const p = paths(config.dataDir);
|
|
43
|
+
let account = null;
|
|
44
|
+
let unreadable = false;
|
|
45
|
+
try {
|
|
46
|
+
account = readLinkedAccount(p.authDir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
unreadable = true;
|
|
50
|
+
}
|
|
51
|
+
const report = {
|
|
52
|
+
data_dir: config.dataDir,
|
|
53
|
+
linked: account !== null,
|
|
54
|
+
credentials_readable: !unreadable,
|
|
55
|
+
account,
|
|
56
|
+
wazap_version: WAZAP_VERSION,
|
|
57
|
+
baileys_version: BAILEYS_VERSION,
|
|
58
|
+
server_pid: lockHolder(p.lockFile),
|
|
59
|
+
checks: await runChecks(config),
|
|
60
|
+
};
|
|
61
|
+
if (config.live)
|
|
62
|
+
report.live = await runLiveProbe(config);
|
|
63
|
+
if (config.json) {
|
|
64
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
65
|
+
if (config.live)
|
|
66
|
+
process.exit(0);
|
|
67
|
+
return report;
|
|
68
|
+
}
|
|
69
|
+
say(`data dir: ${report.data_dir}`);
|
|
70
|
+
if (unreadable) {
|
|
71
|
+
say("linked: no (credentials unreadable — run `wazap logout` then `wazap login`)");
|
|
72
|
+
}
|
|
73
|
+
else if (account) {
|
|
74
|
+
say("linked: yes");
|
|
75
|
+
say(`account: ${describeAccount(account)}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
say("linked: no");
|
|
79
|
+
}
|
|
80
|
+
say(`wazap: ${report.wazap_version}`);
|
|
81
|
+
say(`baileys: ${report.baileys_version}`);
|
|
82
|
+
say(report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`);
|
|
83
|
+
say("");
|
|
84
|
+
say("checks:");
|
|
85
|
+
for (const check of report.checks)
|
|
86
|
+
say(checkLine(check));
|
|
87
|
+
if (report.live) {
|
|
88
|
+
say("");
|
|
89
|
+
for (const line of liveLines(report.live))
|
|
90
|
+
say(line);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
return report;
|
|
94
|
+
}
|
|
95
|
+
function liveLines(live) {
|
|
96
|
+
if (!live.reachable)
|
|
97
|
+
return [`live: no connection (${live.reason ?? "unknown"})`];
|
|
98
|
+
return [
|
|
99
|
+
"live: phone reachable",
|
|
100
|
+
`live: ${live.chats === null ? "chats not synced in time" : `${live.chats} chats synced`}`,
|
|
101
|
+
`live: last message ${live.last_message_age ?? "unknown"}`,
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/** One process owns the session, so a probe only runs when no server holds the lock. */
|
|
105
|
+
async function runLiveProbe(config) {
|
|
106
|
+
const p = paths(config.dataDir);
|
|
107
|
+
const running = lockHolder(p.lockFile);
|
|
108
|
+
if (running !== null) {
|
|
109
|
+
throw new WazapError("WHATSAPP_ERROR", `A server (pid ${running}) already owns this session.`, "Ask it through your MCP client instead: call get_status");
|
|
110
|
+
}
|
|
111
|
+
// The probe owns the session for as long as it runs, exactly like the server.
|
|
112
|
+
writeLock(p.lockFile);
|
|
113
|
+
const wa = new WhatsAppService(config);
|
|
114
|
+
const deadline = Date.now() + LIVE_TIMEOUT_MS;
|
|
115
|
+
try {
|
|
116
|
+
await wa.start();
|
|
117
|
+
let info = wa.getStatus();
|
|
118
|
+
while (!SETTLED_STATUSES.includes(info.status) && Date.now() < deadline) {
|
|
119
|
+
await sleep(250);
|
|
120
|
+
info = wa.getStatus();
|
|
121
|
+
}
|
|
122
|
+
if (info.status !== "connected") {
|
|
123
|
+
return { reachable: false, chats: null, last_message_age: null, reason: info.last_error ?? info.status };
|
|
124
|
+
}
|
|
125
|
+
// listChats waits on its own sync gate, which would outlast the deadline.
|
|
126
|
+
const chats = await Promise.race([
|
|
127
|
+
wa.listChats("all", 100_000).then((synced) => synced.data),
|
|
128
|
+
sleep(Math.max(0, deadline - Date.now()), null, { ref: false }),
|
|
129
|
+
]).catch(() => null);
|
|
130
|
+
const newest = (chats ?? [])
|
|
131
|
+
.map((chat) => (chat.last_message === null ? 0 : Date.parse(chat.last_message.timestamp)))
|
|
132
|
+
.reduce((a, b) => Math.max(a, b), 0);
|
|
133
|
+
return {
|
|
134
|
+
reachable: true,
|
|
135
|
+
chats: chats === null ? null : chats.length,
|
|
136
|
+
last_message_age: newest === 0 ? null : formatAge(newest),
|
|
137
|
+
reason: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await wa.stop();
|
|
142
|
+
releaseLock(p.lockFile);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
|
|
146
|
+
export async function runGreet(config) {
|
|
147
|
+
say(BANNER);
|
|
148
|
+
say("");
|
|
149
|
+
const report = await runStatus(config);
|
|
150
|
+
say("");
|
|
151
|
+
if (report.server_pid !== null) {
|
|
152
|
+
say(`A server is already running (pid ${report.server_pid}).`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!report.credentials_readable) {
|
|
156
|
+
say("Next: wazap logout (then wazap login)");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
say(report.linked
|
|
160
|
+
? 'Next: wazap connect claude-code (then ask your agent: "what did I miss on WhatsApp today?")'
|
|
161
|
+
: "Next: wazap login");
|
|
162
|
+
}
|
|
163
|
+
export async function runServe(config) {
|
|
164
|
+
const p = paths(config.dataDir);
|
|
165
|
+
const running = lockHolder(p.lockFile);
|
|
166
|
+
if (running !== null) {
|
|
167
|
+
say(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`);
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
// Loopback with no token only gets runHttp's warning; off-loopback is refused.
|
|
171
|
+
if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
|
|
172
|
+
say(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
176
|
+
writeLock(p.lockFile);
|
|
177
|
+
process.on("exit", () => releaseLock(p.lockFile));
|
|
178
|
+
const wa = new WhatsAppService(config);
|
|
179
|
+
const shutdown = (signal) => {
|
|
180
|
+
log(`received ${signal}, shutting down`);
|
|
181
|
+
// A wedged socket must not cost the user a kill -9; the lock goes on "exit".
|
|
182
|
+
setTimeout(() => process.exit(0), 3_000).unref();
|
|
183
|
+
void wa.stop().finally(() => process.exit(0));
|
|
184
|
+
};
|
|
185
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
186
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
187
|
+
// Connecting in the background: MCP startup never waits on WhatsApp, and the
|
|
188
|
+
// tools answer NOT_LINKED until a session exists.
|
|
189
|
+
wa.start().catch((err) => logError("whatsapp start", err));
|
|
190
|
+
if (config.transport === "http")
|
|
191
|
+
await runHttp(wa, config);
|
|
192
|
+
else
|
|
193
|
+
await runStdio(wa, config);
|
|
194
|
+
}
|
|
195
|
+
export async function runLogin(config) {
|
|
196
|
+
const p = paths(config.dataDir);
|
|
197
|
+
const linked = readLinkedAccount(p.authDir);
|
|
198
|
+
if (linked) {
|
|
199
|
+
say(`Already linked as ${describeAccount(linked)}. Run \`wazap logout\` to relink.`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const phone = config.loginQr ? null : normalizePhone(config.loginPhone ?? (await askPhone()));
|
|
203
|
+
let requested = false;
|
|
204
|
+
const onQr = async (qr, sock) => {
|
|
205
|
+
if (phone === null) {
|
|
206
|
+
qrcodeTerminal.generate(qr, { small: true }, (art) => say(art));
|
|
207
|
+
await qrcode.toFile(p.qrFile, qr);
|
|
208
|
+
say(`WhatsApp → Settings → Linked devices → Link a device, then scan the code above (also saved to ${p.qrFile}).`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (requested)
|
|
212
|
+
return;
|
|
213
|
+
requested = true;
|
|
214
|
+
const code = await sock.requestPairingCode(phone);
|
|
215
|
+
say("");
|
|
216
|
+
say("WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead");
|
|
217
|
+
say("");
|
|
218
|
+
say(` ${code.slice(0, 4)}-${code.slice(4)}`);
|
|
219
|
+
say("");
|
|
220
|
+
};
|
|
221
|
+
const sock = await linkSession(p.authDir, { deadline: Date.now() + LOGIN_TIMEOUT_MS, onQr });
|
|
222
|
+
const account = await settledAccount(sock, p.authDir);
|
|
223
|
+
say(`Linked ✅ as ${describeAccount(account)}`);
|
|
224
|
+
await sock.end(undefined);
|
|
225
|
+
await offerWrites(config);
|
|
226
|
+
say(CONNECT_HINT);
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
export async function runLogout(config) {
|
|
230
|
+
const p = paths(config.dataDir);
|
|
231
|
+
const running = lockHolder(p.lockFile);
|
|
232
|
+
if (running !== null) {
|
|
233
|
+
say(`wazap is running (pid ${running}). Stop it first, then run \`wazap logout\`.`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
let linked = null;
|
|
237
|
+
let unreadable = false;
|
|
238
|
+
try {
|
|
239
|
+
linked = readLinkedAccount(p.authDir);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Unreadable creds are exactly what logout exists to clear, so keep going.
|
|
243
|
+
unreadable = true;
|
|
244
|
+
}
|
|
245
|
+
if (!linked && !unreadable) {
|
|
246
|
+
say("Not linked.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (linked) {
|
|
250
|
+
const deadline = Date.now() + LOGOUT_TIMEOUT_MS;
|
|
251
|
+
try {
|
|
252
|
+
const sock = await linkSession(p.authDir, { deadline });
|
|
253
|
+
await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
logError("unlink from WhatsApp", err);
|
|
257
|
+
say("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed.");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
clearAuth(p.authDir);
|
|
261
|
+
rmSync(p.storeFile, { force: true });
|
|
262
|
+
say("Logged out. Local credentials deleted.");
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
function describeAccount(account) {
|
|
266
|
+
return account.name ? `${account.name} (${account.number})` : account.number;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Writes stay off unless the user says otherwise, so a fresh link cannot message
|
|
270
|
+
* anyone. A non-interactive login leaves the setting alone rather than guessing.
|
|
271
|
+
*/
|
|
272
|
+
export async function offerWrites(config) {
|
|
273
|
+
if (config.writesAnswer !== null) {
|
|
274
|
+
applyWrites(config, config.writesAnswer);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (config.assumeYes || !process.stdin.isTTY)
|
|
278
|
+
return;
|
|
279
|
+
const answer = await ask("Allow the agent to send messages, react and manage chats? [y/N] ");
|
|
280
|
+
applyWrites(config, /^y(es)?$/i.test(answer.trim()));
|
|
281
|
+
}
|
|
282
|
+
async function ask(question) {
|
|
283
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
284
|
+
try {
|
|
285
|
+
return await rl.question(question);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
rl.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function askPhone() {
|
|
292
|
+
return ask("Phone number in international format (e.g. +40722123456): ");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* A socket run until it is open, retrying across the restart WhatsApp demands
|
|
296
|
+
* right after a successful pairing. The returned socket is the caller's to end.
|
|
297
|
+
*/
|
|
298
|
+
async function linkSession(authDir, opts) {
|
|
299
|
+
let current = null;
|
|
300
|
+
let expired = false;
|
|
301
|
+
const timer = setTimeout(() => {
|
|
302
|
+
expired = true;
|
|
303
|
+
void current?.end(undefined);
|
|
304
|
+
}, Math.max(0, opts.deadline - Date.now()));
|
|
305
|
+
try {
|
|
306
|
+
for (;;) {
|
|
307
|
+
// Also checked here: a restart landing just before the deadline would
|
|
308
|
+
// otherwise open a socket the timer can no longer reach.
|
|
309
|
+
if (expired)
|
|
310
|
+
throw timedOut();
|
|
311
|
+
const { state, saveCreds } = await useAtomicAuthState(authDir);
|
|
312
|
+
const sock = makeWASocket({
|
|
313
|
+
auth: state,
|
|
314
|
+
browser: WA_BROWSER,
|
|
315
|
+
markOnlineOnConnect: false,
|
|
316
|
+
logger: SILENT_LOGGER,
|
|
317
|
+
});
|
|
318
|
+
current = sock;
|
|
319
|
+
sock.ev.on("creds.update", () => void saveCreds());
|
|
320
|
+
const attempt = await new Promise((resolve) => {
|
|
321
|
+
sock.ev.on("connection.update", (update) => {
|
|
322
|
+
if (update.qr && opts.onQr) {
|
|
323
|
+
void opts.onQr(update.qr, sock).catch((error) => resolve({ kind: "failed", error }));
|
|
324
|
+
}
|
|
325
|
+
if (update.connection === "open")
|
|
326
|
+
resolve({ kind: "open" });
|
|
327
|
+
if (update.connection === "close") {
|
|
328
|
+
const statusCode = update.lastDisconnect?.error
|
|
329
|
+
?.output?.statusCode;
|
|
330
|
+
resolve(statusCode === DisconnectReason.restartRequired ? { kind: "restart" } : { kind: "closed", statusCode });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
if (attempt.kind === "open")
|
|
335
|
+
return sock;
|
|
336
|
+
await sock.end(undefined);
|
|
337
|
+
current = null;
|
|
338
|
+
if (attempt.kind === "restart")
|
|
339
|
+
continue;
|
|
340
|
+
if (expired)
|
|
341
|
+
throw timedOut();
|
|
342
|
+
if (attempt.kind === "failed")
|
|
343
|
+
throw asWazapError(attempt.error);
|
|
344
|
+
if (attempt.statusCode === DisconnectReason.loggedOut) {
|
|
345
|
+
throw new WazapError("SESSION_EXPIRED", "WhatsApp rejected the link. The code may have expired or been entered wrong.", RELINK_FIX);
|
|
346
|
+
}
|
|
347
|
+
throw new WazapError("WHATSAPP_ERROR", `WhatsApp closed the connection (code ${attempt.statusCode ?? "unknown"}).`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function timedOut() {
|
|
355
|
+
return new WazapError("TIMEOUT", "WhatsApp did not answer in time.", "Check your connection and try again");
|
|
356
|
+
}
|
|
357
|
+
function withDeadline(work, deadline, message) {
|
|
358
|
+
let timer;
|
|
359
|
+
const guard = new Promise((_resolve, reject) => {
|
|
360
|
+
timer = setTimeout(() => reject(new WazapError("TIMEOUT", message)), Math.max(0, deadline - Date.now()));
|
|
361
|
+
});
|
|
362
|
+
return Promise.race([work, guard]).finally(() => clearTimeout(timer));
|
|
363
|
+
}
|
|
364
|
+
/** The freshly linked account. `creds.update` can land a beat after the connection opens. */
|
|
365
|
+
async function settledAccount(sock, authDir) {
|
|
366
|
+
const number = (sock.user?.id ?? "").split(":")[0].split("@")[0];
|
|
367
|
+
const fromSocket = { id: `${number}@s.whatsapp.net`, name: sock.user?.name ?? "", number };
|
|
368
|
+
if (fromSocket.name)
|
|
369
|
+
return fromSocket;
|
|
370
|
+
await sleep(750);
|
|
371
|
+
return readLinkedAccount(authDir) ?? fromSocket;
|
|
372
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import { WazapError } from "./errors.js";
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
export const WAZAP_VERSION = require("../package.json").version;
|
|
9
|
+
export const BAILEYS_VERSION = require("baileys/package.json").version;
|
|
10
|
+
export function paths(dataDir) {
|
|
11
|
+
return {
|
|
12
|
+
dataDir,
|
|
13
|
+
authDir: join(dataDir, "auth"),
|
|
14
|
+
mediaDir: join(dataDir, "media"),
|
|
15
|
+
historyDir: join(dataDir, "history"),
|
|
16
|
+
storeFile: join(dataDir, "store.json"),
|
|
17
|
+
lockFile: join(dataDir, "server.lock"),
|
|
18
|
+
envFile: join(dataDir, ".env"),
|
|
19
|
+
qrFile: join(dataDir, "qr.png"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** How many positionals each command takes after its own name. */
|
|
23
|
+
const COMMAND_ARGS = {
|
|
24
|
+
serve: [0],
|
|
25
|
+
login: [0],
|
|
26
|
+
status: [0],
|
|
27
|
+
logout: [0],
|
|
28
|
+
connect: [1],
|
|
29
|
+
config: [0, 2],
|
|
30
|
+
};
|
|
31
|
+
const COMMANDS = Object.keys(COMMAND_ARGS);
|
|
32
|
+
export function defaultDataDir() {
|
|
33
|
+
return resolve(join(homedir(), ".wazap"));
|
|
34
|
+
}
|
|
35
|
+
function asBool(value, fallback) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return fallback;
|
|
38
|
+
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
function asInt(value, fallback) {
|
|
41
|
+
const n = Number.parseInt((value ?? "").trim(), 10);
|
|
42
|
+
return Number.isFinite(n) ? n : fallback;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A human at a terminal running bare `wazap` wants to see where they stand, not
|
|
46
|
+
* a silent MCP server on stdin. Everything else serves, including `wazap serve`.
|
|
47
|
+
*/
|
|
48
|
+
export function pickDefaultAction(config, stdinTTY, stderrTTY) {
|
|
49
|
+
const human = config.command === "serve" && !config.explicitCommand && config.transport === "stdio" && stdinTTY && stderrTTY;
|
|
50
|
+
return human ? "greet" : "serve";
|
|
51
|
+
}
|
|
52
|
+
export function parseCli(argv = process.argv.slice(2)) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = parseArgs({
|
|
56
|
+
args: argv,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
options: {
|
|
59
|
+
"data-dir": { type: "string" },
|
|
60
|
+
"read-only": { type: "boolean" },
|
|
61
|
+
http: { type: "boolean" },
|
|
62
|
+
host: { type: "string" },
|
|
63
|
+
port: { type: "string" },
|
|
64
|
+
phone: { type: "string" },
|
|
65
|
+
qr: { type: "boolean" },
|
|
66
|
+
"dry-run": { type: "boolean" },
|
|
67
|
+
live: { type: "boolean" },
|
|
68
|
+
json: { type: "boolean" },
|
|
69
|
+
writes: { type: "boolean" },
|
|
70
|
+
"no-writes": { type: "boolean" },
|
|
71
|
+
yes: { type: "boolean", short: "y" },
|
|
72
|
+
help: { type: "boolean", short: "h" },
|
|
73
|
+
version: { type: "boolean", short: "v" },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new WazapError("INVALID_ID", err instanceof Error ? err.message : String(err), "Run `wazap --help`");
|
|
79
|
+
}
|
|
80
|
+
const { values, positionals } = parsed;
|
|
81
|
+
if (values.help)
|
|
82
|
+
return { kind: "help" };
|
|
83
|
+
if (values.version)
|
|
84
|
+
return { kind: "version" };
|
|
85
|
+
const [first, ...args] = positionals;
|
|
86
|
+
if (first !== undefined && !COMMANDS.includes(first)) {
|
|
87
|
+
throw new WazapError("INVALID_ID", `Unknown command "${first}".`, "Run `wazap --help`");
|
|
88
|
+
}
|
|
89
|
+
const command = first ?? "serve";
|
|
90
|
+
if (!COMMAND_ARGS[command].includes(args.length)) {
|
|
91
|
+
throw new WazapError("INVALID_ID", `Wrong arguments for \`wazap ${command}\`.`, "Run `wazap --help`");
|
|
92
|
+
}
|
|
93
|
+
const dataDir = resolve(values["data-dir"] ?? process.env.WAZAP_DATA_DIR ?? defaultDataDir());
|
|
94
|
+
// Snapshot before dotenv, which fills process.env from the data dir's .env
|
|
95
|
+
// without overriding what the real environment already set.
|
|
96
|
+
const shell = new Set(Object.keys(process.env).filter((key) => key.startsWith("WAZAP_")));
|
|
97
|
+
dotenv.config({ path: paths(dataDir).envFile, quiet: true });
|
|
98
|
+
const sourceOf = (key, flagged) => {
|
|
99
|
+
if (flagged)
|
|
100
|
+
return "flag";
|
|
101
|
+
if (shell.has(key))
|
|
102
|
+
return "env";
|
|
103
|
+
return process.env[key] === undefined ? "default" : ".env";
|
|
104
|
+
};
|
|
105
|
+
const httpFromEnv = process.env.WAZAP_TRANSPORT?.trim().toLowerCase() === "http";
|
|
106
|
+
return {
|
|
107
|
+
kind: "run",
|
|
108
|
+
config: {
|
|
109
|
+
dataDir,
|
|
110
|
+
readOnly: values["read-only"] === true || asBool(process.env.WAZAP_READ_ONLY, false),
|
|
111
|
+
syncFullHistory: asBool(process.env.WAZAP_SYNC_FULL_HISTORY, false),
|
|
112
|
+
persistHistory: asBool(process.env.WAZAP_PERSIST_HISTORY, true),
|
|
113
|
+
transport: values.http === true || httpFromEnv ? "http" : "stdio",
|
|
114
|
+
httpHost: values.host ?? (process.env.WAZAP_HOST?.trim() || "127.0.0.1"),
|
|
115
|
+
httpPort: values.port ? asInt(values.port, 8766) : asInt(process.env.WAZAP_PORT, 8766),
|
|
116
|
+
readToken: (process.env.WAZAP_READ_TOKEN ?? "").trim() || null,
|
|
117
|
+
writeToken: (process.env.WAZAP_WRITE_TOKEN ?? "").trim() || null,
|
|
118
|
+
rateLimitPerMinute: asInt(process.env.WAZAP_RATE_LIMIT, 20),
|
|
119
|
+
sources: {
|
|
120
|
+
// Resolved before dotenv runs, so the data dir's own .env cannot name it.
|
|
121
|
+
dataDir: values["data-dir"] !== undefined ? "flag" : shell.has("WAZAP_DATA_DIR") ? "env" : "default",
|
|
122
|
+
readOnly: sourceOf("WAZAP_READ_ONLY", values["read-only"] === true),
|
|
123
|
+
transport: sourceOf("WAZAP_TRANSPORT", values.http === true),
|
|
124
|
+
rateLimit: sourceOf("WAZAP_RATE_LIMIT", false),
|
|
125
|
+
},
|
|
126
|
+
command,
|
|
127
|
+
explicitCommand: first !== undefined,
|
|
128
|
+
args,
|
|
129
|
+
dryRun: values["dry-run"] === true,
|
|
130
|
+
live: values.live === true,
|
|
131
|
+
json: values.json === true,
|
|
132
|
+
loginPhone: values.phone,
|
|
133
|
+
loginQr: values.qr === true,
|
|
134
|
+
writesAnswer: values.writes === true ? true : values["no-writes"] === true ? false : null,
|
|
135
|
+
assumeYes: values.yes === true,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|