@nodium.io/cli 0.1.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 +124 -19
- package/dist/args.js +25 -9
- package/dist/commands/account.js +142 -0
- package/dist/commands/conversations.js +92 -0
- package/dist/commands/flows.js +73 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/numbers.js +44 -0
- package/dist/commands/templates.js +157 -0
- package/dist/commands/tenants.js +100 -0
- package/dist/commands/webhooks.js +159 -0
- package/dist/config.js +48 -0
- package/dist/context.js +48 -0
- package/dist/index.js +42 -0
- package/dist/input.js +39 -0
- package/dist/listen.js +1 -1
- package/dist/output.js +38 -3
- package/dist/run.js +122 -105
- package/dist/send.js +66 -24
- package/package.json +4 -4
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { UsageError, flag, hasFlag } from '../args.js';
|
|
2
|
+
import { readJsonInput } from '../input.js';
|
|
3
|
+
import { details, paint, table, when } from '../output.js';
|
|
4
|
+
/** Les états d'où Meta ne revient pas seul : il faut corriger, puis redéployer. */
|
|
5
|
+
const REFUSED = new Set(['rejected', 'paused', 'disabled', 'failed']);
|
|
6
|
+
/** Le verdict d'ensemble : approuvé partout, refusé quelque part, en revue, ou pas déployé. */
|
|
7
|
+
export function templateOutcome(deployments) {
|
|
8
|
+
if (!deployments.length)
|
|
9
|
+
return 'not_deployed';
|
|
10
|
+
if (deployments.some(d => REFUSED.has(d.status)))
|
|
11
|
+
return 'refused';
|
|
12
|
+
if (deployments.some(d => d.status === 'not_deployed' || d.status === 'no_number'))
|
|
13
|
+
return 'not_deployed';
|
|
14
|
+
if (deployments.every(d => d.status === 'approved'))
|
|
15
|
+
return 'approved';
|
|
16
|
+
return 'pending';
|
|
17
|
+
}
|
|
18
|
+
/** `10m`, `90s`, `1h`, ou un nombre de secondes → millisecondes. */
|
|
19
|
+
export function parseDuration(text) {
|
|
20
|
+
const match = /^(\d+)\s*(s|m|h)?$/.exec(text.trim());
|
|
21
|
+
if (!match)
|
|
22
|
+
throw new UsageError(`--timeout "${text}" must look like 90s, 10m or 1h.`);
|
|
23
|
+
return Number(match[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[(match[2] ?? 's')];
|
|
24
|
+
}
|
|
25
|
+
/** L'attente entre deux questions à Meta. */
|
|
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
|
+
}
|
|
37
|
+
async function list(ctx) {
|
|
38
|
+
const result = await ctx.scope().templates.list(hasFlag(ctx.args, 'all') ? { scope: 'account' } : {});
|
|
39
|
+
ctx.print(result, () => {
|
|
40
|
+
for (const line of table(result.templates.map(t => [t.id, t.name, t.language, t.category.toLowerCase(), t.status])))
|
|
41
|
+
ctx.output.out(line);
|
|
42
|
+
ctx.output.out(paint(ctx.output, 'dim', `${result.summary.total} templates: ${result.summary.deployed} deployed, ${result.summary.draft} drafts.`));
|
|
43
|
+
}, result.templates.length ? [`nodium templates get ${result.templates[0].id}`] : ['nodium templates new --input <file.json>']);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
async function get(ctx) {
|
|
47
|
+
const result = await ctx.scope().templates.get(ctx.args.positionals[0]);
|
|
48
|
+
ctx.print(result, () => {
|
|
49
|
+
details(result.template).forEach(line => ctx.output.out(line));
|
|
50
|
+
if (result.deployments.length)
|
|
51
|
+
ctx.output.out(paint(ctx.output, 'bold', 'At Meta'));
|
|
52
|
+
for (const line of table(result.deployments.map(d => [d.wabaId ?? '—', d.status, d.rejectionReason ?? '', when(d.reviewedAt)])))
|
|
53
|
+
ctx.output.out(` ${line}`);
|
|
54
|
+
}, templateNext(result.template.id, result.template.name, templateOutcome(result.deployments)));
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
async function requireInput(ctx) {
|
|
58
|
+
const input = await readJsonInput(ctx);
|
|
59
|
+
if (!input)
|
|
60
|
+
throw new UsageError('Give the template as JSON: --input <file.json> or --stdin. See `nodium help templates new`.');
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
async function create(ctx) {
|
|
64
|
+
const result = await ctx.scope().templates.create(await requireInput(ctx));
|
|
65
|
+
ctx.print(result, () => {
|
|
66
|
+
ctx.output.out(`${paint(ctx.output, 'green', 'Draft saved')}: ${result.template.name} (${result.template.id}). Nothing went to Meta yet.`);
|
|
67
|
+
}, [`nodium templates deploy ${result.template.id}`]);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
async function update(ctx) {
|
|
71
|
+
const result = await ctx.scope().templates.update(ctx.args.positionals[0], await requireInput(ctx));
|
|
72
|
+
ctx.print(result, () => ctx.output.out(`Saved: ${result.template.name} (${result.template.status}).`), [`nodium templates deploy ${result.template.id}`]);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
async function deploy(ctx) {
|
|
76
|
+
const id = ctx.args.positionals[0];
|
|
77
|
+
await ctx.confirm(`Submit template ${id} to Meta for review (its name is then reserved there)`);
|
|
78
|
+
const result = await ctx.scope().templates.deploy(id);
|
|
79
|
+
const refused = result.results.some(r => REFUSED.has(r.status));
|
|
80
|
+
ctx.print(result, () => {
|
|
81
|
+
for (const line of table(result.results.map(r => [r.clientName, r.wabaId ?? '—', r.status, r.reason ?? ''])))
|
|
82
|
+
ctx.output.out(line);
|
|
83
|
+
}, refused ? [`nodium templates update ${id} --input <file.json>`] : [`nodium templates wait ${id}`]);
|
|
84
|
+
return refused ? 1 : 0;
|
|
85
|
+
}
|
|
86
|
+
async function refresh(ctx) {
|
|
87
|
+
const result = await ctx.scope().templates.refresh(ctx.args.positionals[0]);
|
|
88
|
+
ctx.print(result, () => ctx.output.out(`${result.template.name}: ${result.template.status} (${result.changed} changed).`), [`nodium templates get ${result.template.id}`]);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
async function remove(ctx) {
|
|
92
|
+
const id = ctx.args.positionals[0];
|
|
93
|
+
await ctx.confirm(`Delete template ${id}`);
|
|
94
|
+
const result = await ctx.scope().templates.delete(id);
|
|
95
|
+
ctx.print(result, () => ctx.output.out(result.deleted ? `Template ${id} deleted.` : `Template ${id} was not deleted.`), ['nodium templates list']);
|
|
96
|
+
return result.deleted ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
async function wait(ctx) {
|
|
99
|
+
const id = ctx.args.positionals[0];
|
|
100
|
+
const timeout = parseDuration(flag(ctx.args, 'timeout') ?? '10m');
|
|
101
|
+
const deadline = Date.now() + timeout;
|
|
102
|
+
const templates = ctx.scope().templates;
|
|
103
|
+
for (;;) {
|
|
104
|
+
await templates.refresh(id);
|
|
105
|
+
const detail = await templates.get(id);
|
|
106
|
+
const outcome = templateOutcome(detail.deployments);
|
|
107
|
+
if (outcome !== 'pending' || Date.now() + WAIT_INTERVAL_MS > deadline || ctx.signal.aborted) {
|
|
108
|
+
ctx.print({ outcome: outcome === 'pending' ? 'timeout' : outcome, template: detail.template, deployments: detail.deployments }, () => {
|
|
109
|
+
const { out, err } = ctx.output;
|
|
110
|
+
if (outcome === 'approved')
|
|
111
|
+
out(paint(ctx.output, 'green', `${detail.template.name} is approved: it can be sent.`));
|
|
112
|
+
else if (outcome === 'not_deployed')
|
|
113
|
+
err(`${detail.template.name} is not at Meta yet: run \`nodium templates deploy ${id}\` first.`);
|
|
114
|
+
else if (outcome === 'pending')
|
|
115
|
+
err(`Still in review at Meta after ${Math.round(timeout / 1000)} s. Run the command again later.`);
|
|
116
|
+
else {
|
|
117
|
+
err(paint(ctx.output, 'red', `${detail.template.name} was not approved.`));
|
|
118
|
+
for (const d of detail.deployments.filter(d => REFUSED.has(d.status)))
|
|
119
|
+
err(` ${d.wabaId ?? '—'} ${d.status} ${d.rejectionReason ?? ''}`);
|
|
120
|
+
}
|
|
121
|
+
}, templateNext(id, detail.template.name, outcome));
|
|
122
|
+
return outcome === 'approved' ? 0 : 1;
|
|
123
|
+
}
|
|
124
|
+
if (!ctx.json)
|
|
125
|
+
ctx.output.err(paint(ctx.output, 'dim', `In review at Meta… checking again in ${WAIT_INTERVAL_MS / 1000} s.`));
|
|
126
|
+
await ctx.sleep(WAIT_INTERVAL_MS, ctx.signal);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const INPUT_HELP = ` --input <file.json> The template as JSON, or --stdin to read it from a pipe.`;
|
|
130
|
+
export const TEMPLATE_COMMANDS = [
|
|
131
|
+
{
|
|
132
|
+
path: ['templates', 'list'], summary: 'Your templates and their state at Meta.', flags: ['all'],
|
|
133
|
+
help: ' --all The templates of every client of the account.', run: list
|
|
134
|
+
},
|
|
135
|
+
{ path: ['templates', 'get'], args: ['<id>'], summary: 'One template, and its review at Meta.', run: get },
|
|
136
|
+
{
|
|
137
|
+
path: ['templates', 'new'], summary: 'Save a draft template (nothing goes to Meta yet).', flags: ['input', 'stdin'],
|
|
138
|
+
help: `${INPUT_HELP}
|
|
139
|
+
|
|
140
|
+
Example file:
|
|
141
|
+
{ "name": "order_ready", "language": "en", "category": "UTILITY",
|
|
142
|
+
"body": "Hello {{1}}, your order is ready." }`,
|
|
143
|
+
run: create
|
|
144
|
+
},
|
|
145
|
+
{ path: ['templates', 'update'], args: ['<id>'], summary: 'Change a draft, or a template Meta refused.', flags: ['input', 'stdin'], help: INPUT_HELP, run: update },
|
|
146
|
+
{ path: ['templates', 'deploy'], args: ['<id>'], summary: 'Submit it to Meta for review. Asks first.', run: deploy },
|
|
147
|
+
{ path: ['templates', 'refresh'], args: ['<id>'], summary: 'Ask Meta where the review is.', run: refresh },
|
|
148
|
+
{
|
|
149
|
+
path: ['templates', 'wait'], args: ['<id>'], summary: 'Wait for Meta\'s verdict. Exit 0 approved, 1 refused or timed out.', flags: ['timeout'],
|
|
150
|
+
help: ` --timeout <time> How long to wait: 90s, 10m, 1h. Default: 10m.
|
|
151
|
+
|
|
152
|
+
It asks Meta every ${WAIT_INTERVAL_MS / 1000} s until every WhatsApp account approved it, or one
|
|
153
|
+
rejected, paused or disabled it.`,
|
|
154
|
+
run: wait
|
|
155
|
+
},
|
|
156
|
+
{ path: ['templates', 'delete'], args: ['<id>'], summary: 'Delete a template. Asks first.', run: remove }
|
|
157
|
+
];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { UsageError, flag, hasFlag, readInteger } from '../args.js';
|
|
2
|
+
import { listLimit, pageSize, take } from '../context.js';
|
|
3
|
+
import { details, paint, table } from '../output.js';
|
|
4
|
+
/**
|
|
5
|
+
* Vos clients (un par client final, « Mon entreprise » compris) et le lien
|
|
6
|
+
* hébergé où chacun branche son WhatsApp.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ L'adresse d'un lien ne se montre qu'à sa création : la commande l'écrit
|
|
9
|
+
* une fois, rien ne la relit.
|
|
10
|
+
*/
|
|
11
|
+
/** `--redirect-url` et `--expires-in-days`, s'ils sont donnés. */
|
|
12
|
+
function linkInput(ctx) {
|
|
13
|
+
const redirectUrl = flag(ctx.args, 'redirect-url');
|
|
14
|
+
const expiresInDays = readInteger(ctx.args, 'expires-in-days', 1);
|
|
15
|
+
if (expiresInDays !== null && expiresInDays > 30)
|
|
16
|
+
throw new UsageError('--expires-in-days must be 30 or less.');
|
|
17
|
+
return { ...(redirectUrl ? { redirectUrl } : {}), ...(expiresInDays !== null ? { expiresInDays } : {}) };
|
|
18
|
+
}
|
|
19
|
+
function printLink(ctx, url, expiresAt) {
|
|
20
|
+
ctx.output.out(`Send this link to your client (single use, until ${expiresAt.slice(0, 10)}):`);
|
|
21
|
+
ctx.output.out(paint(ctx.output, 'bold', url));
|
|
22
|
+
ctx.output.err(paint(ctx.output, 'dim', 'It is shown only now. A lost link is replaced by a new one.'));
|
|
23
|
+
}
|
|
24
|
+
async function list(ctx) {
|
|
25
|
+
const limit = listLimit(ctx.args);
|
|
26
|
+
const externalId = flag(ctx.args, 'external-id');
|
|
27
|
+
const items = await take(ctx.nodium().tenants.each({ limit: pageSize(limit), ...(externalId ? { externalId } : {}) }), limit);
|
|
28
|
+
ctx.print(items, () => {
|
|
29
|
+
for (const line of table(items.map(t => [t.id, t.externalId ?? '—', t.name, `WhatsApp ${t.whatsapp.status.replace('_', ' ')}`, t.status])))
|
|
30
|
+
ctx.output.out(line);
|
|
31
|
+
}, items.length ? [`nodium tenants get ${items[0].externalId ?? items[0].id}`] : ['nodium tenants new --name "<client name>" --connect-link']);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
async function get(ctx) {
|
|
35
|
+
const tenant = await ctx.nodium().tenants.get(ctx.args.positionals[0]);
|
|
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}`]);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
async function create(ctx) {
|
|
42
|
+
const name = flag(ctx.args, 'name');
|
|
43
|
+
if (!name)
|
|
44
|
+
throw new UsageError('--name "<client name>" is required.');
|
|
45
|
+
const externalId = flag(ctx.args, 'external-id');
|
|
46
|
+
const withLink = hasFlag(ctx.args, 'connect-link');
|
|
47
|
+
await ctx.confirm(`Add the client "${name}"${withLink ? ' and create its connect link' : ''}`);
|
|
48
|
+
const input = { name, ...(externalId ? { externalId } : {}) };
|
|
49
|
+
const tenants = ctx.nodium().tenants;
|
|
50
|
+
if (withLink) {
|
|
51
|
+
const tenant = await tenants.create(input, { connectLink: linkInput(ctx) });
|
|
52
|
+
ctx.print(tenant, () => {
|
|
53
|
+
ctx.output.out(`${paint(ctx.output, 'green', 'Client added')}: ${tenant.name} (${tenant.id}).`);
|
|
54
|
+
printLink(ctx, tenant.connectLink.url, tenant.connectLink.expiresAt);
|
|
55
|
+
}, [`nodium tenants get ${tenant.externalId ?? tenant.id}`]);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const tenant = await tenants.create(input);
|
|
59
|
+
ctx.print(tenant, () => {
|
|
60
|
+
ctx.output.out(`${paint(ctx.output, 'green', 'Client added')}: ${tenant.name} (${tenant.id}).`);
|
|
61
|
+
}, [`nodium connect-link new --tenant ${tenant.externalId ?? tenant.id}`]);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
async function newLink(ctx) {
|
|
65
|
+
const tenant = flag(ctx.args, 'tenant');
|
|
66
|
+
if (!tenant)
|
|
67
|
+
throw new UsageError('--tenant <id> is required: the client who connects (its id or your externalId).');
|
|
68
|
+
const input = linkInput(ctx);
|
|
69
|
+
await ctx.confirm(`Create a connect link for client ${tenant}`);
|
|
70
|
+
const link = await ctx.nodium().tenants.connectLink(tenant, input);
|
|
71
|
+
ctx.print(link, () => printLink(ctx, link.url, link.expiresAt), [`nodium tenants get ${tenant}`]);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const LINK_HELP = ` --redirect-url <https://…> Where your client lands once connected.
|
|
75
|
+
--expires-in-days <n> 1 to 30, default 7.`;
|
|
76
|
+
export const TENANT_COMMANDS = [
|
|
77
|
+
{
|
|
78
|
+
path: ['tenants', 'list'], summary: 'Your clients, newest first.', flags: ['limit', 'external-id'],
|
|
79
|
+
help: ` --external-id <id> The client with your own identifier.
|
|
80
|
+
--limit <n> At most n (default 20).`,
|
|
81
|
+
run: list
|
|
82
|
+
},
|
|
83
|
+
{ path: ['tenants', 'get'], args: ['<id>'], summary: 'One client, by its id or your externalId.', run: get },
|
|
84
|
+
{
|
|
85
|
+
path: ['tenants', 'new'], summary: 'Add a client. With --connect-link, prints the link to send it.',
|
|
86
|
+
flags: ['name', 'external-id', 'connect-link', 'redirect-url', 'expires-in-days'],
|
|
87
|
+
help: ` --name <text> The client's name. Required.
|
|
88
|
+
--external-id <id> Your own identifier for it: unique, it makes the call safe to repeat.
|
|
89
|
+
--connect-link Also create the link where it connects its WhatsApp.
|
|
90
|
+
${LINK_HELP}`,
|
|
91
|
+
run: create
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
path: ['connect-link', 'new'], summary: 'A link where your client connects its WhatsApp number.',
|
|
95
|
+
flags: ['redirect-url', 'expires-in-days'],
|
|
96
|
+
help: ` --tenant <id> The client. Required.
|
|
97
|
+
${LINK_HELP}`,
|
|
98
|
+
run: newLink
|
|
99
|
+
}
|
|
100
|
+
];
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { WEBHOOK_EVENTS } from '@nodium.io/whatsapp';
|
|
2
|
+
import { UsageError, flag, flagAll, hasFlag } from '../args.js';
|
|
3
|
+
import { listLimit, pageSize, take } from '../context.js';
|
|
4
|
+
import { paint, table, when } from '../output.js';
|
|
5
|
+
/**
|
|
6
|
+
* Les abonnements aux événements, et leurs livraisons.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ Le secret de signature ne se montre qu'à la création et au changement de
|
|
9
|
+
* secret : la commande le dit, une fois.
|
|
10
|
+
*/
|
|
11
|
+
/** `--event a --event b,c` → `['a', 'b', 'c']`, chaque nom connu (`*` = tous). */
|
|
12
|
+
export function readEvents(values) {
|
|
13
|
+
const names = values.flatMap(v => v.split(',')).map(n => n.trim()).filter(Boolean);
|
|
14
|
+
const known = new Set([...WEBHOOK_EVENTS, '*']);
|
|
15
|
+
const unknown = names.filter(n => !known.has(n));
|
|
16
|
+
if (unknown.length)
|
|
17
|
+
throw new UsageError(`Unknown event ${unknown.map(n => `"${n}"`).join(', ')}. Known: ${WEBHOOK_EVENTS.join(', ')}, or * for all.`);
|
|
18
|
+
return names;
|
|
19
|
+
}
|
|
20
|
+
function printSecret(ctx, secret) {
|
|
21
|
+
ctx.output.out(`Signing secret: ${secret}`);
|
|
22
|
+
ctx.output.err(paint(ctx.output, 'yellow', 'Store it now (for example in NODIUM_WEBHOOK_SECRET): it is never shown again.'));
|
|
23
|
+
}
|
|
24
|
+
async function list(ctx) {
|
|
25
|
+
const result = await ctx.scope().webhooks.list();
|
|
26
|
+
ctx.print(result, () => {
|
|
27
|
+
if (!result.subscriptions.length)
|
|
28
|
+
ctx.output.out('No subscription.');
|
|
29
|
+
for (const line of table(result.subscriptions.map(s => [
|
|
30
|
+
s.id, s.isActive ? 'active' : `paused${s.disabledReason ? ` (${s.disabledReason})` : ''}`, s.url, s.events.join(','), s.lastError ? `last error: ${s.lastError}` : ''
|
|
31
|
+
])))
|
|
32
|
+
ctx.output.out(line);
|
|
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>']);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
async function deliveries(ctx) {
|
|
39
|
+
const limit = listLimit(ctx.args);
|
|
40
|
+
const subscriptionId = flag(ctx.args, 'subscription');
|
|
41
|
+
const event = flag(ctx.args, 'event');
|
|
42
|
+
const items = await take(ctx.scope().webhooks.eachDelivery({
|
|
43
|
+
limit: pageSize(limit),
|
|
44
|
+
...(hasFlag(ctx.args, 'failed') ? { status: 'failed' } : {}),
|
|
45
|
+
...(subscriptionId ? { subscriptionId } : {}), ...(event ? { event } : {})
|
|
46
|
+
}), limit);
|
|
47
|
+
ctx.print(items, () => {
|
|
48
|
+
for (const line of table(items.map(d => [
|
|
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 ?? ''
|
|
50
|
+
])))
|
|
51
|
+
ctx.output.out(line);
|
|
52
|
+
}, items.find(d => d.status === 'failed') ? [`nodium webhooks redeliver ${items.find(d => d.status === 'failed').id}`] : []);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
async function create(ctx) {
|
|
56
|
+
const url = flag(ctx.args, 'url');
|
|
57
|
+
if (!url)
|
|
58
|
+
throw new UsageError('--url <https://…> is required: the address Nodium calls.');
|
|
59
|
+
const events = readEvents(flagAll(ctx.args, 'event'));
|
|
60
|
+
if (!events.length)
|
|
61
|
+
throw new UsageError('Name at least one event: --event message.received (repeat it), or --event "*" for all.');
|
|
62
|
+
const channelIds = flagAll(ctx.args, 'channel');
|
|
63
|
+
const label = flag(ctx.args, 'label');
|
|
64
|
+
await ctx.confirm(`Subscribe ${url} to ${events.join(', ')}`);
|
|
65
|
+
const result = await ctx.scope().webhooks.create({ url, events, ...(channelIds.length ? { channelIds } : {}), ...(label ? { label } : {}) });
|
|
66
|
+
ctx.print(result, () => {
|
|
67
|
+
ctx.output.out(`${paint(ctx.output, 'green', 'Subscribed')}: ${result.id}`);
|
|
68
|
+
printSecret(ctx, result.secret);
|
|
69
|
+
}, [`nodium webhooks test ${result.id}`]);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
async function update(ctx) {
|
|
73
|
+
const id = ctx.args.positionals[0];
|
|
74
|
+
const input = {};
|
|
75
|
+
const url = flag(ctx.args, 'url');
|
|
76
|
+
const label = flag(ctx.args, 'label');
|
|
77
|
+
const active = flag(ctx.args, 'active');
|
|
78
|
+
if (url)
|
|
79
|
+
input.url = url;
|
|
80
|
+
if (label !== undefined)
|
|
81
|
+
input.label = label;
|
|
82
|
+
if (hasFlag(ctx.args, 'event'))
|
|
83
|
+
input.events = readEvents(flagAll(ctx.args, 'event'));
|
|
84
|
+
if (hasFlag(ctx.args, 'channel'))
|
|
85
|
+
input.channelIds = flagAll(ctx.args, 'channel').filter(Boolean);
|
|
86
|
+
if (active !== undefined) {
|
|
87
|
+
if (active !== 'true' && active !== 'false')
|
|
88
|
+
throw new UsageError('--active must be true or false.');
|
|
89
|
+
input.isActive = active === 'true';
|
|
90
|
+
}
|
|
91
|
+
if (!Object.keys(input).length)
|
|
92
|
+
throw new UsageError('Nothing to change: give --url, --event, --channel, --label or --active.');
|
|
93
|
+
await ctx.confirm(`Change webhook ${id} (${Object.keys(input).join(', ')})`);
|
|
94
|
+
const subscription = await ctx.scope().webhooks.update(id, input);
|
|
95
|
+
ctx.print(subscription, () => ctx.output.out(`Saved: ${subscription.id} → ${subscription.url} (${subscription.isActive ? 'active' : 'paused'}).`), [`nodium webhooks test ${subscription.id}`]);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
async function remove(ctx) {
|
|
99
|
+
const id = ctx.args.positionals[0];
|
|
100
|
+
await ctx.confirm(`Delete webhook ${id}: Nodium stops calling it`);
|
|
101
|
+
const result = await ctx.scope().webhooks.delete(id);
|
|
102
|
+
ctx.print(result ?? { deleted: true }, () => ctx.output.out(`Webhook ${id} deleted.`), ['nodium webhooks list']);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
async function test(ctx) {
|
|
106
|
+
const result = await ctx.scope().webhooks.test(ctx.args.positionals[0]);
|
|
107
|
+
ctx.print(result, () => {
|
|
108
|
+
ctx.output.out(result.ok
|
|
109
|
+
? `${paint(ctx.output, 'green', String(result.status))} answered in ${result.ms} ms.`
|
|
110
|
+
: `${paint(ctx.output, 'red', result.status === null ? 'ERR' : String(result.status))} ${result.error ?? 'not a 2xx answer'} (${result.ms} ms).`);
|
|
111
|
+
}, result.ok ? ['nodium webhooks deliveries'] : [`nodium webhooks update ${ctx.args.positionals[0]} --url <https://…>`]);
|
|
112
|
+
return result.ok ? 0 : 1;
|
|
113
|
+
}
|
|
114
|
+
async function redeliver(ctx) {
|
|
115
|
+
const id = ctx.args.positionals[0];
|
|
116
|
+
await ctx.confirm(`Send delivery ${id} again`);
|
|
117
|
+
const delivery = await ctx.scope().webhooks.redeliver(id);
|
|
118
|
+
ctx.print(delivery, () => ctx.output.out(`Delivery ${delivery.id}: ${delivery.status}${delivery.responseStatus ? ` (${delivery.responseStatus})` : ''}.`), ['nodium webhooks deliveries']);
|
|
119
|
+
return delivery.status === 'failed' ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
async function rotateSecret(ctx) {
|
|
122
|
+
const id = ctx.args.positionals[0];
|
|
123
|
+
await ctx.confirm(`Issue a new signing secret for webhook ${id}`);
|
|
124
|
+
const result = await ctx.scope().webhooks.rotateSecret(id);
|
|
125
|
+
ctx.print(result, () => {
|
|
126
|
+
printSecret(ctx, result.secret);
|
|
127
|
+
ctx.output.out(`The old secret still signs until ${when(result.previousValidUntil)}: verify with both meanwhile.`);
|
|
128
|
+
});
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
export const WEBHOOK_COMMANDS = [
|
|
132
|
+
{ path: ['webhooks', 'list'], summary: 'Your subscriptions.', run: list },
|
|
133
|
+
{
|
|
134
|
+
path: ['webhooks', 'deliveries'], summary: 'The latest deliveries, newest first.', flags: ['limit', 'failed', 'subscription', 'event'],
|
|
135
|
+
help: ` --failed Only the failed ones.
|
|
136
|
+
--subscription <id> Only for this subscription.
|
|
137
|
+
--event <name> Only this event.
|
|
138
|
+
--limit <n> At most n (default 20).`,
|
|
139
|
+
run: deliveries
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
path: ['webhooks', 'new'], summary: 'Subscribe an https address to events. Prints the secret once.', flags: ['url', 'event', 'channel', 'label'],
|
|
143
|
+
help: ` --url <https://…> The address Nodium calls. Required.
|
|
144
|
+
--event <name> An event (repeat it, or a,b), * for all. Required.
|
|
145
|
+
--channel <id> Only the events of this number (repeat it). Default: all numbers.
|
|
146
|
+
--label <text> A name for you.`,
|
|
147
|
+
run: create
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: ['webhooks', 'update'], args: ['<id>'], summary: 'Change the address, events, numbers, label, or pause it.', flags: ['url', 'event', 'channel', 'label', 'active'],
|
|
151
|
+
help: ` --url, --event, --channel, --label As for \`webhooks new\`; --event and --channel replace the list.
|
|
152
|
+
--active <true|false> Resume or pause it.`,
|
|
153
|
+
run: update
|
|
154
|
+
},
|
|
155
|
+
{ path: ['webhooks', 'delete'], args: ['<id>'], summary: 'Delete a subscription.', run: remove },
|
|
156
|
+
{ path: ['webhooks', 'test'], args: ['<id>'], summary: 'Call the address once with a test event.', run: test },
|
|
157
|
+
{ path: ['webhooks', 'redeliver'], args: ['<deliveryId>'], summary: 'Send a delivery again.', run: redeliver },
|
|
158
|
+
{ path: ['webhooks', 'rotate-secret'], args: ['<id>'], summary: 'A new signing secret; the old one still works for a while.', run: rotateSecret }
|
|
159
|
+
];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { UsageError } from './args.js';
|
|
5
|
+
export function configPath(env) {
|
|
6
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'nodium', 'config.json');
|
|
7
|
+
}
|
|
8
|
+
/** Le fichier lu ; absent ou illisible : rien. */
|
|
9
|
+
export function readConfig(path) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
12
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function writeConfig(path, config) {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
21
|
+
// Un fichier déjà là garde ses droits à l'écriture : on les resserre.
|
|
22
|
+
chmodSync(path, 0o600);
|
|
23
|
+
}
|
|
24
|
+
/** Vrai si un fichier a été effacé. */
|
|
25
|
+
export function deleteConfig(path) {
|
|
26
|
+
try {
|
|
27
|
+
readFileSync(path);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
rmSync(path, { force: true });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** La clé à employer, et d'où elle vient : `--key`, puis `NODIUM_KEY`, puis le fichier. */
|
|
36
|
+
export function resolveKey(flagKey, env, config) {
|
|
37
|
+
if (flagKey)
|
|
38
|
+
return { key: flagKey, source: '--key' };
|
|
39
|
+
if (env.NODIUM_KEY)
|
|
40
|
+
return { key: env.NODIUM_KEY, source: 'NODIUM_KEY' };
|
|
41
|
+
if (config.key)
|
|
42
|
+
return { key: config.key, source: 'config' };
|
|
43
|
+
throw new UsageError('No API key: run `nodium login`, or set NODIUM_KEY (or pass --key). Create one in the console, under Settings › API keys.');
|
|
44
|
+
}
|
|
45
|
+
/** Le début d'une clé, assez pour la reconnaître : `nod_ab12…`. */
|
|
46
|
+
export function maskKey(key) {
|
|
47
|
+
return `${key.slice(0, 8)}…`;
|
|
48
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { UsageError, readInteger } from './args.js';
|
|
2
|
+
/**
|
|
3
|
+
* La sortie par défaut : lisible devant un terminal, JSON dans un tube (un
|
|
4
|
+
* script ou un agent). `--output` l'impose.
|
|
5
|
+
*/
|
|
6
|
+
export function outputMode(value, stdoutIsTty) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return stdoutIsTty ? 'human' : 'json';
|
|
9
|
+
if (value !== 'json' && value !== 'human')
|
|
10
|
+
throw new UsageError('--output must be json or human.');
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* L'accord avant d'envoyer ou de changer quelque chose : `--yes` le donne ;
|
|
15
|
+
* sinon, devant un terminal, la question « Proceed? [y/N] » ; hors d'un
|
|
16
|
+
* terminal, sans `--yes`, la commande refuse (code 2).
|
|
17
|
+
*/
|
|
18
|
+
export async function confirmWrite(what, options) {
|
|
19
|
+
if (options.yes)
|
|
20
|
+
return;
|
|
21
|
+
if (!options.interactive) {
|
|
22
|
+
throw new UsageError(`${what}: this changes something and needs your approval. Run it again with --yes (not in a terminal, nobody can answer the question).`);
|
|
23
|
+
}
|
|
24
|
+
const answer = (await options.ask(`${what}. Proceed? [y/N] `)).trim().toLowerCase();
|
|
25
|
+
if (answer !== 'y' && answer !== 'yes')
|
|
26
|
+
throw new UsageError('Cancelled: nothing was sent or changed.');
|
|
27
|
+
}
|
|
28
|
+
/** Les premiers éléments d'une liste parcourue page après page. */
|
|
29
|
+
export async function take(items, limit) {
|
|
30
|
+
const out = [];
|
|
31
|
+
if (limit <= 0)
|
|
32
|
+
return out;
|
|
33
|
+
for await (const item of items) {
|
|
34
|
+
out.push(item);
|
|
35
|
+
if (out.length >= limit)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Combien d'éléments montre une liste : `--limit`, 20 par défaut. */
|
|
41
|
+
export const DEFAULT_LIMIT = 20;
|
|
42
|
+
export function listLimit(args) {
|
|
43
|
+
return readInteger(args, 'limit', 1) ?? DEFAULT_LIMIT;
|
|
44
|
+
}
|
|
45
|
+
/** La taille d'une page demandée à l'API pour en montrer `limit` (100 au plus). */
|
|
46
|
+
export function pageSize(limit) {
|
|
47
|
+
return Math.min(limit, 100);
|
|
48
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
2
4
|
import { run } from './run.js';
|
|
3
5
|
/**
|
|
4
6
|
* Le point d'entrée de `nodium` (`bin` du paquet). Ctrl+C et SIGTERM arrêtent
|
|
@@ -16,9 +18,49 @@ const stop = () => {
|
|
|
16
18
|
process.on('SIGINT', stop);
|
|
17
19
|
process.on('SIGTERM', stop);
|
|
18
20
|
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && !argv.includes('--no-color');
|
|
21
|
+
/** Une question au terminal, sur la sortie d'erreur ; `hidden` n'affiche pas la frappe. */
|
|
22
|
+
function prompt(question, hidden) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let muted = false;
|
|
25
|
+
const sink = new Writable({
|
|
26
|
+
write(chunk, encoding, done) {
|
|
27
|
+
if (!muted)
|
|
28
|
+
process.stderr.write(chunk, encoding);
|
|
29
|
+
done();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const rl = createInterface({ input: process.stdin, output: sink, terminal: true });
|
|
33
|
+
// Ctrl+C pendant la question : une réponse vide, donc « non ».
|
|
34
|
+
rl.on('SIGINT', () => {
|
|
35
|
+
rl.close();
|
|
36
|
+
process.stderr.write('\n');
|
|
37
|
+
resolve('');
|
|
38
|
+
});
|
|
39
|
+
rl.question(question, (answer) => {
|
|
40
|
+
rl.close();
|
|
41
|
+
if (hidden)
|
|
42
|
+
process.stderr.write('\n');
|
|
43
|
+
resolve(answer);
|
|
44
|
+
});
|
|
45
|
+
muted = hidden;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const io = {
|
|
49
|
+
ask: question => prompt(question, false),
|
|
50
|
+
readSecret: question => prompt(question, true),
|
|
51
|
+
readStdin: async () => {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const chunk of process.stdin)
|
|
54
|
+
chunks.push(Buffer.from(chunk));
|
|
55
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
56
|
+
}
|
|
57
|
+
};
|
|
19
58
|
run(argv, {
|
|
20
59
|
env: process.env,
|
|
21
60
|
signal: controller.signal,
|
|
61
|
+
stdoutIsTty: Boolean(process.stdout.isTTY),
|
|
62
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
63
|
+
io,
|
|
22
64
|
output: {
|
|
23
65
|
out: text => process.stdout.write(`${text}\n`),
|
|
24
66
|
err: text => process.stderr.write(`${text}\n`),
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { UsageError, flag, hasFlag } from './args.js';
|
|
3
|
+
/**
|
|
4
|
+
* Un corps JSON donné à une commande : `--input <fichier.json>` ou `--stdin`.
|
|
5
|
+
* `null` quand aucun des deux n'est donné.
|
|
6
|
+
*/
|
|
7
|
+
export async function readJsonInput(ctx) {
|
|
8
|
+
const file = flag(ctx.args, 'input');
|
|
9
|
+
const stdin = hasFlag(ctx.args, 'stdin');
|
|
10
|
+
if (file && stdin)
|
|
11
|
+
throw new UsageError('Give --input <file> or --stdin, not both.');
|
|
12
|
+
if (!file && !stdin)
|
|
13
|
+
return null;
|
|
14
|
+
let text;
|
|
15
|
+
if (file) {
|
|
16
|
+
try {
|
|
17
|
+
text = readFileSync(file, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw new UsageError(`Cannot read ${file}: ${error?.code === 'ENOENT' ? 'no such file' : error?.message ?? error}.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
text = await ctx.io.readStdin();
|
|
25
|
+
}
|
|
26
|
+
return parseJsonObject(text, file ?? 'stdin');
|
|
27
|
+
}
|
|
28
|
+
export function parseJsonObject(text, where) {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new UsageError(`${where} is not valid JSON: ${error instanceof Error ? error.message : error}`);
|
|
35
|
+
}
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
37
|
+
throw new UsageError(`${where} must hold one JSON object.`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
package/dist/listen.js
CHANGED
|
@@ -124,7 +124,7 @@ export function pause(ms, signal) {
|
|
|
124
124
|
signal.addEventListener('abort', done, { once: true });
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
|
-
/** Ce qui ne se réessaie pas : la clé,
|
|
127
|
+
/** Ce qui ne se réessaie pas : la clé, un appel interdit, le client visé. */
|
|
128
128
|
function isFatal(error) {
|
|
129
129
|
return error instanceof NodiumError && (error.status === 401 || error.status === 403 || error.status === 404);
|
|
130
130
|
}
|