diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /src/cli.js | |
| download | wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip | |
Diffstat (limited to 'src/cli.js')
| -rw-r--r-- | src/cli.js | 379 |
1 files changed, 379 insertions, 0 deletions
diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..8324522 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,379 @@ +import { parseArgs } from 'node:util'; +import qrcode from 'qrcode-terminal'; +import { METHODS, getMethod, resources, schema, validate } from './api/index.js'; +import { accountName, paths, VERSION } from './config.js'; +import { FORMATS } from './format/index.js'; +import { HELPERS } from './helpers.js'; +import { parseJsonArg, print } from './output.js'; +import { WaError, call, ping } from './rpc.js'; +import { Session } from './session.js'; + +// Same exit codes as gws, plus 4 = daemon. +const EXIT = { EAPI: 1, ENOTFOUND: 1, ETIMEOUT: 1, EAUTH: 2, EINVAL: 3, EAMBIGUOUS: 3, EDAEMON: 4, EINTERNAL: 5 }; + +const GLOBAL_OPTIONS = { + account: { type: 'string', short: 'a' }, + format: { type: 'string' }, + params: { type: 'string' }, + json: { type: 'string' }, + output: { type: 'string', short: 'o' }, + 'no-autostart': { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean' }, +}; + +// CLI commands that are not daemon methods. +const LOCAL_COMMANDS = { + 'auth.login': 'Link this device (QR in the terminal, or --phone for a code)', + 'daemon.start': 'Start the daemon in the background (--foreground for systemd)', +}; +const LOCAL_ONLY = [ + ' wa format <FMT> < data.json', +]; + +const out = (s = '') => process.stdout.write(`${s}\n`); +const err = (s = '') => process.stderr.write(`${s}\n`); + +export async function main(argv) { + process.stdout.on('error', (e) => { + if (e.code === 'EPIPE') process.exit(0); + }); + try { + return await run(argv); + } catch (e) { + return fail(e); + } +} + +function fail(e) { + const known = e.code in EXIT; + err(`wa: ${e.message}`); + if (Array.isArray(e.data)) for (const c of e.data) err(` ${c.name ?? ''} <${c.id}>`); + if (!known && process.env.WA_CLI_DEBUG) err(e.stack); + return known ? EXIT[e.code] : (e.name === 'TypeError' || e.name === 'ReferenceError' ? 5 : 1); +} + +function parse(argv, extra = {}) { + try { + return parseArgs({ args: argv, options: { ...GLOBAL_OPTIONS, ...extra }, strict: true, allowPositionals: true }); + } catch (e) { + throw new WaError('EINVAL', e.message); + } +} + +async function run(argv) { + const pre = parseArgs({ args: argv, options: GLOBAL_OPTIONS, strict: false, allowPositionals: true }); + const [cmd, sub] = pre.positionals; + + if (pre.values.version) return out(VERSION), 0; + if (!cmd || cmd === 'help') { + if (sub && resources()[sub]) return resourceHelp(sub), 0; + return mainHelp(), 0; + } + if (pre.values.format && !FORMATS[pre.values.format]) { + throw new WaError('EINVAL', `unknown format: ${pre.values.format} (${Object.keys(FORMATS).join(', ')})`); + } + + // ---- helpers + if (cmd.startsWith('+')) { + const helper = HELPERS[cmd]; + if (!helper) throw new WaError('EINVAL', `unknown helper: ${cmd} (${Object.keys(HELPERS).join(', ')})`); + const spec = Object.fromEntries(Object.entries(helper.options || {}).map(([k, { description, ...o }]) => [k, o])); + const { values, positionals } = parse(argv, spec); + if (values.help) return helperHelp(cmd), 0; + return helper.run(sessionFor(values), positionals.slice(1), values); + } + + // ---- schema + if (cmd === 'schema') { + const { positionals } = parse(argv); + if (positionals[1]) return print(schema(positionals[1]), pre.values.format || 'json'), 0; + const all = Object.fromEntries(Object.entries(METHODS).map(([k, d]) => [k, d.description])); + return print(all, pre.values.format || 'json'), 0; + } + + // ---- format: JSON on stdin -> another format, without the daemon (e.g. in hooks) + if (cmd === 'format') { + const { values, positionals } = parse(argv, { columns: { type: 'string' } }); + const fmt = positionals[1] || values.format; + if (!fmt || values.help) { + out('Usage: wa format <FMT> [--columns a,b.c] < data.json\n'); + out('Converts JSON (the output of another `wa`) to another format without talking to the daemon.'); + out('E.g.: jq .data | wa format eml (inside a hook)'); + return fmt || values.help ? 0 : 3; + } + const columns = values.columns?.split(','); + print(parseJsonArg('-', 'stdin'), fmt, { columns, output: values.output }); + return 0; + } + + // ---- auth login + if (cmd === 'auth' && sub === 'login') { + const { values } = parse(argv, { phone: { type: 'string' } }); + if (values.help) { + out('Links this device to WhatsApp.\n'); + out('Usage: wa auth login [--phone NUMBER]\n'); + out('Without --phone it shows a QR: WhatsApp > Settings > Linked devices > Link a device.'); + out('With --phone (e.g. 5491122334455) it shows an 8-character code for'); + out('"Link with phone number instead" on that same screen.'); + return 0; + } + return authLogin(sessionFor(values), values.phone); + } + + // ---- daemon + if (cmd === 'daemon') return daemonCommand(argv, sub); + + // ---- generic API: wa <resource> <method> + const res = resources()[cmd]; + if (!res) throw new WaError('EINVAL', `unknown resource: ${cmd} (see \`wa --help\`)`); + if (!sub) return resourceHelp(cmd), pre.values.help ? 0 : 3; + const method = `${cmd}.${sub}`; + const def = getMethod(method); + const { values, positionals } = parse(argv); + if (values.help) return methodHelp(method), 0; + if (positionals.length > 2) { + throw new WaError('EINVAL', `unexpected argument: ${positionals[2]} (parameters go in --params '{...}')`); + } + const params = validate(method, { + ...parseJsonArg(values.params, '--params'), + ...parseJsonArg(values.json, '--json'), + }, { cwd: process.cwd() }); + const session = sessionFor(values); + + if (def.stream) { + await session.call(method, params, { onEvent: (ev) => print(ev, values.format || 'ndjson') }); + return 0; + } + const result = await session.call(method, params); + print(result, values.format || 'json', { columns: def.columns, output: values.output }); + return 0; +} + +function sessionFor(values) { + return new Session(accountName(values.account), values['no-autostart'] ? { autostart: false } : {}); +} + +// ---------------------------------------------------------------- auth login + +function authLogin(session, phone) { + return new Promise((resolve, reject) => { + let pairing = false; + let lastCode = null; + let result = 0; + const tty = process.stdout.isTTY; + + session.call('events.watch', { + events: ['qr', 'code', 'authenticated', 'ready', 'auth_failure', 'disconnected'], + replay: true, + }, { + onEvent: (ev, close) => { + switch (ev.event) { + case 'qr': + if (phone) { + if (pairing) return; + pairing = true; + err('wa: requesting a pairing code…'); + session.call('auth.pair', { phone }).catch((e) => { + close(); + reject(e); + }); + return; + } + if (tty) process.stdout.write('\x1b[2J\x1b[H'); + out('Scan the QR with WhatsApp: Settings > Linked devices > Link a device'); + out('(it refreshes every ~20 s; Ctrl-C to cancel)\n'); + qrcode.generate(ev.data.qr, { small: true }, (s) => out(s)); + return; + case 'code': + if (ev.data.code === lastCode) return; + lastCode = ev.data.code; + out(`Pairing code: ${ev.data.code.replace(/^(.{4})(.{4})$/, '$1-$2')}`); + out('On the phone: Linked devices > Link a device > Link with phone number instead'); + return; + case 'authenticated': + err('wa: authenticated, syncing chats (this may take a while)…'); + return; + case 'auth_failure': + err(`wa: authentication failed: ${ev.data.message}`); + return; + case 'ready': + out(`Ready: ${ev.data.me?.name ?? ''} <${ev.data.me?.id ?? '?'}> (account ${session.account})`); + close(); + return; + case 'disconnected': + err(`wa: disconnected (${ev.data.reason})`); + result = 4; + close(); + } + }, + }).then(() => resolve(result), reject); + }); +} + +// ---------------------------------------------------------------- daemon + +async function daemonCommand(argv, sub) { + const { values } = parse(argv, { foreground: { type: 'boolean' } }); + const account = accountName(values.account); + const P = paths(account); + const fmt = values.format || 'json'; + if (!sub || values.help) { + out('Usage: wa daemon <start|stop|status> [--account ACCOUNT]\n'); + out(' start [--foreground] starts the daemon (--foreground: in the foreground, for systemd)'); + out(' stop stops it (the session stays saved)'); + out(' status process and session status'); + out(`\nWith autostart (the default) any command starts it. Log: ${P.logFile}`); + return sub || values.help ? 0 : 3; + } + const notRunning = { running: false, account, socket: P.socket }; + switch (sub) { + case 'start': { + if (values.foreground) { + const { runDaemon } = await import('./daemon.js'); + await runDaemon(account); + return undefined; // the process stays alive with the socket + } + const session = new Session(account, { autostart: true }); + if (!(await session.running())) await session.startBackground(); + print(await session.call('daemon.status'), fmt); + return 0; + } + case 'stop': { + if (!(await ping(P.socket))) return print(notRunning, fmt), 0; + const { pid } = await call(P.socket, 'daemon.status'); + await call(P.socket, 'daemon.stop'); + // Wait for the process to exit (it closes Chrome), not just the socket. + const alive = () => { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === 'EPERM'; + } + }; + for (let i = 0; i < 200 && alive(); i++) await new Promise((r) => setTimeout(r, 100)); + if (alive()) throw new WaError('EDAEMON', `the daemon (pid ${pid}) did not exit within 20 s`); + print({ running: false, stopped: true, account, pid }, fmt); + return 0; + } + case 'status': { + if (!(await ping(P.socket))) return print(notRunning, fmt), 0; + print(await call(P.socket, 'daemon.status'), fmt); + return 0; + } + default: + throw new WaError('EINVAL', `daemon: unknown subcommand: ${sub}`); + } +} + +// ---------------------------------------------------------------- help + +function mainHelp() { + const res = resources(); + const lines = [ + 'wa — WhatsApp from the terminal (whatsapp-web.js)', + '', + 'USAGE:', + ' wa <resource> <method> [--params JSON] [--format FMT]', + ' wa +<helper> [arguments] [flags]', + ' wa schema [<resource.method>]', + ' wa auth login [--phone NUMBER]', + ' wa daemon start|stop|status', + ...LOCAL_ONLY, + '', + 'EXAMPLES:', + ' wa auth login', + ' wa chats list --params \'{"unread": true}\' --format table', + ' wa messages list --params \'{"chatId": "5491122334455@c.us", "limit": 20}\'', + ' wa +send "Mom" there in 10', + ' wa +read "Family" -n 50', + ' wa +watch --format text', + ' wa +export "Family" --media -o family.mbox && neomutt -R -f family.mbox', + '', + 'FLAGS:', + ' -a, --account <NAME> Account (session) to use; default "default" or $WA_CLI_ACCOUNT', + ' --params <JSON> Method parameters (JSON, @file or - for stdin)', + ' --json <JSON> Alias of --params (they are merged)', + ` --format <FMT> ${Object.keys(FORMATS).join(', ')} (default: json)`, + ' -o, --output <PATH> Write the output to a file', + ' --no-autostart Do not start the daemon if it is not running', + ' -h, --help Help (also: wa <resource> --help, wa <resource> <method> --help)', + '', + 'RESOURCES:', + ...Object.entries(res).map(([name, methods]) => { + const extra = Object.keys(LOCAL_COMMANDS).filter((k) => k.startsWith(`${name}.`)).map((k) => k.split('.')[1]); + return ` ${name.padEnd(10)} ${[...extra, ...Object.keys(methods)].join(', ')}`; + }), + '', + 'HELPERS:', + ...Object.entries(HELPERS).map(([name, h]) => ` ${name.padEnd(10)} ${h.summary}`), + '', + 'ENVIRONMENT:', + ' WA_CLI_ACCOUNT Default account', + ' WA_CLI_CONFIG_DIR Config and hooks directory (default: ~/.config/wa-cli)', + ' WA_CLI_DATA_DIR Sessions directory (default: ~/.local/share/wa-cli)', + ' WA_CLI_SOCKET Daemon socket (default: $XDG_RUNTIME_DIR/wa-cli/<account>.sock)', + ' WA_CLI_AUTOSTART 0 to not start the daemon automatically', + '', + 'EXIT CODES:', + ' 0 Success', + ' 1 WhatsApp error / not found', + ' 2 No session — run `wa auth login`', + ' 3 Validation — invalid arguments or parameters', + ' 4 Daemon — could not start or connect', + ' 5 Internal', + ]; + out(lines.join('\n')); +} + +function resourceHelp(name) { + const methods = resources()[name]; + out(`Usage: wa ${name} <method> [--params JSON] [--format FMT]\n`); + out('Methods:'); + for (const [k, d] of Object.entries(LOCAL_COMMANDS)) { + if (k.startsWith(`${name}.`)) out(` ${k.split('.')[1].padEnd(12)} ${d}`); + } + for (const [m, def] of Object.entries(methods)) out(` ${m.padEnd(12)} ${def.description}`); + out(`\nDetails: wa ${name} <method> --help | wa schema ${name}.<method>`); +} + +function methodHelp(method) { + const def = getMethod(method); + const [res, m] = method.split('.'); + out(`${def.description}\n`); + out(`Usage: wa ${res} ${m}${def.params ? ' --params \'{...}\'' : ''}${def.stream ? ' (NDJSON stream)' : ''}`); + if (def.params) { + out('\nParameters:'); + const rows = Object.entries(def.params).map(([k, p]) => { + const type = [p.type, p.required ? 'required' : null, p.default !== undefined ? `= ${JSON.stringify(p.default)}` : null] + .filter(Boolean).join(', '); + return [k, type, p.description]; + }); + const w0 = Math.max(...rows.map((r) => r[0].length)); + const w1 = Math.max(...rows.map((r) => r[1].length)); + for (const [k, t, d] of rows) out(` ${k.padEnd(w0)} ${t.padEnd(w1)} ${d}`); + } + if (def.columns) out(`\nColumns (--format table/csv): ${def.columns.join(', ')}`); + if (def.ready === false) out('\nDoes not need a ready session.'); +} + +function helperHelp(name) { + const h = HELPERS[name]; + out(`${h.summary}\n`); + out(`Usage: ${h.usage}`); + if (h.details) out(`\n${h.details}`); + const opts = Object.entries(h.options || {}); + if (opts.length) { + out('\nOptions:'); + for (const [k, o] of opts) { + const flag = `${o.short ? `-${o.short}, ` : ' '}--${k}${o.type === 'string' ? ' <value>' : ''}`; + out(` ${flag.padEnd(26)} ${o.description}`); + } + } + out(` ${'--format <FMT>'.padEnd(26)} Output format`); + if (h.examples) { + out('\nExamples:'); + for (const e of h.examples) out(` ${e}`); + } +} |