@nodium.io/cli 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 By-Tech.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # @nodium.io/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@nodium.io/cli.svg)](https://www.npmjs.com/package/@nodium.io/cli)
4
+ [![license](https://img.shields.io/npm/l/@nodium.io/cli.svg)](./LICENSE)
5
+
6
+ The `nodium` command for the [Nodium](https://nodium.io) WhatsApp API: replay
7
+ your webhooks to your local server, signed like production, and send WhatsApp
8
+ messages from the terminal. Built on
9
+ [`@nodium.io/whatsapp`](https://www.npmjs.com/package/@nodium.io/whatsapp).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g @nodium.io/cli # or run it once: npx @nodium.io/cli …
15
+ export NODIUM_KEY=nod_… # Settings › API keys in the console
16
+ ```
17
+
18
+ ## Listen
19
+
20
+ No tunnel, no public address: it reads your events from Nodium and replays
21
+ each one to a URL on your machine.
22
+
23
+ ```bash
24
+ export NODIUM_KEY=nod_…
25
+ nodium listen --forward http://localhost:3000/webhook --secret whsec_… --events message.received,message.failed
26
+ ```
27
+
28
+ ```
29
+ Listening from now → http://localhost:3000/webhook
30
+ 10:32:01 message.received 200 12 ms evt_0123…
31
+ 10:32:07 message.failed 500 4 ms evt_4567… [crm-4187]
32
+ ```
33
+
34
+ - Each call is the body of a real delivery and carries the real headers
35
+ (`X-Nodium-Event`, `X-Nodium-Delivery`, and `X-Nodium-Signature: t=…,v1=…` when
36
+ you give `--secret`, or `NODIUM_WEBHOOK_SECRET`). The signature is computed
37
+ locally with the secret you give: use the one of your webhook subscription, or
38
+ any string your code also uses to verify.
39
+ - It reads the event log (`GET /events`) from a cursor, so a dropped connection
40
+ loses nothing; `Ctrl+C` stops it and prints the number to resume from
41
+ (`--from <seq>`). The key needs the `admin` role.
42
+ - It acts on the events of every client of the account, unless `--tenant <id>`
43
+ names one or the key is pinned to one. `--events` filters; `--verbose` prints
44
+ each event's `data`; `--interval <ms>` sets the polling pace (default 1000).
45
+ - A call your server answers with a non-`2xx` status, or not at all, is shown
46
+ and not retried.
47
+
48
+ ## Send
49
+
50
+ To make something happen, send a message to yourself:
51
+
52
+ ```bash
53
+ nodium send --channel <id> --to +33612345678 --text "Hello"
54
+ nodium send --channel <id> --to +33612345678 --template order_ready --language en --var 1=Karim
55
+ ```
56
+
57
+ `--base-url` (or `NODIUM_BASE_URL`) changes the API address; `nodium --help` and
58
+ `nodium listen --help` list everything. Exit code 0 on success, 1 when Nodium or
59
+ Meta refuses (the `error.code` and the request id are printed), 2 for a wrong
60
+ command line.
61
+
62
+ ## License
63
+
64
+ MIT © By-Tech.io
package/dist/args.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Les arguments de la ligne de commande, sans dépendance : `--nom valeur`,
3
+ * `--nom=valeur`, un drapeau sans valeur (`--verbose`), `--` pour finir.
4
+ *
5
+ * ⚠️ Un drapeau qui prend une valeur prend le mot SUIVANT tel quel, même s'il
6
+ * commence par `--` : `--text "--hello"` envoie `--hello`.
7
+ */
8
+ /** Une faute d'usage : message lisible, code de sortie 2, aucune trace d'appel. */
9
+ export class UsageError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'UsageError';
13
+ }
14
+ }
15
+ /** Les drapeaux qui ne prennent jamais de valeur. */
16
+ const SWITCHES = new Set(['help', 'version', 'verbose', 'no-color']);
17
+ /** Les abréviations d'une lettre. */
18
+ const SHORT = { h: 'help', v: 'version' };
19
+ export function parseArgs(argv) {
20
+ const flags = new Map();
21
+ const words = [];
22
+ const add = (name, value) => flags.set(name, [...(flags.get(name) ?? []), value]);
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === '--') {
26
+ words.push(...argv.slice(i + 1));
27
+ break;
28
+ }
29
+ const short = /^-([a-zA-Z])$/.exec(arg);
30
+ if (short) {
31
+ const name = SHORT[short[1]];
32
+ if (!name)
33
+ throw new UsageError(`Unknown option ${arg}.`);
34
+ add(name, 'true');
35
+ continue;
36
+ }
37
+ if (!arg.startsWith('--')) {
38
+ words.push(arg);
39
+ continue;
40
+ }
41
+ const eq = arg.indexOf('=');
42
+ const name = eq < 0 ? arg.slice(2) : arg.slice(2, eq);
43
+ if (!name)
44
+ throw new UsageError(`Unknown option ${arg}.`);
45
+ if (SWITCHES.has(name)) {
46
+ add(name, 'true');
47
+ continue;
48
+ }
49
+ if (eq >= 0) {
50
+ add(name, arg.slice(eq + 1));
51
+ continue;
52
+ }
53
+ const value = argv[++i];
54
+ if (value === undefined)
55
+ throw new UsageError(`--${name} needs a value.`);
56
+ add(name, value);
57
+ }
58
+ return { command: words.shift() ?? null, positionals: words, flags };
59
+ }
60
+ /** La dernière valeur d'un drapeau, ou `undefined`. */
61
+ export function flag(args, name) {
62
+ const values = args.flags.get(name);
63
+ return values?.[values.length - 1];
64
+ }
65
+ /** Toutes les valeurs d'un drapeau répété. */
66
+ export function flagAll(args, name) {
67
+ return args.flags.get(name) ?? [];
68
+ }
69
+ export function hasFlag(args, name) {
70
+ return args.flags.has(name);
71
+ }
72
+ /** Refuse un drapeau que la commande ne connaît pas : une faute de frappe ne passe pas en silence. */
73
+ export function rejectUnknown(args, allowed) {
74
+ for (const name of args.flags.keys()) {
75
+ if (!allowed.includes(name))
76
+ throw new UsageError(`Unknown option --${name} for \`${args.command}\`. Try \`nodium ${args.command} --help\`.`);
77
+ }
78
+ if (args.positionals.length)
79
+ throw new UsageError(`Unexpected argument "${args.positionals[0]}". Try \`nodium ${args.command} --help\`.`);
80
+ }
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './run.js';
3
+ /**
4
+ * Le point d'entrée de `nodium` (`bin` du paquet). Ctrl+C et SIGTERM arrêtent
5
+ * proprement : la boucle de `listen` finit l'événement en cours, puis la
6
+ * commande dit où reprendre. Un second Ctrl+C sort tout de suite.
7
+ */
8
+ const argv = process.argv.slice(2);
9
+ const controller = new AbortController();
10
+ const stop = () => {
11
+ if (controller.signal.aborted)
12
+ process.exit(130);
13
+ controller.abort();
14
+ process.stderr.write('\nStopping…\n');
15
+ };
16
+ process.on('SIGINT', stop);
17
+ process.on('SIGTERM', stop);
18
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && !argv.includes('--no-color');
19
+ run(argv, {
20
+ env: process.env,
21
+ signal: controller.signal,
22
+ output: {
23
+ out: text => process.stdout.write(`${text}\n`),
24
+ err: text => process.stderr.write(`${text}\n`),
25
+ color
26
+ }
27
+ }).then((code) => { process.exitCode = code; }, (error) => {
28
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
29
+ process.exitCode = 1;
30
+ });
package/dist/listen.js ADDED
@@ -0,0 +1,200 @@
1
+ import { NodiumError, WEBHOOK_EVENTS } from '@nodium.io/whatsapp';
2
+ import { signWebhook } from '@nodium.io/whatsapp/server';
3
+ import { UsageError } from './args.js';
4
+ import { clock, paint } from './output.js';
5
+ /**
6
+ * `nodium listen` : les événements de votre compte, rejoués vers une adresse
7
+ * locale, signés comme en production. Aucun tunnel : c'est la commande qui
8
+ * interroge Nodium, jamais l'inverse.
9
+ *
10
+ * Elle lit le JOURNAL D'ÉVÉNEMENTS (`GET /events`, depuis un curseur) : chaque
11
+ * ligne est l'enveloppe exacte d'un webhook, plus son `seq`. Le direct du
12
+ * journal de la console (`GET /events/stream`) porte d'autres lignes (messages,
13
+ * livraisons, appels) et non ces enveloppes : il ne sert pas ici.
14
+ *
15
+ * ⚠️ Le corps envoyé est l'enveloppe SANS `seq`, sérialisée une fois ; la
16
+ * signature est calculée sur ces octets (`t=<s>,v1=hmac-sha256(secret, "<t>.<corps>")`,
17
+ * la même fonction que `signWebhook` de `@nodium.io/whatsapp/server`). Un récepteur qui vérifie sur
18
+ * le corps brut accepte l'appel comme en production.
19
+ * ⚠️ Le curseur avance après chaque page rejouée, que l'adresse locale ait
20
+ * répondu ou non : un échec local n'est pas rejoué (production le fait ;
21
+ * ici, relancez avec `--from <seq>` ou rejouez depuis la console).
22
+ * ⚠️ `message.imported` ne sonne jamais en production : il n'est rejoué que si
23
+ * `--events` le nomme.
24
+ */
25
+ /** Le délai d'attente de l'adresse locale : celui de Nodium en production. */
26
+ export const FORWARD_TIMEOUT_MS = 10_000;
27
+ /** Événements lus par appel (le défaut de l'API est 100, son plafond 500). */
28
+ const PAGE = 100;
29
+ /** Attente entre deux lectures du journal quand il n'y a rien de neuf, en millisecondes. */
30
+ export const DEFAULT_INTERVAL_MS = 1000;
31
+ const MIN_INTERVAL_MS = 250;
32
+ /** L'attente maximale entre deux essais quand Nodium est injoignable. */
33
+ const BACKOFF_CAP_MS = 15_000;
34
+ /**
35
+ * L'appel que Nodium ferait : le corps (l'enveloppe, sans `seq`) et les
36
+ * en-têtes. Sans `secret`, pas d'en-tête de signature.
37
+ */
38
+ export function buildDelivery(entry, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
39
+ const envelope = Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'seq'));
40
+ const body = JSON.stringify(envelope);
41
+ return {
42
+ body,
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'User-Agent': 'Nodium-Webhooks/1',
46
+ 'X-Nodium-Event': entry.event,
47
+ 'X-Nodium-Delivery': `local-${entry.id}`,
48
+ ...(secret ? { 'X-Nodium-Signature': signWebhook(secret, nowSeconds, body) } : {})
49
+ }
50
+ };
51
+ }
52
+ /**
53
+ * `--events message.received,message.failed` → l'ensemble voulu ; `null` pour
54
+ * « tous ceux qui sonnent ». `*` veut dire tous, comme dans un abonnement.
55
+ */
56
+ export function parseEventFilter(text) {
57
+ if (text === undefined)
58
+ return null;
59
+ const names = text.split(',').map(name => name.trim()).filter(Boolean);
60
+ if (!names.length)
61
+ throw new UsageError('--events needs at least one event name.');
62
+ if (names.includes('*'))
63
+ return null;
64
+ const known = new Set(WEBHOOK_EVENTS);
65
+ const unknown = names.filter(name => !known.has(name));
66
+ if (unknown.length)
67
+ throw new UsageError(`Unknown event ${unknown.map(n => `"${n}"`).join(', ')}. Known: ${WEBHOOK_EVENTS.join(', ')}.`);
68
+ return new Set(names);
69
+ }
70
+ /** Cet événement est-il rejoué ? Sans filtre : tous, sauf l'historique importé. */
71
+ export function wanted(filter, event) {
72
+ return filter ? filter.has(event) : event !== 'message.imported';
73
+ }
74
+ /** L'adresse locale : http ou https, rien d'autre. */
75
+ export function parseForwardUrl(text) {
76
+ if (!text)
77
+ throw new UsageError('--forward <url> is required, for example --forward http://localhost:3000/webhook.');
78
+ let url;
79
+ try {
80
+ url = new URL(text);
81
+ }
82
+ catch {
83
+ throw new UsageError(`--forward "${text}" is not a valid URL. Example: http://localhost:3000/webhook.`);
84
+ }
85
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
86
+ throw new UsageError('--forward must be an http:// or https:// URL.');
87
+ return url.toString();
88
+ }
89
+ async function forwardOne(url, delivery, fetchImpl) {
90
+ const started = performance.now();
91
+ try {
92
+ const response = await fetchImpl(url, {
93
+ method: 'POST',
94
+ headers: delivery.headers,
95
+ body: delivery.body,
96
+ redirect: 'manual',
97
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS)
98
+ });
99
+ await response.arrayBuffer().catch(() => { });
100
+ return { status: response.status, ms: Math.round(performance.now() - started), error: null };
101
+ }
102
+ catch (cause) {
103
+ const ms = Math.round(performance.now() - started);
104
+ if (cause?.name === 'TimeoutError' || cause?.name === 'AbortError') {
105
+ return { status: null, ms, error: `no answer within ${FORWARD_TIMEOUT_MS / 1000} s (Nodium waits as long in production)` };
106
+ }
107
+ const code = cause?.cause?.code ?? cause?.code;
108
+ if (code === 'ECONNREFUSED')
109
+ return { status: null, ms, error: `connection refused: is your server listening on ${new URL(url).host}?` };
110
+ return { status: null, ms, error: String(cause?.cause?.message ?? cause?.message ?? cause) };
111
+ }
112
+ }
113
+ /** Une attente que l'arrêt interrompt. */
114
+ export function pause(ms, signal) {
115
+ return new Promise((resolve) => {
116
+ if (signal.aborted)
117
+ return resolve();
118
+ const done = () => {
119
+ clearTimeout(timer);
120
+ signal.removeEventListener('abort', done);
121
+ resolve();
122
+ };
123
+ const timer = setTimeout(done, ms);
124
+ signal.addEventListener('abort', done, { once: true });
125
+ });
126
+ }
127
+ /** Ce qui ne se réessaie pas : la clé, le rôle, le client visé. */
128
+ function isFatal(error) {
129
+ return error instanceof NodiumError && (error.status === 401 || error.status === 403 || error.status === 404);
130
+ }
131
+ export async function listen(options) {
132
+ const { scope, signal, output } = options;
133
+ const fetchImpl = options.fetch ?? globalThis.fetch;
134
+ const sleep = options.sleep ?? pause;
135
+ const intervalMs = Math.max(MIN_INTERVAL_MS, options.intervalMs);
136
+ const summary = { forwarded: 0, failed: 0, after: 0 };
137
+ // Un premier appel sans `after` rend la tête du journal : le curseur du « maintenant ».
138
+ summary.after = options.from ?? (await scope.events.list()).next_after;
139
+ output.out(`${paint(output, 'bold', 'Listening')} ${options.from === null ? 'from now' : `after event #${options.from}`} → ${options.forward}`);
140
+ output.out(paint(output, 'dim', options.secret
141
+ ? 'Calls are signed with your secret: verify X-Nodium-Signature as in production.'
142
+ : 'No --secret: calls carry no X-Nodium-Signature header.'));
143
+ output.out(paint(output, 'dim', 'Press Ctrl+C to stop.'));
144
+ let failures = 0;
145
+ while (!signal.aborted) {
146
+ let full;
147
+ try {
148
+ const page = await scope.events.list(summary.after, PAGE);
149
+ if (failures) {
150
+ output.out(paint(output, 'green', 'Connection with Nodium restored.'));
151
+ failures = 0;
152
+ }
153
+ for (const entry of page.data) {
154
+ if (signal.aborted)
155
+ break;
156
+ if (wanted(options.events, entry.event))
157
+ await deliver(entry, options, fetchImpl, summary);
158
+ summary.after = entry.seq;
159
+ }
160
+ // Une page rejouée en entier : tout ce qui précède `next_after` est rendu ou n'est pas à vous.
161
+ if (!signal.aborted)
162
+ summary.after = page.next_after;
163
+ full = page.data.length >= PAGE;
164
+ }
165
+ catch (error) {
166
+ if (error instanceof NodiumError && error.code === 'cursor_expired') {
167
+ // Le journal garde 30 jours : un curseur plus vieux est repris à la tête.
168
+ summary.after = (await scope.events.list()).next_after;
169
+ output.err(paint(output, 'yellow', 'The cursor is older than the event log (30 days): restarting from now.'));
170
+ continue;
171
+ }
172
+ if (isFatal(error))
173
+ throw error;
174
+ failures++;
175
+ const wait = Math.min(BACKOFF_CAP_MS, intervalMs * 2 ** failures);
176
+ output.err(paint(output, 'yellow', `Nodium is unreachable (${error instanceof NodiumError ? error.code : 'error'}); retrying in ${Math.round(wait / 1000)} s from event #${summary.after}.`));
177
+ await sleep(wait, signal);
178
+ continue;
179
+ }
180
+ if (!full)
181
+ await sleep(intervalMs, signal);
182
+ }
183
+ return summary;
184
+ }
185
+ /** Rejoue un événement et en écrit la ligne. */
186
+ async function deliver(entry, options, fetchImpl, summary) {
187
+ const { output } = options;
188
+ const result = await forwardOne(options.forward, buildDelivery(entry, options.secret), fetchImpl);
189
+ summary.forwarded++;
190
+ const ok = result.status !== null && result.status >= 200 && result.status < 300;
191
+ if (!ok)
192
+ summary.failed++;
193
+ const client = entry.external_id ? ` [${entry.external_id}]` : '';
194
+ const status = paint(output, ok ? 'green' : 'red', result.status === null ? 'ERR' : String(result.status));
195
+ output.out(`${paint(output, 'dim', clock())} ${entry.event.padEnd(24)} ${status} ${String(result.ms).padStart(5)} ms ${paint(output, 'dim', entry.id + client)}`);
196
+ if (result.error)
197
+ output.out(` ${paint(output, 'red', result.error)}`);
198
+ if (options.verbose)
199
+ output.out(paint(output, 'dim', JSON.stringify(entry.data, null, 2).replace(/^/gm, ' ')));
200
+ }
package/dist/output.js ADDED
@@ -0,0 +1,39 @@
1
+ import { NodiumError } from '@nodium.io/whatsapp';
2
+ import { UsageError } from './args.js';
3
+ const CODES = { dim: 2, red: 31, green: 32, yellow: 33, cyan: 36, bold: 1 };
4
+ export function paint(output, style, text) {
5
+ return output.color ? `\u001b[${CODES[style]}m${text}\u001b[0m` : text;
6
+ }
7
+ /** L'heure locale, `HH:MM:SS`. */
8
+ export function clock(date = new Date()) {
9
+ return date.toTimeString().slice(0, 8);
10
+ }
11
+ /** Les codes qui n'ont pas de ligne dans le guide des erreurs. */
12
+ const NO_GUIDE = new Set(['unknown_error', 'network_error', 'timeout']);
13
+ /**
14
+ * Une erreur en quelques lignes : le statut et le `code` (stable, c'est lui
15
+ * qu'on cherche dans la documentation), le texte, l'identifiant à donner au
16
+ * support, et la ligne du guide des erreurs.
17
+ */
18
+ export function describeError(error, baseUrl) {
19
+ if (error instanceof UsageError)
20
+ return error.message;
21
+ if (!(error instanceof NodiumError))
22
+ return error instanceof Error ? error.message : String(error);
23
+ const lines = [`${error.status ? `Error ${error.status}` : 'Error'} ${error.code}: ${error.message}`];
24
+ if (error.status === 401)
25
+ lines.push('The key is missing, malformed or revoked: check NODIUM_KEY (or --key).');
26
+ if (error.status === 403)
27
+ lines.push('This key does not have the role the command needs (`listen` reads the event log: `admin`).');
28
+ if (error.retryAfter !== null)
29
+ lines.push(`Retry in ${error.retryAfter} s.`);
30
+ if (error.requestId)
31
+ lines.push(`Request id: ${error.requestId}`);
32
+ if (error.status > 0 && !NO_GUIDE.has(error.code)) {
33
+ try {
34
+ lines.push(`More: ${new URL(baseUrl).origin}/en/docs/api/guide/responses#${error.code}`);
35
+ }
36
+ catch { /* adresse illisible : pas de lien */ }
37
+ }
38
+ return lines.join('\n');
39
+ }
package/dist/run.js ADDED
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { DEFAULT_BASE_URL, Nodium } from '@nodium.io/whatsapp';
4
+ import { UsageError, flag, hasFlag, parseArgs, rejectUnknown } from './args.js';
5
+ import { DEFAULT_INTERVAL_MS, listen, parseEventFilter, parseForwardUrl } from './listen.js';
6
+ import { describeError, paint } from './output.js';
7
+ import { readSendInput, send } from './send.js';
8
+ /**
9
+ * La commande `nodium` : lit la ligne de commande, branche le client, rend le
10
+ * code de sortie (0 réussi, 1 refusé par Nodium ou par Meta, 2 mal appelé).
11
+ *
12
+ * ⚠️ La clé se donne de préférence par `NODIUM_KEY` : `--key` reste dans
13
+ * l'historique du terminal.
14
+ */
15
+ const HELP = `nodium: the Nodium API from your terminal.
16
+
17
+ Usage
18
+ nodium listen --forward <url> [--secret <whsec_…>] [--events a,b]
19
+ nodium send --channel <id> --to <+33…> --text "…"
20
+
21
+ Commands
22
+ listen Replay your events to a local URL, signed like production: no tunnel.
23
+ send Send a WhatsApp message, to try things.
24
+
25
+ Options for every command
26
+ --key <nod_…> API key. Default: $NODIUM_KEY.
27
+ --tenant <id> Act for one client: its id or your own externalId.
28
+ --base-url <url> API address. Default: ${DEFAULT_BASE_URL} ($NODIUM_BASE_URL).
29
+ --no-color No colors (also: $NO_COLOR).
30
+ -h, --help This help, or the help of a command.
31
+ -v, --version The version.
32
+ `;
33
+ const HELP_LISTEN = `nodium listen: replay your events to your own server, without a tunnel.
34
+
35
+ Usage
36
+ nodium listen --forward <url> [options]
37
+
38
+ Options
39
+ --forward <url> Your local address, for example http://localhost:3000/webhook. Required.
40
+ --secret <whsec> Signs each call with X-Nodium-Signature (t=…,v1=…), as in production.
41
+ Use the secret of your webhook subscription, or any string your code also
42
+ uses to verify: the signature is computed here. Default: $NODIUM_WEBHOOK_SECRET.
43
+ --events <a,b> Only these events (message.received,message.failed…). Default: all.
44
+ --from <seq> Start after this event number (the "Resume with" line printed on exit).
45
+ Default: from now.
46
+ --interval <ms> Wait between two reads when nothing is new. Default: ${DEFAULT_INTERVAL_MS}.
47
+ --verbose Also print each event's data.
48
+
49
+ The key needs the admin role: it reads the event log (GET /events). Events of
50
+ every client of the account arrive, unless --tenant names one or the key is
51
+ pinned to one. A call your server answers with a non-2xx status, or not at
52
+ all, is shown and not retried.
53
+ `;
54
+ const HELP_SEND = `nodium send: send a WhatsApp message.
55
+
56
+ Usage
57
+ nodium send --channel <id> --to <+33612345678> --text "Hello"
58
+ nodium send --channel <id> --to <+33612345678> --template order_ready --language en --var 1=Karim
59
+
60
+ Options
61
+ --channel <id> The number to send from (GET /channels). Required.
62
+ --to <number> The recipient, in international format. Or --recipient wa:… for a
63
+ contact who shows no number.
64
+ --text <text> Free text: only within 24 hours of the person's last message.
65
+ --template <name> An approved template: the only way to write first.
66
+ --language <code> The template language, for example en or fr.
67
+ --var <n=value> A template variable (repeat it): --var 1=Karim --var 2=Friday.
68
+ --idempotency-key <k> Your own anti-duplicate key (8 to 200 characters).
69
+ `;
70
+ function packageVersion() {
71
+ try {
72
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
73
+ return String(JSON.parse(readFileSync(file, 'utf8')).version);
74
+ }
75
+ catch {
76
+ return 'unknown';
77
+ }
78
+ }
79
+ /** Les drapeaux qu'accepte toute commande. */
80
+ const COMMON = ['key', 'tenant', 'base-url', 'no-color', 'help'];
81
+ export async function run(argv, context) {
82
+ const { output, env } = context;
83
+ let baseUrl = env.NODIUM_BASE_URL || DEFAULT_BASE_URL;
84
+ try {
85
+ const args = parseArgs(argv);
86
+ if (hasFlag(args, 'version')) {
87
+ output.out(packageVersion());
88
+ return 0;
89
+ }
90
+ if (!args.command || args.command === 'help') {
91
+ output.out(HELP);
92
+ return args.command || hasFlag(args, 'help') ? 0 : 2;
93
+ }
94
+ if (args.command !== 'listen' && args.command !== 'send') {
95
+ throw new UsageError(`Unknown command "${args.command}". Try \`nodium --help\`.`);
96
+ }
97
+ if (hasFlag(args, 'help')) {
98
+ output.out(args.command === 'listen' ? HELP_LISTEN : HELP_SEND);
99
+ return 0;
100
+ }
101
+ baseUrl = flag(args, 'base-url') ?? baseUrl;
102
+ const key = flag(args, 'key') ?? env.NODIUM_KEY;
103
+ if (!key)
104
+ throw new UsageError('No API key: set NODIUM_KEY (or pass --key). Create one in the console, under Settings › API keys.');
105
+ const nodium = new Nodium(key, { baseUrl, ...(context.fetch ? { fetch: context.fetch } : {}) });
106
+ const tenant = flag(args, 'tenant');
107
+ const scope = tenant ? nodium.as(tenant) : nodium;
108
+ return args.command === 'listen'
109
+ ? await runListen(args, scope, baseUrl, env, context)
110
+ : await runSend(args, scope, output);
111
+ }
112
+ catch (error) {
113
+ output.err(paint(output, 'red', describeError(error, baseUrl)));
114
+ return error instanceof UsageError ? 2 : 1;
115
+ }
116
+ }
117
+ async function runListen(args, scope, baseUrl, env, context) {
118
+ rejectUnknown(args, [...COMMON, 'forward', 'secret', 'events', 'from', 'interval', 'verbose']);
119
+ const forward = parseForwardUrl(flag(args, 'forward'));
120
+ const events = parseEventFilter(flag(args, 'events'));
121
+ const from = readInteger(args, 'from', 0);
122
+ const interval = readInteger(args, 'interval', 1);
123
+ const summary = await listen({
124
+ scope, baseUrl, forward, events,
125
+ secret: flag(args, 'secret') ?? env.NODIUM_WEBHOOK_SECRET ?? null,
126
+ from,
127
+ intervalMs: interval ?? DEFAULT_INTERVAL_MS,
128
+ verbose: hasFlag(args, 'verbose'),
129
+ signal: context.signal,
130
+ output: context.output,
131
+ ...(context.fetch ? { fetch: context.fetch } : {})
132
+ });
133
+ context.output.out(`Stopped. ${summary.forwarded} forwarded, ${summary.failed} not answered with a 2xx. Resume with --from ${summary.after}.`);
134
+ return 0;
135
+ }
136
+ async function runSend(args, scope, output) {
137
+ rejectUnknown(args, [...COMMON, 'channel', 'to', 'recipient', 'text', 'template', 'language', 'var', 'idempotency-key']);
138
+ return await send(scope, readSendInput(args), output);
139
+ }
140
+ /** Un entier au moins égal à `min`, ou `null` si le drapeau est absent. */
141
+ function readInteger(args, name, min) {
142
+ const text = flag(args, name);
143
+ if (text === undefined)
144
+ return null;
145
+ const value = Number(text);
146
+ if (!Number.isInteger(value) || value < min)
147
+ throw new UsageError(`--${name} must be a whole number, ${min} or more.`);
148
+ return value;
149
+ }
package/dist/send.js ADDED
@@ -0,0 +1,70 @@
1
+ import { UsageError, flag, flagAll } from './args.js';
2
+ import { paint } from './output.js';
3
+ /**
4
+ * `nodium send` : un message, pour essayer. Du texte libre (dans les 24 h qui
5
+ * suivent le dernier message de la personne) ou un modèle approuvé (le seul
6
+ * envoi possible pour écrire en premier).
7
+ *
8
+ * ⚠️ Un envoi refusé par Meta répond HTTP 200 : la commande lit
9
+ * `message.status` et sort en erreur (code 1) quand il vaut `failed`.
10
+ */
11
+ /** Ce que la commande a lu de la ligne de commande, vérifié. */
12
+ export function readSendInput(args) {
13
+ const channelId = flag(args, 'channel');
14
+ if (!channelId)
15
+ throw new UsageError('--channel <id> is required: the number to send from (`GET /channels`).');
16
+ const to = flag(args, 'to');
17
+ const recipient = flag(args, 'recipient');
18
+ if (!to && !recipient)
19
+ throw new UsageError('--to <+33612345678> is required (or --recipient wa:… for a contact without a number).');
20
+ if (to && recipient)
21
+ throw new UsageError('Give --to or --recipient, never both.');
22
+ const address = to ? { to } : { recipient: recipient };
23
+ const text = flag(args, 'text');
24
+ const template = flag(args, 'template');
25
+ if (text !== undefined && template)
26
+ throw new UsageError('Give --text or --template, not both.');
27
+ if (text === undefined && !template)
28
+ throw new UsageError('Nothing to send: give --text "…" or --template <name>.');
29
+ const idempotencyKey = flag(args, 'idempotency-key');
30
+ const base = { channelId, ...(idempotencyKey ? { idempotencyKey } : {}) };
31
+ if (text !== undefined)
32
+ return { ...base, ...address, type: 'text', text };
33
+ const variables = {};
34
+ for (const pair of flagAll(args, 'var')) {
35
+ const eq = pair.indexOf('=');
36
+ if (eq <= 0)
37
+ throw new UsageError(`--var "${pair}" must look like 1=Karim (the number of the {{variable}}, then its value).`);
38
+ variables[pair.slice(0, eq)] = pair.slice(eq + 1);
39
+ }
40
+ const language = flag(args, 'language');
41
+ return {
42
+ ...base, ...address, type: 'template', templateName: template,
43
+ ...(language ? { templateLanguage: language } : {}),
44
+ ...(Object.keys(variables).length ? { variables } : {})
45
+ };
46
+ }
47
+ /** Envoie, écrit le résultat, rend le code de sortie. */
48
+ export async function send(scope, input, output) {
49
+ const result = await scope.messages.send(input);
50
+ const message = result.message;
51
+ const target = input.to ?? input.recipient;
52
+ if (!message) {
53
+ output.out(`Sent to ${target}.`);
54
+ return 0;
55
+ }
56
+ if (message.status === 'failed') {
57
+ output.err(paint(output, 'red', `Message ${message.id} to ${target} failed.`));
58
+ if (message.errorInfo) {
59
+ output.err(`${message.errorInfo.title}`);
60
+ output.err(`What to do (${message.errorInfo.actor}): ${message.errorInfo.action}`);
61
+ }
62
+ else if (message.error) {
63
+ output.err(message.error);
64
+ }
65
+ return 1;
66
+ }
67
+ output.out(`${paint(output, 'green', message.status)} message ${message.id} to ${target}${result.duplicate ? ' (already sent with this idempotency key: nothing left again)' : ''}`);
68
+ output.out(paint(output, 'dim', `Follow it: GET /messages/${message.id}, or the message.delivered and message.read events.`));
69
+ return 0;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@nodium.io/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The nodium command: replay your Nodium webhooks to your local server, signed like production, and send WhatsApp messages from the terminal.",
5
+ "keywords": ["nodium", "cli", "whatsapp", "whatsapp-api", "whatsapp-cloud-api", "webhooks", "local-development"],
6
+ "homepage": "https://nodium.io/en/docs/api",
7
+ "license": "MIT",
8
+ "author": "By-Tech.io",
9
+ "type": "module",
10
+ "engines": { "node": ">=18" },
11
+ "bin": { "nodium": "./dist/index.js" },
12
+ "files": ["dist", "README.md", "LICENSE"],
13
+ "publishConfig": { "access": "public" },
14
+ "scripts": {
15
+ "build": "node scripts/build.mjs",
16
+ "typecheck": "node scripts/build.mjs --check",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "@nodium.io/whatsapp": "^0.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^26.4.0",
24
+ "typescript": "^5.9.2"
25
+ }
26
+ }