@nodium.io/cli 0.0.0-stage → 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/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,168 @@
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: check
7
+ your setup, read your numbers and conversations, ship templates, webhooks and
8
+ Flows, add clients — and replay your webhooks to your local server, signed like
9
+ production. A thin layer over
10
+ [`@nodium.io/whatsapp`](https://www.npmjs.com/package/@nodium.io/whatsapp):
11
+ same key, same client header, same errors. Node 20 or later.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @nodium.io/cli # or run it once: npx @nodium.io/cli …
17
+ nodium login # paste your key (nod_…, Settings › API keys in the console)
18
+ nodium status # what the key is, your numbers, your webhooks, the next step
19
+ ```
20
+
21
+ `nodium login` checks the key with Nodium and saves it to
22
+ `$XDG_CONFIG_HOME/nodium/config.json` (`~/.config/nodium/config.json` by
23
+ default), readable by you only. The key is looked up in this order: `--key`,
24
+ then `NODIUM_KEY`, then that file. It is never printed in full. `nodium logout`
25
+ deletes the file.
26
+
27
+ ## Commands
28
+
29
+ ```
30
+ nodium login | logout | status | health
31
+ nodium events [--after <seq>] [--limit <n>]
32
+
33
+ nodium numbers list [--all] | get <id> | check <id>
34
+ nodium conversations list [--channel <id>] [--contact <id>] [--phone <n>] | get <id>
35
+ nodium messages list <conversationId> | get <id>
36
+ nodium contacts list [--search <text>] | get <id>
37
+ nodium templates list [--all] | get <id> | new --input <file> | update <id> --input <file>
38
+ nodium templates deploy <id> | refresh <id> | wait <id> [--timeout 10m] | delete <id>
39
+ nodium webhooks list | deliveries [--failed] | new --url <url> --event <name> | update <id>
40
+ nodium webhooks delete <id> | test <id> | redeliver <deliveryId> | rotate-secret <id>
41
+ nodium tenants list | get <id> | new --name <name> [--external-id <id>] [--connect-link]
42
+ nodium connect-link new --tenant <id>
43
+ nodium flows deploy <file.json> [--name <name>] [--id <flowId>] [--publish]
44
+
45
+ nodium send --channel <id> --to <+33…> --text "…" | --template <name> | --input <file> | --stdin
46
+ nodium listen --forward <url> [--secret <whsec_…>] [--events a,b]
47
+ ```
48
+
49
+ `nodium help <command>` (or `--help`) describes each one. Lists show the newest
50
+ 20; `--limit <n>` shows more, following the pages for you.
51
+
52
+ Options for every command:
53
+
54
+ - `--tenant <id>` acts for one of your clients (its id or your own
55
+ `externalId`). Without it, a command acts on "My company", or on the client
56
+ the key is pinned to.
57
+ - `--output json|human` — see below.
58
+ - `--yes` (`-y`) approves a command that sends or changes something.
59
+ - `--key`, `--base-url` (or `NODIUM_BASE_URL`), `--no-color` (or `NO_COLOR`).
60
+
61
+ ## A template, from file to approved
62
+
63
+ ```bash
64
+ cat > order_ready.json <<'EOF'
65
+ { "name": "order_ready", "language": "en", "category": "UTILITY",
66
+ "body": "Hello {{1}}, your order is ready." }
67
+ EOF
68
+
69
+ nodium templates new --input order_ready.json # a draft: nothing goes to Meta yet
70
+ nodium templates deploy <id> # asks "Proceed? [y/N]"
71
+ nodium templates wait <id> --timeout 10m # exit 0 approved, 1 refused or timed out
72
+ nodium send --channel <id> --to +33612345678 --template order_ready --language en --var 1=Karim
73
+ ```
74
+
75
+ `templates wait` asks Meta every 15 seconds until every WhatsApp account of the
76
+ client approved the template, or one rejected, paused or disabled it.
77
+
78
+ ## Send
79
+
80
+ ```bash
81
+ nodium send --channel <id> --to +33612345678 --text "Hello"
82
+ nodium send --channel <id> --to +33612345678 --input image.json # any message: the JSON of POST /messages
83
+ ```
84
+
85
+ Free text only goes within 24 hours of the person's last message; a template is
86
+ the only way to write first. A send Meta refuses answers HTTP 200: the command
87
+ reads the message status, prints why, and exits 1.
88
+
89
+ ## Webhooks, clients, Flows
90
+
91
+ ```bash
92
+ nodium webhooks new --url https://app.example.com/webhooks/nodium --event message.received --event message.failed
93
+ nodium webhooks deliveries --failed
94
+ nodium webhooks redeliver <deliveryId>
95
+
96
+ nodium tenants new --name "Agence Horizon" --external-id crm-4187 --connect-link
97
+ nodium connect-link new --tenant crm-4187
98
+
99
+ nodium flows deploy booking.json # a draft Flow at Meta; validation problems printed
100
+ nodium flows deploy booking.json --id <flowId> --publish
101
+ ```
102
+
103
+ The webhook secret and the connect link are shown only once: store them. A
104
+ published Flow can no longer change.
105
+
106
+ ## Listen
107
+
108
+ No tunnel, no public address: it reads your events from Nodium and replays
109
+ each one to a URL on your machine.
110
+
111
+ ```bash
112
+ nodium listen --forward http://localhost:3000/webhook --secret whsec_… --events message.received,message.failed
113
+ ```
114
+
115
+ ```
116
+ Listening from now → http://localhost:3000/webhook
117
+ 10:32:01 message.received 200 12 ms evt_0123…
118
+ 10:32:07 message.failed 500 4 ms evt_4567… [crm-4187]
119
+ ```
120
+
121
+ - Each call is the body of a real delivery and carries the real headers
122
+ (`X-Nodium-Event`, `X-Nodium-Delivery`, and `X-Nodium-Signature: t=…,v1=…` when
123
+ you give `--secret`, or `NODIUM_WEBHOOK_SECRET`). The signature is computed
124
+ locally with the secret you give: use the one of your webhook subscription, or
125
+ any string your code also uses to verify.
126
+ - It reads the event log (`GET /events`) from a cursor, so a dropped connection
127
+ loses nothing; `Ctrl+C` stops it and prints the number to resume from
128
+ (`--from <seq>`).
129
+ - It acts on the events of every client of the account, unless `--tenant <id>`
130
+ names one or the key is pinned to one. `--verbose` prints each event's
131
+ `data`; `--interval <ms>` sets the polling pace (default 1000).
132
+ - A call your server answers with a non-`2xx` status, or not at all, is shown
133
+ and not retried.
134
+
135
+ ## Output and exit codes
136
+
137
+ - `--output human` (the default in a terminal): short lines and tables.
138
+ - `--output json` (the default when the output is piped): the object the SDK
139
+ returns, unchanged, on stdout. A refusal goes to stderr as
140
+ `{ "error": { "status", "code", "message", "requestId" } }`.
141
+ - Exit code `0` done, `1` refused by Nodium or Meta (also a template refused, a
142
+ Flow with problems, a failed send), `2` a wrong command line or a missing
143
+ approval.
144
+
145
+ ## Approval before a write
146
+
147
+ `send`, `templates deploy|delete`, `webhooks new|update|delete|redeliver|rotate-secret`,
148
+ `tenants new`, `connect-link new` and `flows deploy` say what they are about to
149
+ do and ask `Proceed? [y/N]`. Outside a terminal nobody can answer: they refuse
150
+ (exit 2) unless you pass `--yes`.
151
+
152
+ ## Using the CLI with an AI agent
153
+
154
+ The CLI suits a coding agent (Claude Code, Cursor, Codex…) as it is:
155
+
156
+ 1. **Read first.** `nodium status`, `nodium numbers list`, `nodium templates get <id>`,
157
+ `nodium conversations list`: they change nothing and need no approval.
158
+ 2. **Confirm before writing.** Show the person what will be sent or changed,
159
+ then run the command with `--yes` only once they agreed. Without `--yes`, a
160
+ write run by an agent stops with exit code 2: nothing is sent.
161
+ 3. **Use JSON.** Piped, every command prints JSON; pass `--output json` to be
162
+ sure. Branch on `error.code`, never on the message.
163
+ 4. **Give the agent its own key**, pinned to one client and with an expiry
164
+ (`POST /keys` with `clientId` and `expiresInDays`), through `NODIUM_KEY`.
165
+
166
+ ## License
167
+
168
+ MIT © By-Tech.io
package/dist/args.js ADDED
@@ -0,0 +1,96 @@
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', 'yes', 'stdin', 'publish', 'failed', 'all', 'connect-link']);
17
+ /** Les abréviations d'une lettre. */
18
+ const SHORT = { h: 'help', v: 'version', y: 'yes' };
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
+ /**
73
+ * Refuse un drapeau que la commande ne connaît pas, et un nombre d'arguments
74
+ * qui n'est pas le sien : une faute de frappe ne passe pas en silence.
75
+ * `name` est le chemin de la commande (`templates wait`).
76
+ */
77
+ export function rejectUnknown(args, allowed, name, arity = 0) {
78
+ for (const key of args.flags.keys()) {
79
+ if (!allowed.includes(key))
80
+ throw new UsageError(`Unknown option --${key} for \`${name}\`. Try \`nodium ${name} --help\`.`);
81
+ }
82
+ if (args.positionals.length > arity)
83
+ throw new UsageError(`Unexpected argument "${args.positionals[arity]}". Try \`nodium ${name} --help\`.`);
84
+ if (args.positionals.length < arity)
85
+ throw new UsageError(`Missing argument. Try \`nodium ${name} --help\`.`);
86
+ }
87
+ /** Un entier au moins égal à `min`, ou `null` si le drapeau est absent. */
88
+ export function readInteger(args, name, min) {
89
+ const text = flag(args, name);
90
+ if (text === undefined)
91
+ return null;
92
+ const value = Number(text);
93
+ if (!Number.isInteger(value) || value < min)
94
+ throw new UsageError(`--${name} must be a whole number, ${min} or more.`);
95
+ return value;
96
+ }
@@ -0,0 +1,129 @@
1
+ import { Nodium } from '@nodium.io/whatsapp';
2
+ import { UsageError, flag, readInteger } from '../args.js';
3
+ import { deleteConfig, maskKey, writeConfig } from '../config.js';
4
+ import { paint, table, when } from '../output.js';
5
+ /**
6
+ * La clé et le compte : `login`, `logout`, `status`, `health`, `events`.
7
+ */
8
+ /** Un numéro en une ligne : son état d'abord. */
9
+ export function numberState(channel) {
10
+ if (!channel.isActive)
11
+ return 'off';
12
+ if (channel.paymentMissing)
13
+ return 'payment missing';
14
+ return channel.qualityRating ? `on (${channel.qualityRating.toLowerCase()})` : 'on';
15
+ }
16
+ /** Ce qu'il reste à faire, d'après les numéros et les abonnements. */
17
+ export function nextSteps(numbers, activeWebhooks) {
18
+ const steps = [];
19
+ if (!numbers)
20
+ steps.push('Connect a WhatsApp number: `nodium tenants list`, then `nodium connect-link new --tenant <id>` and open the link.');
21
+ if (!activeWebhooks)
22
+ steps.push('Receive messages: `nodium webhooks new --url <https://…> --event message.received`, or `nodium listen --forward <local url>` while you build.');
23
+ if (numbers && activeWebhooks)
24
+ steps.push('All set: try `nodium send --channel <id> --to <+33…> --text "Hello"`.');
25
+ return steps;
26
+ }
27
+ async function login(ctx) {
28
+ let key = flag(ctx.args, 'key');
29
+ if (!key) {
30
+ key = ctx.interactive
31
+ ? await ctx.io.readSecret('API key (nod_…, Settings › API keys in the console): ')
32
+ : await ctx.io.readStdin();
33
+ key = key.trim();
34
+ }
35
+ if (!key)
36
+ throw new UsageError('No key given. Paste it when asked, pipe it (`echo $KEY | nodium login`), or pass --key.');
37
+ const current = await new Nodium(key, { baseUrl: ctx.baseUrl, ...(ctx.fetch ? { fetch: ctx.fetch } : {}) }).keys.current();
38
+ const baseUrl = flag(ctx.args, 'base-url');
39
+ writeConfig(ctx.configPath, { key, ...(baseUrl ? { baseUrl } : {}) });
40
+ ctx.print({ key: current, saved: ctx.configPath }, () => {
41
+ ctx.output.out(`${paint(ctx.output, 'green', 'Logged in')} with ${maskKey(key)} (account ${current.accountId}${current.pinned ? ', pinned to one client' : ''}).`);
42
+ ctx.output.out(paint(ctx.output, 'dim', `Saved to ${ctx.configPath} (readable by you only). NODIUM_KEY and --key still win over it.`));
43
+ });
44
+ return 0;
45
+ }
46
+ async function logout(ctx) {
47
+ const removed = deleteConfig(ctx.configPath);
48
+ ctx.print({ removed, path: ctx.configPath }, () => {
49
+ ctx.output.out(removed ? `Logged out: ${ctx.configPath} deleted.` : 'No saved key: nothing to do.');
50
+ if (ctx.env.NODIUM_KEY)
51
+ ctx.output.out(paint(ctx.output, 'yellow', 'NODIUM_KEY is still set in this shell.'));
52
+ });
53
+ return 0;
54
+ }
55
+ async function status(ctx) {
56
+ const nodium = ctx.nodium();
57
+ const key = await nodium.keys.current();
58
+ const tenant = flag(ctx.args, 'tenant');
59
+ // Une clé du compte, sans client nommé, voit les numéros de tous ses clients.
60
+ const numbers = !tenant && !key.pinned
61
+ ? await nodium.channels.list({ scope: 'account' })
62
+ : await ctx.scope().channels.list();
63
+ const { subscriptions } = await ctx.scope().webhooks.list();
64
+ const active = subscriptions.filter(s => s.isActive).length;
65
+ const next = nextSteps(numbers.filter(n => !n.isTest).length, active);
66
+ ctx.print({ key, numbers, webhooks: { total: subscriptions.length, active }, next }, () => {
67
+ const { out } = ctx.output;
68
+ out(paint(ctx.output, 'bold', 'Key'));
69
+ out(` account ${key.accountId} · ${key.pinned ? 'pinned to one client' : 'acts for every client'} · ${key.expiresAt ? `expires ${when(key.expiresAt)}` : 'never expires'}`);
70
+ out(paint(ctx.output, 'bold', `Numbers (${numbers.length})`));
71
+ if (!numbers.length)
72
+ out(' none connected');
73
+ for (const line of table(numbers.map(n => [n.id, n.identity, n.name, numberState(n) + (n.isTest ? ' · sandbox' : '')])))
74
+ out(` ${line}`);
75
+ out(paint(ctx.output, 'bold', 'Webhooks'));
76
+ out(` ${subscriptions.length} subscription${subscriptions.length === 1 ? '' : 's'}, ${active} active`);
77
+ out(paint(ctx.output, 'bold', 'Next'));
78
+ for (const step of next)
79
+ out(` ${step}`);
80
+ });
81
+ return 0;
82
+ }
83
+ async function health(ctx) {
84
+ const result = await ctx.nodium().health();
85
+ ctx.print(result, () => {
86
+ ctx.output.out(result.healthy ? paint(ctx.output, 'green', 'Healthy.') : paint(ctx.output, 'red', 'Something needs your attention:'));
87
+ for (const alert of result.alerts)
88
+ ctx.output.out(` ${alert.level.padEnd(8)} ${alert.code} ${alert.message} (number ${alert.channelId})`);
89
+ });
90
+ return result.healthy ? 0 : 1;
91
+ }
92
+ async function events(ctx) {
93
+ const after = readInteger(ctx.args, 'after', 0);
94
+ const limit = readInteger(ctx.args, 'limit', 1);
95
+ if (limit !== null && limit > 500)
96
+ throw new UsageError('--limit must be 500 or less.');
97
+ const page = await ctx.scope().events.list(after ?? undefined, limit ?? undefined);
98
+ ctx.print(page, () => {
99
+ if (after === null)
100
+ ctx.output.out(`The log is at event #${page.next_after}. Read what comes next with --after ${page.next_after}.`);
101
+ for (const line of table(page.data.map(e => [String(e.seq), when(e.occurred_at), e.event, e.id, e.external_id ?? ''])))
102
+ ctx.output.out(line);
103
+ if (after !== null)
104
+ ctx.output.out(paint(ctx.output, 'dim', `Next: --after ${page.next_after}`));
105
+ });
106
+ return 0;
107
+ }
108
+ export const ACCOUNT_COMMANDS = [
109
+ {
110
+ path: ['login'], summary: 'Save your API key for the next commands.', needsKey: false,
111
+ help: `Asks for the key (hidden), or reads it from a pipe or --key, checks it with Nodium and
112
+ saves it to $XDG_CONFIG_HOME/nodium/config.json (~/.config by default), readable by you
113
+ only. --base-url is saved with it. NODIUM_KEY and --key still win over the saved key.`,
114
+ run: login
115
+ },
116
+ { path: ['logout'], summary: 'Forget the saved key.', needsKey: false, run: logout },
117
+ {
118
+ path: ['status'], summary: 'What your key is, your numbers, your webhooks, and what to do next.',
119
+ run: status
120
+ },
121
+ { path: ['health'], summary: 'Anything wrong with your numbers? Exit code 1 if so.', run: health },
122
+ {
123
+ path: ['events'], summary: 'Read the event log (what your webhooks receive), from a cursor.',
124
+ flags: ['after', 'limit'],
125
+ help: ` --after <seq> The events after this number. Without it: where the log is now.
126
+ --limit <n> At most n events (1 to 500, default 100).`,
127
+ run: events
128
+ }
129
+ ];
@@ -0,0 +1,92 @@
1
+ import { flag } from '../args.js';
2
+ import { listLimit, pageSize, take } from '../context.js';
3
+ import { details, table, when } from '../output.js';
4
+ /**
5
+ * Lire ce qui s'est échangé : les conversations, leurs messages, les contacts.
6
+ * Aucune écriture ici.
7
+ */
8
+ /** Le texte d'un message sur une ligne, coupé. */
9
+ function preview(text, type) {
10
+ const line = (text ?? `[${type}]`).replace(/\s+/g, ' ');
11
+ return line.length > 60 ? `${line.slice(0, 59)}…` : line;
12
+ }
13
+ async function listConversations(ctx) {
14
+ const limit = listLimit(ctx.args);
15
+ const channelId = flag(ctx.args, 'channel');
16
+ const contactId = flag(ctx.args, 'contact');
17
+ const phone = flag(ctx.args, 'phone');
18
+ const items = await take(ctx.scope().conversations.each({
19
+ limit: pageSize(limit),
20
+ ...(channelId ? { channelId } : {}), ...(contactId ? { contactId } : {}), ...(phone ? { phone } : {})
21
+ }), limit);
22
+ ctx.print(items, () => {
23
+ for (const line of table(items.map(c => [
24
+ c.id, c.contact.displayName ?? c.contact.identity, c.channelName,
25
+ c.window.open ? 'window open' : 'window closed', when(c.lastInboundAt)
26
+ ])))
27
+ ctx.output.out(line);
28
+ });
29
+ return 0;
30
+ }
31
+ async function getConversation(ctx) {
32
+ const conversation = await ctx.scope().conversations.get(ctx.args.positionals[0]);
33
+ ctx.print(conversation, () => details(conversation).forEach(line => ctx.output.out(line)));
34
+ return 0;
35
+ }
36
+ async function listMessages(ctx) {
37
+ const limit = listLimit(ctx.args);
38
+ const items = await take(ctx.scope().messages.each(ctx.args.positionals[0], { limit: pageSize(limit) }), limit);
39
+ ctx.print(items, () => {
40
+ for (const line of table(items.map(m => [
41
+ when(m.createdAt), m.direction === 'inbound' ? '←' : '→', m.status, m.id, preview(m.textContent, m.type)
42
+ ])))
43
+ ctx.output.out(line);
44
+ });
45
+ return 0;
46
+ }
47
+ async function getMessage(ctx) {
48
+ const message = await ctx.scope().messages.get(ctx.args.positionals[0]);
49
+ ctx.print(message, () => details(message).forEach(line => ctx.output.out(line)));
50
+ return 0;
51
+ }
52
+ async function listContacts(ctx) {
53
+ const limit = listLimit(ctx.args);
54
+ const q = flag(ctx.args, 'search');
55
+ const items = await take(ctx.scope().contacts.each({ limit: pageSize(limit), ...(q ? { q } : {}) }), limit);
56
+ ctx.print(items, () => {
57
+ for (const line of table(items.map(c => [
58
+ c.id, c.displayName ?? '—', c.phone ?? c.email ?? c.externalId ?? '—', c.optInAt ? 'opted in' : c.optOutAt ? 'opted out' : '', c.blocked ? 'blocked' : ''
59
+ ])))
60
+ ctx.output.out(line);
61
+ });
62
+ return 0;
63
+ }
64
+ async function getContact(ctx) {
65
+ const contact = await ctx.scope().contacts.get(ctx.args.positionals[0]);
66
+ ctx.print(contact, () => details(contact).forEach(line => ctx.output.out(line)));
67
+ return 0;
68
+ }
69
+ const LIMIT_HELP = ' --limit <n> At most n items, newest first (default 20); pages are followed for you.';
70
+ export const CONVERSATION_COMMANDS = [
71
+ {
72
+ path: ['conversations', 'list'], summary: 'The latest conversations.', flags: ['limit', 'channel', 'contact', 'phone'],
73
+ help: `${LIMIT_HELP}
74
+ --channel <id> Only on this number.
75
+ --contact <id> Only with this contact.
76
+ --phone <number> Only with the contact who has this number.`,
77
+ run: listConversations
78
+ },
79
+ { path: ['conversations', 'get'], args: ['<id>'], summary: 'One conversation and its 24-hour window.', run: getConversation },
80
+ {
81
+ path: ['messages', 'list'], args: ['<conversationId>'], summary: 'The messages of a conversation, newest first.', flags: ['limit'],
82
+ help: LIMIT_HELP, run: listMessages
83
+ },
84
+ { path: ['messages', 'get'], args: ['<id>'], summary: 'One message: its status, and why it failed.', run: getMessage },
85
+ {
86
+ path: ['contacts', 'list'], summary: 'Your contacts.', flags: ['limit', 'search'],
87
+ help: `${LIMIT_HELP}
88
+ --search <text> A name, a number or an e-mail.`,
89
+ run: listContacts
90
+ },
91
+ { path: ['contacts', 'get'], args: ['<id>'], summary: 'One contact.', run: getContact }
92
+ ];
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, extname } from 'node:path';
3
+ import { UsageError, flag, hasFlag } from '../args.js';
4
+ import { parseJsonObject } from '../input.js';
5
+ import { paint } from '../output.js';
6
+ /**
7
+ * `nodium flows deploy <file.json>` : un Flow écrit en local part chez Meta —
8
+ * un nouveau brouillon, ou une version de plus d'un brouillon (`--id`). Meta
9
+ * valide le JSON ; ses erreurs s'affichent. `--publish` publie ensuite, et
10
+ * seulement si Meta n'a rien trouvé.
11
+ *
12
+ * ⚠️ Publier est définitif : un Flow publié ne se modifie plus.
13
+ */
14
+ /** Une erreur de Meta sur une ligne : où, et quoi. */
15
+ export function describeFlowError(error) {
16
+ const where = error.line_start !== undefined ? `line ${error.line_start}${error.column_start !== undefined ? `:${error.column_start}` : ''} ` : '';
17
+ return `${where}${error.error}: ${error.message}`;
18
+ }
19
+ async function deploy(ctx) {
20
+ const file = ctx.args.positionals[0];
21
+ let text;
22
+ try {
23
+ text = readFileSync(file, 'utf8');
24
+ }
25
+ catch (error) {
26
+ throw new UsageError(`Cannot read ${file}: ${error?.code === 'ENOENT' ? 'no such file' : error?.message ?? error}.`);
27
+ }
28
+ const flowJson = parseJsonObject(text, file);
29
+ const id = flag(ctx.args, 'id');
30
+ const publish = hasFlag(ctx.args, 'publish');
31
+ if (id && flag(ctx.args, 'name'))
32
+ throw new UsageError('--name is for a new Flow; to rename one, use the console.');
33
+ const flows = ctx.scope().flows;
34
+ await ctx.confirm(`${id ? `Save a new version of Flow ${id}` : 'Create a Flow'} at Meta from ${file}${publish ? ', then publish it (final)' : ''}`);
35
+ const saved = id
36
+ ? { flow: null, ...(await flows.saveVersion(id, flowJson)) }
37
+ : await flows.create({ name: flag(ctx.args, 'name') ?? basename(file, extname(file)), flowJson });
38
+ const flowId = saved.flow?.id ?? id;
39
+ const errors = saved.validationErrors;
40
+ const published = publish && !errors.length ? await flows.publish(flowId) : null;
41
+ ctx.print({ flowId, version: saved.version, validationErrors: errors, published }, () => {
42
+ const { out, err } = ctx.output;
43
+ out(`${paint(ctx.output, 'green', 'Saved')}: Flow ${flowId}, version ${saved.version.label}.`);
44
+ if (errors.length) {
45
+ err(paint(ctx.output, 'red', `Meta found ${errors.length} problem${errors.length === 1 ? '' : 's'}:`));
46
+ for (const e of errors)
47
+ err(` ${describeFlowError(e)}`);
48
+ if (publish)
49
+ err('Not published: fix the file and deploy again.');
50
+ }
51
+ else if (published) {
52
+ out(`${paint(ctx.output, 'green', 'Published')}: ${published.name} can now be sent.`);
53
+ }
54
+ else {
55
+ out(paint(ctx.output, 'dim', `Valid. Publish it with --publish --id ${flowId} once tested.`));
56
+ }
57
+ });
58
+ return errors.length ? 1 : 0;
59
+ }
60
+ export const FLOW_COMMANDS = [
61
+ {
62
+ path: ['flows', 'deploy'], args: ['<file.json>'], summary: 'Send a local Flow JSON to Meta, as a new draft or a new version.',
63
+ flags: ['name', 'id', 'publish'],
64
+ help: ` --id <flowId> Save a new version of this draft Flow. Without it: a new Flow.
65
+ --name <text> The new Flow's name. Default: the file name.
66
+ --publish Publish it once Meta finds no problem. Final: a published Flow cannot change.
67
+
68
+ Meta's validation problems are printed, with their line; exit code 1 if there are any.`,
69
+ run: deploy
70
+ }
71
+ ];
@@ -0,0 +1,58 @@
1
+ import { flag, hasFlag, readInteger } from '../args.js';
2
+ import { DEFAULT_INTERVAL_MS, listen, parseEventFilter, parseForwardUrl } from '../listen.js';
3
+ import { SEND_COMMAND } from '../send.js';
4
+ import { ACCOUNT_COMMANDS } from './account.js';
5
+ import { CONVERSATION_COMMANDS } from './conversations.js';
6
+ import { FLOW_COMMANDS } from './flows.js';
7
+ import { NUMBER_COMMANDS } from './numbers.js';
8
+ import { TEMPLATE_COMMANDS } from './templates.js';
9
+ import { TENANT_COMMANDS } from './tenants.js';
10
+ import { WEBHOOK_COMMANDS } from './webhooks.js';
11
+ /**
12
+ * LA TABLE DES COMMANDES — un nom (`templates`), au besoin un verbe (`wait`) :
13
+ * l'aide, les drapeaux permis et ce que fait chacune vivent ensemble, ici et
14
+ * dans `commands/*.ts`. `run.ts` ne fait que chercher dans cette table.
15
+ */
16
+ async function runListen(ctx) {
17
+ const forward = parseForwardUrl(flag(ctx.args, 'forward'));
18
+ const events = parseEventFilter(flag(ctx.args, 'events'));
19
+ const summary = await listen({
20
+ scope: ctx.scope(), baseUrl: ctx.baseUrl, forward, events,
21
+ secret: flag(ctx.args, 'secret') ?? ctx.env.NODIUM_WEBHOOK_SECRET ?? null,
22
+ from: readInteger(ctx.args, 'from', 0),
23
+ intervalMs: readInteger(ctx.args, 'interval', 1) ?? DEFAULT_INTERVAL_MS,
24
+ verbose: hasFlag(ctx.args, 'verbose'),
25
+ signal: ctx.signal,
26
+ output: ctx.output,
27
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {})
28
+ });
29
+ ctx.output.out(`Stopped. ${summary.forwarded} forwarded, ${summary.failed} not answered with a 2xx. Resume with --from ${summary.after}.`);
30
+ return 0;
31
+ }
32
+ const LISTEN_COMMAND = {
33
+ path: ['listen'], summary: 'Replay your events to a local URL, signed like production: no tunnel.',
34
+ flags: ['forward', 'secret', 'events', 'from', 'interval', 'verbose'],
35
+ help: ` --forward <url> Your local address, for example http://localhost:3000/webhook. Required.
36
+ --secret <whsec> Signs each call with X-Nodium-Signature (t=…,v1=…), as in production.
37
+ Use the secret of your webhook subscription, or any string your code also
38
+ uses to verify: the signature is computed here. Default: $NODIUM_WEBHOOK_SECRET.
39
+ --events <a,b> Only these events (message.received,message.failed…). Default: all.
40
+ --from <seq> Start after this event number (the "Resume with" line printed on exit).
41
+ Default: from now.
42
+ --interval <ms> Wait between two reads when nothing is new. Default: ${DEFAULT_INTERVAL_MS}.
43
+ --verbose Also print each event's data.
44
+
45
+ It reads the event log (GET /events). Events of every client of the account arrive,
46
+ unless --tenant names one or the key is pinned to one. A call your server answers with
47
+ a non-2xx status, or not at all, is shown and not retried. It prints lines as they come,
48
+ whatever --output says.`,
49
+ run: runListen
50
+ };
51
+ export const COMMANDS = [
52
+ ...ACCOUNT_COMMANDS, SEND_COMMAND, LISTEN_COMMAND,
53
+ ...NUMBER_COMMANDS, ...CONVERSATION_COMMANDS, ...TEMPLATE_COMMANDS, ...WEBHOOK_COMMANDS, ...TENANT_COMMANDS, ...FLOW_COMMANDS
54
+ ];
55
+ /** Les commandes dont le chemin commence par ces mots. */
56
+ export function findCommands(words) {
57
+ return COMMANDS.filter(c => words.every((w, i) => c.path[i] === w));
58
+ }