@nodium.io/cli 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -134,7 +134,8 @@ Listening from now → http://localhost:3000/webhook
134
134
 
135
135
  ## Output and exit codes
136
136
 
137
- - `--output human` (the default in a terminal): short lines and tables.
137
+ - `--output human` (the default in a terminal): short lines and tables, then a
138
+ `Next:` block with the commands that usually come next.
138
139
  - `--output json` (the default when the output is piped): the object the SDK
139
140
  returns, unchanged, on stdout. A refusal goes to stderr as
140
141
  `{ "error": { "status", "code", "message", "requestId" } }`.
@@ -1,6 +1,7 @@
1
- import { Nodium } from '@nodium.io/whatsapp';
1
+ import { Nodium, NodiumError } from '@nodium.io/whatsapp';
2
2
  import { UsageError, flag, readInteger } from '../args.js';
3
- import { deleteConfig, maskKey, writeConfig } from '../config.js';
3
+ import { deleteConfig, maskKey, resolveKey, writeConfig } from '../config.js';
4
+ import { take } from '../context.js';
4
5
  import { paint, table, when } from '../output.js';
5
6
  /**
6
7
  * La clé et le compte : `login`, `logout`, `status`, `health`, `events`.
@@ -13,16 +14,17 @@ export function numberState(channel) {
13
14
  return 'payment missing';
14
15
  return channel.qualityRating ? `on (${channel.qualityRating.toLowerCase()})` : 'on';
15
16
  }
16
- /** Ce qu'il reste à faire, d'après les numéros et les abonnements. */
17
- export function nextSteps(numbers, activeWebhooks) {
18
- const steps = [];
17
+ /**
18
+ * Les commandes à lancer ensuite, d'après les numéros et les abonnements.
19
+ * `activeWebhooks` à `null` : la clé ne lit pas les abonnements, rien n'est
20
+ * conseillé à leur sujet.
21
+ */
22
+ export function nextSteps(numbers, activeWebhooks, seesAllClients) {
19
23
  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;
24
+ return seesAllClients ? ['nodium tenants list', 'nodium connect-link new --tenant <id>'] : ['nodium connect-link new'];
25
+ if (activeWebhooks === 0)
26
+ return ['nodium listen --forward <local url>', 'nodium webhooks new --url <https://…> --event message.received'];
27
+ return ['nodium numbers list', 'nodium send --channel <id> --to <+33…> --text "Hello"'];
26
28
  }
27
29
  async function login(ctx) {
28
30
  let key = flag(ctx.args, 'key');
@@ -40,7 +42,7 @@ async function login(ctx) {
40
42
  ctx.print({ key: current, saved: ctx.configPath }, () => {
41
43
  ctx.output.out(`${paint(ctx.output, 'green', 'Logged in')} with ${maskKey(key)} (account ${current.accountId}${current.pinned ? ', pinned to one client' : ''}).`);
42
44
  ctx.output.out(paint(ctx.output, 'dim', `Saved to ${ctx.configPath} (readable by you only). NODIUM_KEY and --key still win over it.`));
43
- });
45
+ }, ['nodium status']);
44
46
  return 0;
45
47
  }
46
48
  async function logout(ctx) {
@@ -52,32 +54,45 @@ async function logout(ctx) {
52
54
  });
53
55
  return 0;
54
56
  }
57
+ /** Au-delà, `status` écrit « 1000+ » : compter tous les clients d'un gros compte serait long. */
58
+ const CLIENTS_COUNTED = 1000;
55
59
  async function status(ctx) {
56
60
  const nodium = ctx.nodium();
57
61
  const key = await nodium.keys.current();
58
62
  const tenant = flag(ctx.args, 'tenant');
63
+ const seesAll = !tenant && !key.pinned;
59
64
  // 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 }, () => {
65
+ const [numbers, clients, subscriptions] = await Promise.all([
66
+ seesAll ? nodium.channels.list({ scope: 'account' }) : ctx.scope().channels.list(),
67
+ // « Mon entreprise » n'est pas dans la liste des clients.
68
+ seesAll ? take(nodium.tenants.each({ limit: 100 }), CLIENTS_COUNTED).then(items => items.length) : Promise.resolve(null),
69
+ // Une clé épinglée à « Mon entreprise » ne lit pas ses abonnements (404) :
70
+ // ils portent les événements de tous les clients.
71
+ ctx.scope().webhooks.list().then(r => r.subscriptions, (error) => {
72
+ if (error instanceof NodiumError && error.status === 404)
73
+ return null;
74
+ throw error;
75
+ })
76
+ ]);
77
+ const connected = numbers.filter(n => !n.isTest);
78
+ const active = subscriptions ? subscriptions.filter(s => s.isActive).length : null;
79
+ const next = nextSteps(connected.length, active, seesAll);
80
+ const webhooks = subscriptions ? { total: subscriptions.length, active } : null;
81
+ ctx.print({ key, clients, numbers, webhooks, next }, () => {
67
82
  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
- });
83
+ const label = key.label ? ` "${key.label}"` : '';
84
+ const scope = key.client
85
+ ? `pinned to ${key.client.isHome ? 'My company' : `"${key.client.name}"`} (${key.client.id})`
86
+ : 'every client of the account';
87
+ out(`Authentication: ${paint(ctx.output, 'green', 'authenticated')}`);
88
+ out(`Key: ${maskKey(resolveKey(flag(ctx.args, 'key'), ctx.env, ctx.config).key)}${label}, ${key.expiresAt ? `expires ${when(key.expiresAt)}` : 'never expires'}`);
89
+ out(`Account: ${key.accountName} (${key.accountId})`);
90
+ out(`Access: ${scope}`);
91
+ if (clients !== null)
92
+ out(`Clients: ${clients >= CLIENTS_COUNTED ? `${CLIENTS_COUNTED}+` : clients}`);
93
+ out(`WhatsApp Numbers: ${connected.length}${connected.length ? ` (${connected.filter(n => n.isActive).length} on)` : ''}`);
94
+ out(`Webhooks: ${subscriptions ? `${active} active of ${subscriptions.length}` : 'not visible with this key (pinned to My company)'}`);
95
+ }, next);
81
96
  return 0;
82
97
  }
83
98
  async function health(ctx) {
@@ -86,7 +101,7 @@ async function health(ctx) {
86
101
  ctx.output.out(result.healthy ? paint(ctx.output, 'green', 'Healthy.') : paint(ctx.output, 'red', 'Something needs your attention:'));
87
102
  for (const alert of result.alerts)
88
103
  ctx.output.out(` ${alert.level.padEnd(8)} ${alert.code} ${alert.message} (number ${alert.channelId})`);
89
- });
104
+ }, result.healthy ? [] : [...new Set(result.alerts.map(a => `nodium numbers check ${a.channelId}`))]);
90
105
  return result.healthy ? 0 : 1;
91
106
  }
92
107
  async function events(ctx) {
@@ -100,9 +115,7 @@ async function events(ctx) {
100
115
  ctx.output.out(`The log is at event #${page.next_after}. Read what comes next with --after ${page.next_after}.`);
101
116
  for (const line of table(page.data.map(e => [String(e.seq), when(e.occurred_at), e.event, e.id, e.external_id ?? ''])))
102
117
  ctx.output.out(line);
103
- if (after !== null)
104
- ctx.output.out(paint(ctx.output, 'dim', `Next: --after ${page.next_after}`));
105
- });
118
+ }, [`nodium events --after ${page.next_after}`]);
106
119
  return 0;
107
120
  }
108
121
  export const ACCOUNT_COMMANDS = [
@@ -25,12 +25,12 @@ async function listConversations(ctx) {
25
25
  c.window.open ? 'window open' : 'window closed', when(c.lastInboundAt)
26
26
  ])))
27
27
  ctx.output.out(line);
28
- });
28
+ }, items.length ? [`nodium messages list ${items[0].id}`] : ['nodium listen --forward <local url>']);
29
29
  return 0;
30
30
  }
31
31
  async function getConversation(ctx) {
32
32
  const conversation = await ctx.scope().conversations.get(ctx.args.positionals[0]);
33
- ctx.print(conversation, () => details(conversation).forEach(line => ctx.output.out(line)));
33
+ ctx.print(conversation, () => details(conversation).forEach(line => ctx.output.out(line)), [`nodium messages list ${conversation.id}`]);
34
34
  return 0;
35
35
  }
36
36
  async function listMessages(ctx) {
@@ -41,7 +41,7 @@ async function listMessages(ctx) {
41
41
  when(m.createdAt), m.direction === 'inbound' ? '←' : '→', m.status, m.id, preview(m.textContent, m.type)
42
42
  ])))
43
43
  ctx.output.out(line);
44
- });
44
+ }, items.length ? [`nodium messages get ${items[0].id}`] : []);
45
45
  return 0;
46
46
  }
47
47
  async function getMessage(ctx) {
@@ -58,12 +58,12 @@ async function listContacts(ctx) {
58
58
  c.id, c.displayName ?? '—', c.phone ?? c.email ?? c.externalId ?? '—', c.optInAt ? 'opted in' : c.optOutAt ? 'opted out' : '', c.blocked ? 'blocked' : ''
59
59
  ])))
60
60
  ctx.output.out(line);
61
- });
61
+ }, items.length ? [`nodium contacts get ${items[0].id}`] : []);
62
62
  return 0;
63
63
  }
64
64
  async function getContact(ctx) {
65
65
  const contact = await ctx.scope().contacts.get(ctx.args.positionals[0]);
66
- ctx.print(contact, () => details(contact).forEach(line => ctx.output.out(line)));
66
+ ctx.print(contact, () => details(contact).forEach(line => ctx.output.out(line)), [`nodium conversations list --contact ${contact.id}`]);
67
67
  return 0;
68
68
  }
69
69
  const LIMIT_HELP = ' --limit <n> At most n items, newest first (default 20); pages are followed for you.';
@@ -52,9 +52,11 @@ async function deploy(ctx) {
52
52
  out(`${paint(ctx.output, 'green', 'Published')}: ${published.name} can now be sent.`);
53
53
  }
54
54
  else {
55
- out(paint(ctx.output, 'dim', `Valid. Publish it with --publish --id ${flowId} once tested.`));
55
+ out('Valid.');
56
56
  }
57
- });
57
+ }, errors.length
58
+ ? [`nodium flows deploy ${file} --id ${flowId}`]
59
+ : published ? [] : [`nodium flows deploy ${file} --id ${flowId} --publish`]);
58
60
  return errors.length ? 1 : 0;
59
61
  }
60
62
  export const FLOW_COMMANDS = [
@@ -9,15 +9,17 @@ async function list(ctx) {
9
9
  const numbers = await ctx.scope().channels.list(hasFlag(ctx.args, 'all') ? { scope: 'account' } : {});
10
10
  ctx.print(numbers, () => {
11
11
  if (!numbers.length)
12
- ctx.output.out('No number connected. Send your client a link: `nodium connect-link new --tenant <id>`.');
12
+ ctx.output.out('No number connected.');
13
13
  for (const line of table(numbers.map(n => [n.id, n.identity, n.name, numberState(n) + (n.isTest ? ' · sandbox' : '')])))
14
14
  ctx.output.out(line);
15
- });
15
+ }, numbers.length
16
+ ? [`nodium numbers check ${numbers[0].id}`, `nodium send --channel ${numbers[0].id} --to <+33…> --text "Hello"`]
17
+ : ['nodium connect-link new --tenant <id>']);
16
18
  return 0;
17
19
  }
18
20
  async function get(ctx) {
19
21
  const number = await ctx.scope().channels.get(ctx.args.positionals[0]);
20
- ctx.print(number, () => details(number).forEach(line => ctx.output.out(line)));
22
+ ctx.print(number, () => details(number).forEach(line => ctx.output.out(line)), [`nodium numbers check ${number.id}`, `nodium send --channel ${number.id} --to <+33…> --text "Hello"`]);
21
23
  return 0;
22
24
  }
23
25
  async function check(ctx) {
@@ -28,7 +30,7 @@ async function check(ctx) {
28
30
  ctx.output.out(result.message);
29
31
  if (result.number)
30
32
  details(result.number).forEach(line => ctx.output.out(` ${line}`));
31
- });
33
+ }, result.ok ? [`nodium send --channel ${ctx.args.positionals[0]} --to <+33…> --text "Hello"`] : ['nodium health']);
32
34
  return result.ok ? 0 : 1;
33
35
  }
34
36
  export const NUMBER_COMMANDS = [
@@ -24,13 +24,23 @@ export function parseDuration(text) {
24
24
  }
25
25
  /** L'attente entre deux questions à Meta. */
26
26
  export const WAIT_INTERVAL_MS = 15_000;
27
+ /** Ce qui suit un modèle, d'après son verdict chez Meta. */
28
+ function templateNext(id, name, outcome) {
29
+ if (outcome === 'approved')
30
+ return [`nodium send --channel <id> --to <+33…> --template ${name} --language <code>`];
31
+ if (outcome === 'not_deployed')
32
+ return [`nodium templates deploy ${id}`];
33
+ if (outcome === 'pending')
34
+ return [`nodium templates wait ${id}`];
35
+ return [`nodium templates update ${id} --input <file.json>`, `nodium templates deploy ${id}`];
36
+ }
27
37
  async function list(ctx) {
28
38
  const result = await ctx.scope().templates.list(hasFlag(ctx.args, 'all') ? { scope: 'account' } : {});
29
39
  ctx.print(result, () => {
30
40
  for (const line of table(result.templates.map(t => [t.id, t.name, t.language, t.category.toLowerCase(), t.status])))
31
41
  ctx.output.out(line);
32
42
  ctx.output.out(paint(ctx.output, 'dim', `${result.summary.total} templates: ${result.summary.deployed} deployed, ${result.summary.draft} drafts.`));
33
- });
43
+ }, result.templates.length ? [`nodium templates get ${result.templates[0].id}`] : ['nodium templates new --input <file.json>']);
34
44
  return 0;
35
45
  }
36
46
  async function get(ctx) {
@@ -41,7 +51,7 @@ async function get(ctx) {
41
51
  ctx.output.out(paint(ctx.output, 'bold', 'At Meta'));
42
52
  for (const line of table(result.deployments.map(d => [d.wabaId ?? '—', d.status, d.rejectionReason ?? '', when(d.reviewedAt)])))
43
53
  ctx.output.out(` ${line}`);
44
- });
54
+ }, templateNext(result.template.id, result.template.name, templateOutcome(result.deployments)));
45
55
  return 0;
46
56
  }
47
57
  async function requireInput(ctx) {
@@ -54,13 +64,12 @@ async function create(ctx) {
54
64
  const result = await ctx.scope().templates.create(await requireInput(ctx));
55
65
  ctx.print(result, () => {
56
66
  ctx.output.out(`${paint(ctx.output, 'green', 'Draft saved')}: ${result.template.name} (${result.template.id}). Nothing went to Meta yet.`);
57
- ctx.output.out(paint(ctx.output, 'dim', `Next: nodium templates deploy ${result.template.id}`));
58
- });
67
+ }, [`nodium templates deploy ${result.template.id}`]);
59
68
  return 0;
60
69
  }
61
70
  async function update(ctx) {
62
71
  const result = await ctx.scope().templates.update(ctx.args.positionals[0], await requireInput(ctx));
63
- ctx.print(result, () => ctx.output.out(`Saved: ${result.template.name} (${result.template.status}).`));
72
+ ctx.print(result, () => ctx.output.out(`Saved: ${result.template.name} (${result.template.status}).`), [`nodium templates deploy ${result.template.id}`]);
64
73
  return 0;
65
74
  }
66
75
  async function deploy(ctx) {
@@ -71,21 +80,19 @@ async function deploy(ctx) {
71
80
  ctx.print(result, () => {
72
81
  for (const line of table(result.results.map(r => [r.clientName, r.wabaId ?? '—', r.status, r.reason ?? ''])))
73
82
  ctx.output.out(line);
74
- if (!refused)
75
- ctx.output.out(paint(ctx.output, 'dim', `Wait for Meta's review: nodium templates wait ${id}`));
76
- });
83
+ }, refused ? [`nodium templates update ${id} --input <file.json>`] : [`nodium templates wait ${id}`]);
77
84
  return refused ? 1 : 0;
78
85
  }
79
86
  async function refresh(ctx) {
80
87
  const result = await ctx.scope().templates.refresh(ctx.args.positionals[0]);
81
- ctx.print(result, () => ctx.output.out(`${result.template.name}: ${result.template.status} (${result.changed} changed).`));
88
+ ctx.print(result, () => ctx.output.out(`${result.template.name}: ${result.template.status} (${result.changed} changed).`), [`nodium templates get ${result.template.id}`]);
82
89
  return 0;
83
90
  }
84
91
  async function remove(ctx) {
85
92
  const id = ctx.args.positionals[0];
86
93
  await ctx.confirm(`Delete template ${id}`);
87
94
  const result = await ctx.scope().templates.delete(id);
88
- ctx.print(result, () => ctx.output.out(result.deleted ? `Template ${id} deleted.` : `Template ${id} was not deleted.`));
95
+ ctx.print(result, () => ctx.output.out(result.deleted ? `Template ${id} deleted.` : `Template ${id} was not deleted.`), ['nodium templates list']);
89
96
  return result.deleted ? 0 : 1;
90
97
  }
91
98
  async function wait(ctx) {
@@ -110,9 +117,8 @@ async function wait(ctx) {
110
117
  err(paint(ctx.output, 'red', `${detail.template.name} was not approved.`));
111
118
  for (const d of detail.deployments.filter(d => REFUSED.has(d.status)))
112
119
  err(` ${d.wabaId ?? '—'} ${d.status} ${d.rejectionReason ?? ''}`);
113
- err('Fix it with `nodium templates update`, then deploy again.');
114
120
  }
115
- });
121
+ }, templateNext(id, detail.template.name, outcome));
116
122
  return outcome === 'approved' ? 0 : 1;
117
123
  }
118
124
  if (!ctx.json)
@@ -28,12 +28,14 @@ async function list(ctx) {
28
28
  ctx.print(items, () => {
29
29
  for (const line of table(items.map(t => [t.id, t.externalId ?? '—', t.name, `WhatsApp ${t.whatsapp.status.replace('_', ' ')}`, t.status])))
30
30
  ctx.output.out(line);
31
- });
31
+ }, items.length ? [`nodium tenants get ${items[0].externalId ?? items[0].id}`] : ['nodium tenants new --name "<client name>" --connect-link']);
32
32
  return 0;
33
33
  }
34
34
  async function get(ctx) {
35
35
  const tenant = await ctx.nodium().tenants.get(ctx.args.positionals[0]);
36
- ctx.print(tenant, () => details(tenant).forEach(line => ctx.output.out(line)));
36
+ ctx.print(tenant, () => details(tenant).forEach(line => ctx.output.out(line)), tenant.whatsapp.status === 'connected'
37
+ ? [`nodium numbers list --tenant ${tenant.externalId ?? tenant.id}`]
38
+ : [`nodium connect-link new --tenant ${tenant.externalId ?? tenant.id}`]);
37
39
  return 0;
38
40
  }
39
41
  async function create(ctx) {
@@ -50,14 +52,13 @@ async function create(ctx) {
50
52
  ctx.print(tenant, () => {
51
53
  ctx.output.out(`${paint(ctx.output, 'green', 'Client added')}: ${tenant.name} (${tenant.id}).`);
52
54
  printLink(ctx, tenant.connectLink.url, tenant.connectLink.expiresAt);
53
- });
55
+ }, [`nodium tenants get ${tenant.externalId ?? tenant.id}`]);
54
56
  return 0;
55
57
  }
56
58
  const tenant = await tenants.create(input);
57
59
  ctx.print(tenant, () => {
58
60
  ctx.output.out(`${paint(ctx.output, 'green', 'Client added')}: ${tenant.name} (${tenant.id}).`);
59
- ctx.output.out(paint(ctx.output, 'dim', `Next: nodium connect-link new --tenant ${tenant.externalId ?? tenant.id}`));
60
- });
61
+ }, [`nodium connect-link new --tenant ${tenant.externalId ?? tenant.id}`]);
61
62
  return 0;
62
63
  }
63
64
  async function newLink(ctx) {
@@ -67,7 +68,7 @@ async function newLink(ctx) {
67
68
  const input = linkInput(ctx);
68
69
  await ctx.confirm(`Create a connect link for client ${tenant}`);
69
70
  const link = await ctx.nodium().tenants.connectLink(tenant, input);
70
- ctx.print(link, () => printLink(ctx, link.url, link.expiresAt));
71
+ ctx.print(link, () => printLink(ctx, link.url, link.expiresAt), [`nodium tenants get ${tenant}`]);
71
72
  return 0;
72
73
  }
73
74
  const LINK_HELP = ` --redirect-url <https://…> Where your client lands once connected.
@@ -25,12 +25,14 @@ async function list(ctx) {
25
25
  const result = await ctx.scope().webhooks.list();
26
26
  ctx.print(result, () => {
27
27
  if (!result.subscriptions.length)
28
- ctx.output.out('No subscription. Add one: nodium webhooks new --url <https://…> --event message.received');
28
+ ctx.output.out('No subscription.');
29
29
  for (const line of table(result.subscriptions.map(s => [
30
30
  s.id, s.isActive ? 'active' : `paused${s.disabledReason ? ` (${s.disabledReason})` : ''}`, s.url, s.events.join(','), s.lastError ? `last error: ${s.lastError}` : ''
31
31
  ])))
32
32
  ctx.output.out(line);
33
- });
33
+ }, result.subscriptions.length
34
+ ? [`nodium webhooks test ${result.subscriptions[0].id}`, 'nodium webhooks deliveries --failed']
35
+ : ['nodium webhooks new --url <https://…> --event message.received', 'nodium listen --forward <local url>']);
34
36
  return 0;
35
37
  }
36
38
  async function deliveries(ctx) {
@@ -47,9 +49,7 @@ async function deliveries(ctx) {
47
49
  when(d.createdAt), d.id, d.event, d.status, d.responseStatus === null ? '—' : String(d.responseStatus), `${d.attempts} attempt${d.attempts === 1 ? '' : 's'}`, d.lastError ?? ''
48
50
  ])))
49
51
  ctx.output.out(line);
50
- if (items.some(d => d.status === 'failed'))
51
- ctx.output.out(paint(ctx.output, 'dim', 'Send one again: nodium webhooks redeliver <deliveryId>'));
52
- });
52
+ }, items.find(d => d.status === 'failed') ? [`nodium webhooks redeliver ${items.find(d => d.status === 'failed').id}`] : []);
53
53
  return 0;
54
54
  }
55
55
  async function create(ctx) {
@@ -66,8 +66,7 @@ async function create(ctx) {
66
66
  ctx.print(result, () => {
67
67
  ctx.output.out(`${paint(ctx.output, 'green', 'Subscribed')}: ${result.id}`);
68
68
  printSecret(ctx, result.secret);
69
- ctx.output.out(paint(ctx.output, 'dim', `Try it: nodium webhooks test ${result.id}`));
70
- });
69
+ }, [`nodium webhooks test ${result.id}`]);
71
70
  return 0;
72
71
  }
73
72
  async function update(ctx) {
@@ -93,14 +92,14 @@ async function update(ctx) {
93
92
  throw new UsageError('Nothing to change: give --url, --event, --channel, --label or --active.');
94
93
  await ctx.confirm(`Change webhook ${id} (${Object.keys(input).join(', ')})`);
95
94
  const subscription = await ctx.scope().webhooks.update(id, input);
96
- ctx.print(subscription, () => ctx.output.out(`Saved: ${subscription.id} → ${subscription.url} (${subscription.isActive ? 'active' : 'paused'}).`));
95
+ ctx.print(subscription, () => ctx.output.out(`Saved: ${subscription.id} → ${subscription.url} (${subscription.isActive ? 'active' : 'paused'}).`), [`nodium webhooks test ${subscription.id}`]);
97
96
  return 0;
98
97
  }
99
98
  async function remove(ctx) {
100
99
  const id = ctx.args.positionals[0];
101
100
  await ctx.confirm(`Delete webhook ${id}: Nodium stops calling it`);
102
101
  const result = await ctx.scope().webhooks.delete(id);
103
- ctx.print(result ?? { deleted: true }, () => ctx.output.out(`Webhook ${id} deleted.`));
102
+ ctx.print(result ?? { deleted: true }, () => ctx.output.out(`Webhook ${id} deleted.`), ['nodium webhooks list']);
104
103
  return 0;
105
104
  }
106
105
  async function test(ctx) {
@@ -109,14 +108,14 @@ async function test(ctx) {
109
108
  ctx.output.out(result.ok
110
109
  ? `${paint(ctx.output, 'green', String(result.status))} answered in ${result.ms} ms.`
111
110
  : `${paint(ctx.output, 'red', result.status === null ? 'ERR' : String(result.status))} ${result.error ?? 'not a 2xx answer'} (${result.ms} ms).`);
112
- });
111
+ }, result.ok ? ['nodium webhooks deliveries'] : [`nodium webhooks update ${ctx.args.positionals[0]} --url <https://…>`]);
113
112
  return result.ok ? 0 : 1;
114
113
  }
115
114
  async function redeliver(ctx) {
116
115
  const id = ctx.args.positionals[0];
117
116
  await ctx.confirm(`Send delivery ${id} again`);
118
117
  const delivery = await ctx.scope().webhooks.redeliver(id);
119
- ctx.print(delivery, () => ctx.output.out(`Delivery ${delivery.id}: ${delivery.status}${delivery.responseStatus ? ` (${delivery.responseStatus})` : ''}.`));
118
+ ctx.print(delivery, () => ctx.output.out(`Delivery ${delivery.id}: ${delivery.status}${delivery.responseStatus ? ` (${delivery.responseStatus})` : ''}.`), ['nodium webhooks deliveries']);
120
119
  return delivery.status === 'failed' ? 1 : 0;
121
120
  }
122
121
  async function rotateSecret(ctx) {
package/dist/output.js CHANGED
@@ -4,6 +4,15 @@ const CODES = { dim: 2, red: 31, green: 32, yellow: 33, cyan: 36, bold: 1 };
4
4
  export function paint(output, style, text) {
5
5
  return output.color ? `\u001b[${CODES[style]}m${text}\u001b[0m` : text;
6
6
  }
7
+ /** Le bloc « Next: » : les commandes à lancer ensuite, une par ligne. */
8
+ export function printNext(output, steps) {
9
+ if (!steps?.length)
10
+ return;
11
+ output.out('');
12
+ output.out('Next:');
13
+ for (const step of steps)
14
+ output.out(` ${step}`);
15
+ }
7
16
  /** L'heure locale, `HH:MM:SS`. */
8
17
  export function clock(date = new Date()) {
9
18
  return date.toTimeString().slice(0, 8);
package/dist/run.js CHANGED
@@ -6,7 +6,7 @@ import { COMMANDS, findCommands } from './commands/index.js';
6
6
  import { configPath as defaultConfigPath, readConfig, resolveKey } from './config.js';
7
7
  import { confirmWrite, outputMode } from './context.js';
8
8
  import { pause } from './listen.js';
9
- import { describeError, errorJson, paint, table } from './output.js';
9
+ import { describeError, errorJson, paint, table, printNext } from './output.js';
10
10
  /**
11
11
  * La commande `nodium` : lit la ligne de commande, cherche la commande dans la
12
12
  * table (`commands/index.ts`), branche le client, rend le code de sortie
@@ -147,7 +147,12 @@ export async function run(argv, context) {
147
147
  const tenant = flag(args, 'tenant');
148
148
  return tenant ? nodium().as(tenant) : nodium();
149
149
  },
150
- print: (data, human) => (json ? output.out(JSON.stringify(data, null, 2)) : human()),
150
+ print: (data, human, next) => {
151
+ if (json)
152
+ return output.out(JSON.stringify(data, null, 2));
153
+ human();
154
+ printNext(output, next);
155
+ },
151
156
  confirm: what => confirmWrite(what, { yes: hasFlag(args, 'yes'), interactive, ask: io.ask })
152
157
  };
153
158
  if (command.needsKey !== false)
package/dist/send.js CHANGED
@@ -85,8 +85,7 @@ async function send(ctx) {
85
85
  return;
86
86
  }
87
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
- });
88
+ }, message ? [`nodium messages get ${message.id}`] : []);
90
89
  return failed ? 1 : 0;
91
90
  }
92
91
  export const SEND_COMMAND = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodium.io/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
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",
@@ -17,7 +17,7 @@
17
17
  "prepublishOnly": "npm run build"
18
18
  },
19
19
  "dependencies": {
20
- "@nodium.io/whatsapp": "^0.3.0"
20
+ "@nodium.io/whatsapp": "^0.3.1"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^26.4.0",