@nodium.io/cli 0.1.0 → 0.2.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/dist/run.js CHANGED
@@ -2,71 +2,64 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { DEFAULT_BASE_URL, Nodium } from '@nodium.io/whatsapp';
4
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';
5
+ import { COMMANDS, findCommands } from './commands/index.js';
6
+ import { configPath as defaultConfigPath, readConfig, resolveKey } from './config.js';
7
+ import { confirmWrite, outputMode } from './context.js';
8
+ import { pause } from './listen.js';
9
+ import { describeError, errorJson, paint, table } from './output.js';
8
10
  /**
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
+ * La commande `nodium` : lit la ligne de commande, cherche la commande dans la
12
+ * table (`commands/index.ts`), branche le client, rend le code de sortie
13
+ * (0 réussi, 1 refusé par Nodium ou par Meta, 2 mal appelé).
11
14
  *
12
- * ⚠️ La clé se donne de préférence par `NODIUM_KEY` : `--key` reste dans
13
- * l'historique du terminal.
15
+ * ⚠️ La clé se donne de préférence par `nodium login` ou `NODIUM_KEY` :
16
+ * `--key` reste dans l'historique du terminal.
14
17
  */
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 "…"
18
+ /** Les drapeaux qu'accepte toute commande. */
19
+ const COMMON = ['key', 'tenant', 'base-url', 'output', 'yes', 'no-color', 'help'];
20
+ const COMMON_HELP = `Options for every command
21
+ --key <nod_…> API key. Default: $NODIUM_KEY, then the key saved by \`nodium login\`.
22
+ --tenant <id> Act for one client: its id or your own externalId.
23
+ --output <json|human> json: the API's object, for scripts and agents. Default: human in a
24
+ terminal, json when piped.
25
+ -y, --yes Approve a command that sends or changes something, without asking.
26
+ Required when not in a terminal.
27
+ --base-url <url> API address. Default: ${DEFAULT_BASE_URL} ($NODIUM_BASE_URL).
28
+ --no-color No colors (also: $NO_COLOR).
29
+ -h, --help This help, or the help of a command.
30
+ -v, --version The version.`;
31
+ function usage(command) {
32
+ return ['nodium', ...command.path, ...(command.args ?? [])].join(' ');
33
+ }
34
+ export function globalHelp() {
35
+ return `nodium: the Nodium WhatsApp API from your terminal.
20
36
 
21
37
  Commands
22
- listen Replay your events to a local URL, signed like production: no tunnel.
23
- send Send a WhatsApp message, to try things.
38
+ ${table(COMMANDS.map(c => [` ${usage(c)}`, c.summary])).join('\n')}
39
+
40
+ ${COMMON_HELP}
24
41
 
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.
42
+ Exit codes: 0 done, 1 refused by Nodium or Meta, 2 wrong command line.
43
+ More: nodium help <command>, for example \`nodium help templates wait\`.
32
44
  `;
33
- const HELP_LISTEN = `nodium listen: replay your events to your own server, without a tunnel.
45
+ }
46
+ export function commandHelp(command) {
47
+ return `${usage(command)}: ${command.summary}
34
48
 
35
49
  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.
50
+ ${usage(command)}${command.flags?.length ? ' [options]' : ''}
51
+ ${command.help ? `\nOptions\n${command.help}\n` : ''}
52
+ Options for every command: \`nodium --help\`.
53
53
  `;
54
- const HELP_SEND = `nodium send: send a WhatsApp message.
54
+ }
55
+ function groupHelp(noun, commands) {
56
+ return `nodium ${noun}
55
57
 
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
58
+ ${table(commands.map(c => [` ${usage(c)}`, c.summary])).join('\n')}
59
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).
60
+ More: nodium help ${noun} <${commands.map(c => c.path[1]).join('|')}>
69
61
  `;
62
+ }
70
63
  function packageVersion() {
71
64
  try {
72
65
  const file = fileURLToPath(new URL('../package.json', import.meta.url));
@@ -76,74 +69,93 @@ function packageVersion() {
76
69
  return 'unknown';
77
70
  }
78
71
  }
79
- /** Les drapeaux qu'accepte toute commande. */
80
- const COMMON = ['key', 'tenant', 'base-url', 'no-color', 'help'];
72
+ const NO_IO = {
73
+ ask: async () => '',
74
+ readSecret: async () => '',
75
+ readStdin: async () => ''
76
+ };
77
+ /**
78
+ * La commande nommée par les mots de la ligne : `templates wait` ou `send`.
79
+ * Rend aussi les mots qui restent (ses arguments).
80
+ */
81
+ export function resolveCommand(words) {
82
+ const [noun, verb, ...more] = words;
83
+ if (!noun)
84
+ return { command: null, rest: [], group: [] };
85
+ const group = findCommands([noun]);
86
+ const exact = group.find(c => c.path.length === 1);
87
+ if (exact)
88
+ return { command: exact, rest: words.slice(1), group };
89
+ const command = verb ? group.find(c => c.path[1] === verb) ?? null : null;
90
+ return { command, rest: command ? more : [], group };
91
+ }
81
92
  export async function run(argv, context) {
82
93
  const { output, env } = context;
83
94
  let baseUrl = env.NODIUM_BASE_URL || DEFAULT_BASE_URL;
95
+ let json = false;
84
96
  try {
85
97
  const args = parseArgs(argv);
98
+ json = outputMode(flag(args, 'output'), Boolean(context.stdoutIsTty)) === 'json';
86
99
  if (hasFlag(args, 'version')) {
87
100
  output.out(packageVersion());
88
101
  return 0;
89
102
  }
90
- if (!args.command || args.command === 'help') {
91
- output.out(HELP);
92
- return args.command || hasFlag(args, 'help') ? 0 : 2;
103
+ // `nodium help templates wait` vaut `nodium templates wait --help`.
104
+ const asksHelp = args.command === 'help' || hasFlag(args, 'help');
105
+ const words = args.command === 'help' ? args.positionals : [args.command ?? '', ...args.positionals].filter(Boolean);
106
+ if (!words.length) {
107
+ output.out(globalHelp());
108
+ return asksHelp ? 0 : 2;
93
109
  }
94
- if (args.command !== 'listen' && args.command !== 'send') {
95
- throw new UsageError(`Unknown command "${args.command}". Try \`nodium --help\`.`);
110
+ const { command, rest, group } = resolveCommand(words);
111
+ if (!command) {
112
+ if (!group.length)
113
+ throw new UsageError(`Unknown command "${words[0]}". Try \`nodium --help\`.`);
114
+ if (asksHelp || words.length === 1) {
115
+ output.out(groupHelp(words[0], group));
116
+ return asksHelp ? 0 : 2;
117
+ }
118
+ throw new UsageError(`Unknown command "${words.slice(0, 2).join(' ')}". Try \`nodium help ${words[0]}\`.`);
96
119
  }
97
- if (hasFlag(args, 'help')) {
98
- output.out(args.command === 'listen' ? HELP_LISTEN : HELP_SEND);
120
+ if (asksHelp) {
121
+ output.out(commandHelp(command));
99
122
  return 0;
100
123
  }
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);
124
+ const name = command.path.join(' ');
125
+ const commandArgs = { ...args, command: name, positionals: rest };
126
+ rejectUnknown(commandArgs, [...COMMON, ...(command.flags ?? [])], name, command.args?.length ?? 0);
127
+ const path = context.configPath ?? defaultConfigPath(env);
128
+ const config = readConfig(path);
129
+ baseUrl = flag(args, 'base-url') ?? (env.NODIUM_BASE_URL || config.baseUrl || DEFAULT_BASE_URL);
130
+ let client = null;
131
+ const nodium = () => {
132
+ if (!client) {
133
+ const { key } = resolveKey(flag(args, 'key'), env, config);
134
+ client = new Nodium(key, { baseUrl, ...(context.fetch ? { fetch: context.fetch } : {}) });
135
+ }
136
+ return client;
137
+ };
138
+ const io = context.io ?? NO_IO;
139
+ const interactive = Boolean(context.interactive);
140
+ const ctx = {
141
+ args: commandArgs, output, json, interactive, env, signal: context.signal, io,
142
+ ...(context.fetch ? { fetch: context.fetch } : {}),
143
+ sleep: context.sleep ?? pause,
144
+ baseUrl, configPath: path, config,
145
+ nodium,
146
+ scope: () => {
147
+ const tenant = flag(args, 'tenant');
148
+ return tenant ? nodium().as(tenant) : nodium();
149
+ },
150
+ print: (data, human) => (json ? output.out(JSON.stringify(data, null, 2)) : human()),
151
+ confirm: what => confirmWrite(what, { yes: hasFlag(args, 'yes'), interactive, ask: io.ask })
152
+ };
153
+ if (command.needsKey !== false)
154
+ nodium();
155
+ return await command.run(ctx);
111
156
  }
112
157
  catch (error) {
113
- output.err(paint(output, 'red', describeError(error, baseUrl)));
158
+ output.err(json ? errorJson(error) : paint(output, 'red', describeError(error, baseUrl)));
114
159
  return error instanceof UsageError ? 2 : 1;
115
160
  }
116
161
  }
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 CHANGED
@@ -1,9 +1,11 @@
1
- import { UsageError, flag, flagAll } from './args.js';
1
+ import { UsageError, flag, flagAll, hasFlag } from './args.js';
2
+ import { readJsonInput } from './input.js';
2
3
  import { paint } from './output.js';
3
4
  /**
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).
5
+ * `nodium send` : un message. Du texte libre (dans les 24 h qui suivent le
6
+ * dernier message de la personne) ou un modèle approuvé (le seul envoi
7
+ * possible pour écrire en premier) par drapeaux ; tout autre message (fichier,
8
+ * boutons, liste…) en JSON, par `--input` ou `--stdin`.
7
9
  *
8
10
  * ⚠️ Un envoi refusé par Meta répond HTTP 200 : la commande lit
9
11
  * `message.status` et sort en erreur (code 1) quand il vaut `failed`.
@@ -44,27 +46,68 @@ export function readSendInput(args) {
44
46
  ...(Object.keys(variables).length ? { variables } : {})
45
47
  };
46
48
  }
49
+ /** Les drapeaux qui décrivent le message ; avec `--input` ou `--stdin`, aucun ne se donne. */
50
+ const MESSAGE_FLAGS = ['channel', 'to', 'recipient', 'text', 'template', 'language', 'var', 'idempotency-key'];
51
+ /** Le message à envoyer : le JSON de `--input`/`--stdin` (un `SendInput`), sinon les drapeaux. */
52
+ async function readMessage(ctx) {
53
+ const json = await readJsonInput(ctx);
54
+ if (!json)
55
+ return readSendInput(ctx.args);
56
+ const mixed = MESSAGE_FLAGS.find(name => hasFlag(ctx.args, name));
57
+ if (mixed)
58
+ throw new UsageError(`--${mixed} cannot be mixed with --input or --stdin: put everything in the JSON.`);
59
+ if (typeof json.channelId !== 'string' || (typeof json.to !== 'string' && typeof json.recipient !== 'string')) {
60
+ throw new UsageError('The JSON needs "channelId" and "to" (or "recipient"), plus "type" and its fields.');
61
+ }
62
+ return json;
63
+ }
47
64
  /** 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;
65
+ async function send(ctx) {
66
+ const input = await readMessage(ctx);
51
67
  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);
68
+ await ctx.confirm(`Send a ${input.type ?? 'message'} to ${target} from number ${input.channelId}`);
69
+ const result = await ctx.scope().messages.send(input);
70
+ const message = result.message;
71
+ const failed = message?.status === 'failed';
72
+ ctx.print(result, () => {
73
+ const { output } = ctx;
74
+ if (!message)
75
+ return output.out(`Sent to ${target}.`);
76
+ if (failed) {
77
+ output.err(paint(output, 'red', `Message ${message.id} to ${target} failed.`));
78
+ if (message.errorInfo) {
79
+ output.err(`${message.errorInfo.title}`);
80
+ output.err(`What to do (${message.errorInfo.actor}): ${message.errorInfo.action}`);
81
+ }
82
+ else if (message.error) {
83
+ output.err(message.error);
84
+ }
85
+ return;
64
86
  }
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;
87
+ output.out(`${paint(output, 'green', message.status)} message ${message.id} to ${target}${result.duplicate ? ' (already sent with this idempotency key: nothing left again)' : ''}`);
88
+ output.out(paint(output, 'dim', `Follow it: nodium messages get ${message.id}`));
89
+ });
90
+ return failed ? 1 : 0;
70
91
  }
92
+ export const SEND_COMMAND = {
93
+ path: ['send'], summary: 'Send a WhatsApp message. Asks first.',
94
+ flags: [...MESSAGE_FLAGS, 'input', 'stdin'],
95
+ help: ` --channel <id> The number to send from (\`nodium numbers list\`). Required.
96
+ --to <number> The recipient, in international format. Or --recipient wa:… for a
97
+ contact who shows no number.
98
+ --text <text> Free text: only within 24 hours of the person's last message.
99
+ --template <name> An approved template: the only way to write first.
100
+ --language <code> The template language, for example en or fr.
101
+ --var <n=value> A template variable (repeat it): --var 1=Karim --var 2=Friday.
102
+ --idempotency-key <k> Your own anti-duplicate key (8 to 200 characters).
103
+ --input <file.json> Any message as JSON (image, buttons, list, location…), or --stdin.
104
+ The fields of POST /messages: channelId, to, type and its fields.
105
+
106
+ Examples
107
+ nodium send --channel <id> --to +33612345678 --text "Hello"
108
+ nodium send --channel <id> --to +33612345678 --template order_ready --language en --var 1=Karim
109
+ echo '{"channelId":"<id>","to":"+33612345678","type":"image","mediaId":"<id>"}' | nodium send --stdin --yes
110
+
111
+ A send Meta refuses answers HTTP 200: the command reads the message status and exits 1.`,
112
+ run: send
113
+ };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nodium.io/cli",
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.",
3
+ "version": "0.2.0",
4
+ "description": "The nodium command: the Nodium WhatsApp API from your terminal — numbers, conversations, templates, webhooks, clients, Flows — and your webhooks replayed to your local server, signed like production.",
5
5
  "keywords": ["nodium", "cli", "whatsapp", "whatsapp-api", "whatsapp-cloud-api", "webhooks", "local-development"],
6
6
  "homepage": "https://nodium.io/en/docs/api",
7
7
  "license": "MIT",
8
8
  "author": "By-Tech.io",
9
9
  "type": "module",
10
- "engines": { "node": ">=18" },
10
+ "engines": { "node": ">=20" },
11
11
  "bin": { "nodium": "./dist/index.js" },
12
12
  "files": ["dist", "README.md", "LICENSE"],
13
13
  "publishConfig": { "access": "public" },
@@ -17,7 +17,7 @@
17
17
  "prepublishOnly": "npm run build"
18
18
  },
19
19
  "dependencies": {
20
- "@nodium.io/whatsapp": "^0.1.0"
20
+ "@nodium.io/whatsapp": "^0.3.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^26.4.0",