@nodium.io/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
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
+ async function list(ctx) {
28
+ const result = await ctx.scope().templates.list(hasFlag(ctx.args, 'all') ? { scope: 'account' } : {});
29
+ ctx.print(result, () => {
30
+ for (const line of table(result.templates.map(t => [t.id, t.name, t.language, t.category.toLowerCase(), t.status])))
31
+ ctx.output.out(line);
32
+ ctx.output.out(paint(ctx.output, 'dim', `${result.summary.total} templates: ${result.summary.deployed} deployed, ${result.summary.draft} drafts.`));
33
+ });
34
+ return 0;
35
+ }
36
+ async function get(ctx) {
37
+ const result = await ctx.scope().templates.get(ctx.args.positionals[0]);
38
+ ctx.print(result, () => {
39
+ details(result.template).forEach(line => ctx.output.out(line));
40
+ if (result.deployments.length)
41
+ ctx.output.out(paint(ctx.output, 'bold', 'At Meta'));
42
+ for (const line of table(result.deployments.map(d => [d.wabaId ?? '—', d.status, d.rejectionReason ?? '', when(d.reviewedAt)])))
43
+ ctx.output.out(` ${line}`);
44
+ });
45
+ return 0;
46
+ }
47
+ async function requireInput(ctx) {
48
+ const input = await readJsonInput(ctx);
49
+ if (!input)
50
+ throw new UsageError('Give the template as JSON: --input <file.json> or --stdin. See `nodium help templates new`.');
51
+ return input;
52
+ }
53
+ async function create(ctx) {
54
+ const result = await ctx.scope().templates.create(await requireInput(ctx));
55
+ ctx.print(result, () => {
56
+ 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
+ });
59
+ return 0;
60
+ }
61
+ async function update(ctx) {
62
+ 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}).`));
64
+ return 0;
65
+ }
66
+ async function deploy(ctx) {
67
+ const id = ctx.args.positionals[0];
68
+ await ctx.confirm(`Submit template ${id} to Meta for review (its name is then reserved there)`);
69
+ const result = await ctx.scope().templates.deploy(id);
70
+ const refused = result.results.some(r => REFUSED.has(r.status));
71
+ ctx.print(result, () => {
72
+ for (const line of table(result.results.map(r => [r.clientName, r.wabaId ?? '—', r.status, r.reason ?? ''])))
73
+ 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
+ });
77
+ return refused ? 1 : 0;
78
+ }
79
+ async function refresh(ctx) {
80
+ 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).`));
82
+ return 0;
83
+ }
84
+ async function remove(ctx) {
85
+ const id = ctx.args.positionals[0];
86
+ await ctx.confirm(`Delete template ${id}`);
87
+ 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.`));
89
+ return result.deleted ? 0 : 1;
90
+ }
91
+ async function wait(ctx) {
92
+ const id = ctx.args.positionals[0];
93
+ const timeout = parseDuration(flag(ctx.args, 'timeout') ?? '10m');
94
+ const deadline = Date.now() + timeout;
95
+ const templates = ctx.scope().templates;
96
+ for (;;) {
97
+ await templates.refresh(id);
98
+ const detail = await templates.get(id);
99
+ const outcome = templateOutcome(detail.deployments);
100
+ if (outcome !== 'pending' || Date.now() + WAIT_INTERVAL_MS > deadline || ctx.signal.aborted) {
101
+ ctx.print({ outcome: outcome === 'pending' ? 'timeout' : outcome, template: detail.template, deployments: detail.deployments }, () => {
102
+ const { out, err } = ctx.output;
103
+ if (outcome === 'approved')
104
+ out(paint(ctx.output, 'green', `${detail.template.name} is approved: it can be sent.`));
105
+ else if (outcome === 'not_deployed')
106
+ err(`${detail.template.name} is not at Meta yet: run \`nodium templates deploy ${id}\` first.`);
107
+ else if (outcome === 'pending')
108
+ err(`Still in review at Meta after ${Math.round(timeout / 1000)} s. Run the command again later.`);
109
+ else {
110
+ err(paint(ctx.output, 'red', `${detail.template.name} was not approved.`));
111
+ for (const d of detail.deployments.filter(d => REFUSED.has(d.status)))
112
+ err(` ${d.wabaId ?? '—'} ${d.status} ${d.rejectionReason ?? ''}`);
113
+ err('Fix it with `nodium templates update`, then deploy again.');
114
+ }
115
+ });
116
+ return outcome === 'approved' ? 0 : 1;
117
+ }
118
+ if (!ctx.json)
119
+ ctx.output.err(paint(ctx.output, 'dim', `In review at Meta… checking again in ${WAIT_INTERVAL_MS / 1000} s.`));
120
+ await ctx.sleep(WAIT_INTERVAL_MS, ctx.signal);
121
+ }
122
+ }
123
+ const INPUT_HELP = ` --input <file.json> The template as JSON, or --stdin to read it from a pipe.`;
124
+ export const TEMPLATE_COMMANDS = [
125
+ {
126
+ path: ['templates', 'list'], summary: 'Your templates and their state at Meta.', flags: ['all'],
127
+ help: ' --all The templates of every client of the account.', run: list
128
+ },
129
+ { path: ['templates', 'get'], args: ['<id>'], summary: 'One template, and its review at Meta.', run: get },
130
+ {
131
+ path: ['templates', 'new'], summary: 'Save a draft template (nothing goes to Meta yet).', flags: ['input', 'stdin'],
132
+ help: `${INPUT_HELP}
133
+
134
+ Example file:
135
+ { "name": "order_ready", "language": "en", "category": "UTILITY",
136
+ "body": "Hello {{1}}, your order is ready." }`,
137
+ run: create
138
+ },
139
+ { path: ['templates', 'update'], args: ['<id>'], summary: 'Change a draft, or a template Meta refused.', flags: ['input', 'stdin'], help: INPUT_HELP, run: update },
140
+ { path: ['templates', 'deploy'], args: ['<id>'], summary: 'Submit it to Meta for review. Asks first.', run: deploy },
141
+ { path: ['templates', 'refresh'], args: ['<id>'], summary: 'Ask Meta where the review is.', run: refresh },
142
+ {
143
+ path: ['templates', 'wait'], args: ['<id>'], summary: 'Wait for Meta\'s verdict. Exit 0 approved, 1 refused or timed out.', flags: ['timeout'],
144
+ help: ` --timeout <time> How long to wait: 90s, 10m, 1h. Default: 10m.
145
+
146
+ It asks Meta every ${WAIT_INTERVAL_MS / 1000} s until every WhatsApp account approved it, or one
147
+ rejected, paused or disabled it.`,
148
+ run: wait
149
+ },
150
+ { path: ['templates', 'delete'], args: ['<id>'], summary: 'Delete a template. Asks first.', run: remove }
151
+ ];
@@ -0,0 +1,99 @@
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
+ });
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)));
37
+ return 0;
38
+ }
39
+ async function create(ctx) {
40
+ const name = flag(ctx.args, 'name');
41
+ if (!name)
42
+ throw new UsageError('--name "<client name>" is required.');
43
+ const externalId = flag(ctx.args, 'external-id');
44
+ const withLink = hasFlag(ctx.args, 'connect-link');
45
+ await ctx.confirm(`Add the client "${name}"${withLink ? ' and create its connect link' : ''}`);
46
+ const input = { name, ...(externalId ? { externalId } : {}) };
47
+ const tenants = ctx.nodium().tenants;
48
+ if (withLink) {
49
+ const tenant = await tenants.create(input, { connectLink: linkInput(ctx) });
50
+ ctx.print(tenant, () => {
51
+ ctx.output.out(`${paint(ctx.output, 'green', 'Client added')}: ${tenant.name} (${tenant.id}).`);
52
+ printLink(ctx, tenant.connectLink.url, tenant.connectLink.expiresAt);
53
+ });
54
+ return 0;
55
+ }
56
+ const tenant = await tenants.create(input);
57
+ ctx.print(tenant, () => {
58
+ 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
+ return 0;
62
+ }
63
+ async function newLink(ctx) {
64
+ const tenant = flag(ctx.args, 'tenant');
65
+ if (!tenant)
66
+ throw new UsageError('--tenant <id> is required: the client who connects (its id or your externalId).');
67
+ const input = linkInput(ctx);
68
+ await ctx.confirm(`Create a connect link for client ${tenant}`);
69
+ const link = await ctx.nodium().tenants.connectLink(tenant, input);
70
+ ctx.print(link, () => printLink(ctx, link.url, link.expiresAt));
71
+ return 0;
72
+ }
73
+ const LINK_HELP = ` --redirect-url <https://…> Where your client lands once connected.
74
+ --expires-in-days <n> 1 to 30, default 7.`;
75
+ export const TENANT_COMMANDS = [
76
+ {
77
+ path: ['tenants', 'list'], summary: 'Your clients, newest first.', flags: ['limit', 'external-id'],
78
+ help: ` --external-id <id> The client with your own identifier.
79
+ --limit <n> At most n (default 20).`,
80
+ run: list
81
+ },
82
+ { path: ['tenants', 'get'], args: ['<id>'], summary: 'One client, by its id or your externalId.', run: get },
83
+ {
84
+ path: ['tenants', 'new'], summary: 'Add a client. With --connect-link, prints the link to send it.',
85
+ flags: ['name', 'external-id', 'connect-link', 'redirect-url', 'expires-in-days'],
86
+ help: ` --name <text> The client's name. Required.
87
+ --external-id <id> Your own identifier for it: unique, it makes the call safe to repeat.
88
+ --connect-link Also create the link where it connects its WhatsApp.
89
+ ${LINK_HELP}`,
90
+ run: create
91
+ },
92
+ {
93
+ path: ['connect-link', 'new'], summary: 'A link where your client connects its WhatsApp number.',
94
+ flags: ['redirect-url', 'expires-in-days'],
95
+ help: ` --tenant <id> The client. Required.
96
+ ${LINK_HELP}`,
97
+ run: newLink
98
+ }
99
+ ];
@@ -0,0 +1,160 @@
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. Add one: nodium webhooks new --url <https://…> --event message.received');
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
+ });
34
+ return 0;
35
+ }
36
+ async function deliveries(ctx) {
37
+ const limit = listLimit(ctx.args);
38
+ const subscriptionId = flag(ctx.args, 'subscription');
39
+ const event = flag(ctx.args, 'event');
40
+ const items = await take(ctx.scope().webhooks.eachDelivery({
41
+ limit: pageSize(limit),
42
+ ...(hasFlag(ctx.args, 'failed') ? { status: 'failed' } : {}),
43
+ ...(subscriptionId ? { subscriptionId } : {}), ...(event ? { event } : {})
44
+ }), limit);
45
+ ctx.print(items, () => {
46
+ for (const line of table(items.map(d => [
47
+ 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
+ ])))
49
+ 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
+ });
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
+ ctx.output.out(paint(ctx.output, 'dim', `Try it: nodium webhooks test ${result.id}`));
70
+ });
71
+ return 0;
72
+ }
73
+ async function update(ctx) {
74
+ const id = ctx.args.positionals[0];
75
+ const input = {};
76
+ const url = flag(ctx.args, 'url');
77
+ const label = flag(ctx.args, 'label');
78
+ const active = flag(ctx.args, 'active');
79
+ if (url)
80
+ input.url = url;
81
+ if (label !== undefined)
82
+ input.label = label;
83
+ if (hasFlag(ctx.args, 'event'))
84
+ input.events = readEvents(flagAll(ctx.args, 'event'));
85
+ if (hasFlag(ctx.args, 'channel'))
86
+ input.channelIds = flagAll(ctx.args, 'channel').filter(Boolean);
87
+ if (active !== undefined) {
88
+ if (active !== 'true' && active !== 'false')
89
+ throw new UsageError('--active must be true or false.');
90
+ input.isActive = active === 'true';
91
+ }
92
+ if (!Object.keys(input).length)
93
+ throw new UsageError('Nothing to change: give --url, --event, --channel, --label or --active.');
94
+ await ctx.confirm(`Change webhook ${id} (${Object.keys(input).join(', ')})`);
95
+ 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'}).`));
97
+ return 0;
98
+ }
99
+ async function remove(ctx) {
100
+ const id = ctx.args.positionals[0];
101
+ await ctx.confirm(`Delete webhook ${id}: Nodium stops calling it`);
102
+ const result = await ctx.scope().webhooks.delete(id);
103
+ ctx.print(result ?? { deleted: true }, () => ctx.output.out(`Webhook ${id} deleted.`));
104
+ return 0;
105
+ }
106
+ async function test(ctx) {
107
+ const result = await ctx.scope().webhooks.test(ctx.args.positionals[0]);
108
+ ctx.print(result, () => {
109
+ ctx.output.out(result.ok
110
+ ? `${paint(ctx.output, 'green', String(result.status))} answered in ${result.ms} ms.`
111
+ : `${paint(ctx.output, 'red', result.status === null ? 'ERR' : String(result.status))} ${result.error ?? 'not a 2xx answer'} (${result.ms} ms).`);
112
+ });
113
+ return result.ok ? 0 : 1;
114
+ }
115
+ async function redeliver(ctx) {
116
+ const id = ctx.args.positionals[0];
117
+ await ctx.confirm(`Send delivery ${id} again`);
118
+ const delivery = await ctx.scope().webhooks.redeliver(id);
119
+ ctx.print(delivery, () => ctx.output.out(`Delivery ${delivery.id}: ${delivery.status}${delivery.responseStatus ? ` (${delivery.responseStatus})` : ''}.`));
120
+ return delivery.status === 'failed' ? 1 : 0;
121
+ }
122
+ async function rotateSecret(ctx) {
123
+ const id = ctx.args.positionals[0];
124
+ await ctx.confirm(`Issue a new signing secret for webhook ${id}`);
125
+ const result = await ctx.scope().webhooks.rotateSecret(id);
126
+ ctx.print(result, () => {
127
+ printSecret(ctx, result.secret);
128
+ ctx.output.out(`The old secret still signs until ${when(result.previousValidUntil)}: verify with both meanwhile.`);
129
+ });
130
+ return 0;
131
+ }
132
+ export const WEBHOOK_COMMANDS = [
133
+ { path: ['webhooks', 'list'], summary: 'Your subscriptions.', run: list },
134
+ {
135
+ path: ['webhooks', 'deliveries'], summary: 'The latest deliveries, newest first.', flags: ['limit', 'failed', 'subscription', 'event'],
136
+ help: ` --failed Only the failed ones.
137
+ --subscription <id> Only for this subscription.
138
+ --event <name> Only this event.
139
+ --limit <n> At most n (default 20).`,
140
+ run: deliveries
141
+ },
142
+ {
143
+ path: ['webhooks', 'new'], summary: 'Subscribe an https address to events. Prints the secret once.', flags: ['url', 'event', 'channel', 'label'],
144
+ help: ` --url <https://…> The address Nodium calls. Required.
145
+ --event <name> An event (repeat it, or a,b), * for all. Required.
146
+ --channel <id> Only the events of this number (repeat it). Default: all numbers.
147
+ --label <text> A name for you.`,
148
+ run: create
149
+ },
150
+ {
151
+ path: ['webhooks', 'update'], args: ['<id>'], summary: 'Change the address, events, numbers, label, or pause it.', flags: ['url', 'event', 'channel', 'label', 'active'],
152
+ help: ` --url, --event, --channel, --label As for \`webhooks new\`; --event and --channel replace the list.
153
+ --active <true|false> Resume or pause it.`,
154
+ run: update
155
+ },
156
+ { path: ['webhooks', 'delete'], args: ['<id>'], summary: 'Delete a subscription.', run: remove },
157
+ { path: ['webhooks', 'test'], args: ['<id>'], summary: 'Call the address once with a test event.', run: test },
158
+ { path: ['webhooks', 'redeliver'], args: ['<deliveryId>'], summary: 'Send a delivery again.', run: redeliver },
159
+ { path: ['webhooks', 'rotate-secret'], args: ['<id>'], summary: 'A new signing secret; the old one still works for a while.', run: rotateSecret }
160
+ ];
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
+ }
@@ -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é, le rôle, le client visé. */
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
  }
package/dist/output.js CHANGED
@@ -22,9 +22,7 @@ export function describeError(error, baseUrl) {
22
22
  return error instanceof Error ? error.message : String(error);
23
23
  const lines = [`${error.status ? `Error ${error.status}` : 'Error'} ${error.code}: ${error.message}`];
24
24
  if (error.status === 401)
25
- lines.push('The key is missing, malformed or revoked: check NODIUM_KEY (or --key).');
26
- if (error.status === 403)
27
- lines.push('This key does not have the role the command needs (`listen` reads the event log: `admin`).');
25
+ lines.push('The key is missing, malformed, expired or revoked: check --key, NODIUM_KEY, or run `nodium login` again.');
28
26
  if (error.retryAfter !== null)
29
27
  lines.push(`Retry in ${error.retryAfter} s.`);
30
28
  if (error.requestId)
@@ -37,3 +35,31 @@ export function describeError(error, baseUrl) {
37
35
  }
38
36
  return lines.join('\n');
39
37
  }
38
+ /**
39
+ * Le refus en JSON, sur la sortie d'erreur : `{ "error": { status, code,
40
+ * message, requestId } }`. Une faute d'usage a `status` 0 et le code `usage_error`.
41
+ */
42
+ export function errorJson(error) {
43
+ const body = error instanceof NodiumError
44
+ ? { status: error.status, code: error.code, message: error.message, requestId: error.requestId }
45
+ : { status: 0, code: error instanceof UsageError ? 'usage_error' : 'cli_error', message: error instanceof Error ? error.message : String(error), requestId: null };
46
+ return JSON.stringify({ error: body }, null, 2);
47
+ }
48
+ /** Des colonnes alignées, une ligne par entrée ; la dernière colonne n'est pas complétée. */
49
+ export function table(rows) {
50
+ const widths = [];
51
+ for (const row of rows)
52
+ row.forEach((cell, i) => { widths[i] = Math.max(widths[i] ?? 0, cell.length); });
53
+ return rows.map(row => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ').trimEnd());
54
+ }
55
+ /** Une date ISO en `AAAA-MM-JJ HH:MM`, ou `—`. */
56
+ export function when(iso) {
57
+ return iso ? iso.slice(0, 16).replace('T', ' ') : '—';
58
+ }
59
+ /** Un objet en lignes `clé valeur` ; ce qui est imbriqué s'écrit en JSON compact. */
60
+ export function details(object) {
61
+ return table(Object.entries(object).map(([key, value]) => [
62
+ key,
63
+ value === null || value === undefined ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value)
64
+ ]));
65
+ }