wldx-admin-cli 1.1.0 → 1.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,308 @@
1
+ // A config import, from a folder or (copy) from another PBX read in memory: plan against the target, choose, ask the
2
+ // passwords, confirm, write, say what is left to do; everything in the log. Shared by `config import` and
3
+ // `config copy`.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {createInterface} from 'node:readline/promises';
7
+ import {UsageError} from 'wldx-core/errors';
8
+ import {clean} from 'wldx-core/format';
9
+ import {readSecret} from 'wldx-core/secret';
10
+ import {select} from 'wldx-core/select';
11
+ import {readSerial} from '../pbx.mjs';
12
+ import {applyPlan} from './apply.mjs';
13
+ import {TRANSFER_KINDS, readKinds} from './kinds.mjs';
14
+ import {openLog} from './log.mjs';
15
+ import {nextSteps} from './next-steps.mjs';
16
+ import {makePlan} from './plan.mjs';
17
+ import {keyOf, refsOf} from './refs.mjs';
18
+ import {GROUPS, parseOnly} from './selection.mjs';
19
+
20
+ const listOf = (r) => (Array.isArray(r) ? r : (r?.records ?? []));
21
+ const interactive = (ctx) => Boolean(ctx.stdin?.isTTY && ctx.stderr?.isTTY);
22
+ const tryRead = async (fn) => {
23
+ try {
24
+ return await fn();
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ };
29
+
30
+ // What the plan checks on the target besides its objects: free licenses per type, sounds, music on hold classes,
31
+ // phonebooks (a list that cannot be read is undefined: not checked).
32
+ export async function targetChecks(client) {
33
+ const licenses = await tryRead(async () => {
34
+ const l = await client.call('GET', '/PBX/settings/license/', undefined, {scope: 'pbx'});
35
+ const total = l?.limitations?.licenseTypes ?? {};
36
+ const used = l?.licenseUsage ?? {};
37
+ return Object.fromEntries(Object.keys(total).map((t) => [t, Number(total[t] ?? 0) - Number(used[t] ?? 0)]));
38
+ });
39
+ const entries = async (dir) =>
40
+ listOf(await client.call('GET', dir ? `/Sounds/${dir}/` : '/Sounds/', undefined, {scope: 'sounds'})).filter(
41
+ (e) => e.name !== '..',
42
+ );
43
+ const moh = await tryRead(async () => (await entries('MusicOnHold')).filter((e) => e.isDir).map((e) => e.name));
44
+ const sounds = await tryRead(async () => {
45
+ const names = [];
46
+ const add = (e) => {
47
+ const bare = (s) => String(s).replace(/\.[A-Za-z0-9]{2,4}$/, '');
48
+ names.push(bare(e.name), bare(e.path ?? e.name));
49
+ };
50
+ for (const e of await entries('')) {
51
+ if (!e.isDir) add(e);
52
+ else if (e.name !== 'MusicOnHold') for (const f of await entries(e.name)) if (!f.isDir) add(f);
53
+ }
54
+ return names;
55
+ });
56
+ const phonebooks = await tryRead(async () =>
57
+ listOf(await client.call('GET', '/Phonebooks/?count=1000', undefined, {scope: 'phonebooks'})).flatMap((p) => [
58
+ String(p.id),
59
+ String(p.name),
60
+ ]),
61
+ );
62
+ return {licenses, sounds, moh, phonebooks};
63
+ }
64
+
65
+ // The kinds to read on the target: those of the export and those its references point at.
66
+ function kindsToRead(portable) {
67
+ const kinds = new Set(Object.keys(portable.kinds));
68
+ // queue members and users named by dialplans are checked against the target's users
69
+ if (Object.values(portable.soft ?? {}).some((keys) => keys.some((k) => k.startsWith('users/')))) kinds.add('users');
70
+ for (const list of Object.values(portable.kinds))
71
+ for (const o of list) for (const r of refsOf(o.data)) if (Object.hasOwn(TRANSFER_KINDS, r.kind)) kinds.add(r.kind);
72
+ return Object.keys(TRANSFER_KINDS).filter((k) => kinds.has(k));
73
+ }
74
+
75
+ // The paths of the secrets an object needs.
76
+ export function secretsOf(data, path = '', out = []) {
77
+ if (!data || typeof data !== 'object') return out;
78
+ if (!Array.isArray(data) && typeof data.$secret === 'string') {
79
+ out.push(data.$secret);
80
+ return out;
81
+ }
82
+ for (const [k, v] of Array.isArray(data) ? data.entries() : Object.entries(data))
83
+ secretsOf(v, Array.isArray(data) ? `${path}[${k}]` : path ? `${path}.${k}` : k, out);
84
+ return out;
85
+ }
86
+
87
+ // --secrets FILE: {"<kind>/<name>": {"<path>": "…"}} or {"<kind>": {"<name>": {"<path>": "…"}}}; "password" fills
88
+ // every secret path that names a secret or a password.
89
+ function secretsFrom(file, items) {
90
+ const flat = {};
91
+ for (const [k, v] of Object.entries(file ?? {})) {
92
+ if (k.includes('/')) flat[k] = v;
93
+ else for (const [name, s] of Object.entries(v ?? {})) flat[keyOf(k, name)] = s;
94
+ }
95
+ const out = {};
96
+ for (const it of items) {
97
+ const given = flat[it.key];
98
+ if (!given) continue;
99
+ out[it.key] = {};
100
+ for (const p of secretsOf(it.data)) {
101
+ if (typeof given[p] === 'string') out[it.key][p] = given[p];
102
+ else if (typeof given.password === 'string' && /secret|password/i.test(p.split('.').pop()))
103
+ out[it.key][p] = given.password;
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // --secrets FILE (or - for stdin): a file of passwords by design, so not refused for looking like one (the checks
110
+ // of files to send are for files sent elsewhere; these values go only to the chosen objects on the target). A parse
111
+ // error never repeats the content.
112
+ async function readSecretsFile(ctx, from) {
113
+ let text;
114
+ if (from === '-') {
115
+ const chunks = [];
116
+ for await (const c of ctx.stdin) chunks.push(Buffer.from(c));
117
+ text = Buffer.concat(chunks).toString('utf8');
118
+ } else {
119
+ try {
120
+ text = fs.readFileSync(path.resolve(ctx.cwd ?? process.cwd(), from), 'utf8');
121
+ } catch (e) {
122
+ throw new UsageError(`--secrets ${from}: ${e.code === 'ENOENT' ? 'no such file' : e.code}`);
123
+ }
124
+ }
125
+ try {
126
+ const data = JSON.parse(text);
127
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error();
128
+ return data;
129
+ } catch {
130
+ throw new UsageError(`--secrets ${from === '-' ? 'stdin' : from} is not a JSON object`);
131
+ }
132
+ }
133
+
134
+ // What ticking an object in the picker ticks with it: the new objects it needs. One that differs is on the target
135
+ // already (the reference resolves to it); it is overwritten only when ticked itself.
136
+ export function pickerRequires(plan) {
137
+ const state = new Map(plan.items.map((i) => [i.key, i.state]));
138
+ const needs = new Map(plan.items.map((i) => [i.key, i.needs]));
139
+ return (k) => (needs.get(k) ?? []).filter((n) => state.get(n) === 'new');
140
+ }
141
+
142
+ // The keys to write: the picker, --all or --only (with what they need that is new or differs).
143
+ async function choose(ctx, plan, values) {
144
+ const writable = (i) => i.state === 'new' || (i.state === 'differs' && values.overwrite);
145
+ const byKey = new Map(plan.items.map((i) => [i.key, i]));
146
+ const withNeeds = (keys) => {
147
+ const out = new Set();
148
+ const add = (k) => {
149
+ const it = byKey.get(k);
150
+ if (!it || out.has(k) || it.state === 'blocked' || it.state === 'same') return;
151
+ out.add(k);
152
+ for (const n of it.needs) add(n);
153
+ };
154
+ for (const k of keys) add(k);
155
+ return out;
156
+ };
157
+ if (values.all) return new Set(plan.items.filter(writable).map((i) => i.key));
158
+ if (values.only) {
159
+ const wanted = parseOnly(values.only);
160
+ const keys = plan.items
161
+ .filter((i) => wanted.some((w) => w.kind === i.kind && (w.name === '*' || w.name === i.name)))
162
+ .filter(writable)
163
+ .map((i) => i.key);
164
+ return new Set([...withNeeds(keys)].filter((k) => writable(byKey.get(k))));
165
+ }
166
+ if (!interactive(ctx))
167
+ throw new UsageError('Without a terminal, choose what to import with --all or --only KIND:NAME,… (and --yes)');
168
+ const groups = GROUPS.map((g) => ({
169
+ ...g,
170
+ items: plan.items
171
+ .filter((i) => TRANSFER_KINDS[i.kind].group === g.id && i.state !== 'same')
172
+ .map((i) => ({
173
+ id: i.key,
174
+ label: `${TRANSFER_KINDS[i.kind].label} ${i.name}`,
175
+ badge: i.state === 'differs' ? 'differs: overwrite' : i.state,
176
+ note: i.warnings.length ? i.warnings.map((w) => w.text).join('; ') : undefined,
177
+ checked: i.state === 'new',
178
+ ...(i.state === 'blocked' ? {disabled: i.reason} : {}),
179
+ })),
180
+ })).filter((g) => g.items.length);
181
+ if (!groups.length) return new Set();
182
+ return select(ctx, {
183
+ title: `What to write to ${plan.host} (new ones are ticked; ticking one that differs overwrites it)`,
184
+ groups,
185
+ requires: pickerRequires(plan),
186
+ });
187
+ }
188
+
189
+ async function confirm(ctx, question) {
190
+ const rl = createInterface({input: ctx.stdin, output: ctx.stderr, terminal: false});
191
+ try {
192
+ ctx.stderr.write(question);
193
+ const line = await rl[Symbol.asyncIterator]().next();
194
+ return /^y(es)?$/i.test(String(line.value ?? '').trim());
195
+ } finally {
196
+ rl.close();
197
+ }
198
+ }
199
+
200
+ // from: what the export comes from (a folder or a PBX), for the log and the output; rerun: the command to run the
201
+ // same import again.
202
+ export async function runImport(ctx, {portable, from, client, values, rerun}) {
203
+ const host = client.target.host;
204
+ const serial = await readSerial(client);
205
+ const families = [...new Set((portable.kinds['dialplan-db'] ?? []).map((o) => o.name.split('/')[0]))];
206
+ const target = await readKinds(client, kindsToRead(portable), {dbFamilies: families, info: ctx.info});
207
+ const plan = makePlan(portable, target, {serial, host, ...(await targetChecks(client))});
208
+
209
+ const log = openLog(ctx, {dir: values.log, host, now: ctx.clock()});
210
+ const results = [];
211
+ try {
212
+ log.line(`wx-admin config import${values['dry-run'] ? ' (DRY RUN: nothing is written)' : ''}`);
213
+ log.line(
214
+ `from ${from} to ${host}; access: ${client.target.auth ?? 'x-bees login'}${ctx.profile ? ` (${ctx.profile})` : ''}`,
215
+ );
216
+ log.line(
217
+ `plan: ${Object.entries(plan.counts)
218
+ .map(([k, n]) => `${n} ${k}`)
219
+ .join(', ')}`,
220
+ );
221
+ for (const i of plan.items)
222
+ log.line(
223
+ `plan ${i.state} ${i.key}${i.reason ? `: ${i.reason}` : ''}${i.warnings.length ? ` (${i.warnings.map((w) => w.text).join('; ')})` : ''}`,
224
+ );
225
+
226
+ let chosen;
227
+ try {
228
+ chosen = await choose(ctx, plan, values);
229
+ } catch (e) {
230
+ log.line(`stopped: ${e.message}`);
231
+ throw e;
232
+ }
233
+ for (const k of chosen) log.line(`chosen ${k}`);
234
+ const items = plan.items.filter((i) => chosen.has(i.key));
235
+
236
+ // Passwords of the chosen objects that need one: --secrets, else asked (empty: skip that object).
237
+ let secrets = values.secrets ? secretsFrom(await readSecretsFile(ctx, values.secrets), items) : {};
238
+ if (!values.secrets && interactive(ctx) && !values['dry-run']) {
239
+ secrets = {};
240
+ for (const it of items) {
241
+ for (const p of secretsOf(it.data)) {
242
+ const label = clean(`${TRANSFER_KINDS[it.kind].label} ${it.name}, ${p}`).replace(
243
+ /\x1b\[[0-9;]*m|[\r\n]/g,
244
+ ' ',
245
+ );
246
+ const v = await readSecret(ctx, `${label} (empty: skip it): `);
247
+ if (v) (secrets[it.key] ??= {})[p] = v;
248
+ }
249
+ }
250
+ }
251
+
252
+ const creating = items.filter((i) => i.state === 'new').length;
253
+ const overwriting = items.filter((i) => i.state === 'differs').length;
254
+ const summary = `Write to ${host}: create ${creating}, overwrite ${overwriting}; not written: ${plan.items.length - creating - overwriting} (${plan.counts.same} the same, ${plan.counts.blocked} blocked)${plan.welcomeMails ? `; ${plan.welcomeMails} new users with an e-mail may get the welcome e-mail of the PBX` : ''}`;
255
+ log.line(summary);
256
+ let write = !values['dry-run'] && chosen.size > 0;
257
+ if (write && !values.yes) {
258
+ write = interactive(ctx) ? await confirm(ctx, `${summary}\nWrite? [y/N] `) : false;
259
+ if (!write)
260
+ log.line(interactive(ctx) ? 'not confirmed: nothing written' : 'no --yes without a terminal: nothing written');
261
+ }
262
+ if (write) {
263
+ const selfDn = serial ? `pbxid=${serial},dc=wildix` : null;
264
+ results.push(
265
+ ...(await applyPlan(client, plan, chosen, {
266
+ secrets,
267
+ selfDn,
268
+ onEvent: (r) => {
269
+ const line = `${r.action} ${r.key}${r.id ? ` id ${r.id}` : ''}${r.error ? `: ${r.error}` : ''}${r.because ? ` (because ${r.because})` : ''}`;
270
+ log.line(line);
271
+ ctx.info(line);
272
+ },
273
+ })),
274
+ );
275
+ }
276
+ const steps = nextSteps({plan, results, host, rerun});
277
+ for (const [n, s] of steps.entries())
278
+ log.line(
279
+ `next ${n + 1}${s.blocking ? ' (needed)' : ''}: ${s.what} — ${s.why}${s.wms ? ` — ${s.wms}` : ''}${s.command ? ` — ${s.command}` : ''}`,
280
+ );
281
+ log.line('done');
282
+ return {
283
+ pbx: host,
284
+ from,
285
+ dryRun: Boolean(values['dry-run']),
286
+ written: write,
287
+ plan: {
288
+ counts: plan.counts,
289
+ welcomeMails: plan.welcomeMails,
290
+ items: plan.items.map(({key, state, reason, warnings}) => ({
291
+ key,
292
+ state,
293
+ ...(reason ? {reason} : {}),
294
+ warnings: warnings.map((w) => w.text),
295
+ })),
296
+ },
297
+ chosen: [...chosen],
298
+ results,
299
+ nextSteps: steps,
300
+ log: log.path,
301
+ };
302
+ } catch (e) {
303
+ log.line(`error: ${e.message}`);
304
+ throw e;
305
+ } finally {
306
+ log.close();
307
+ }
308
+ }
@@ -0,0 +1,124 @@
1
+ // What a config export carries: --all, --only KIND:NAME,…, or the picker; and what an object brings with it.
2
+ import {UsageError} from 'wldx-core/errors';
3
+ import {keyOf} from './refs.mjs';
4
+ import {TRANSFER_KINDS} from './kinds.mjs';
5
+
6
+ export const GROUPS = [
7
+ {id: 'routing', label: 'Routing'},
8
+ {id: 'users', label: 'Users and rights'},
9
+ {id: 'trunks', label: 'Trunks'},
10
+ ];
11
+
12
+ // Split at the commas that are not escaped; \, and \\ in a name stand for , and \.
13
+ function splitList(spec) {
14
+ const out = [''];
15
+ for (let i = 0; i < spec.length; i += 1) {
16
+ const ch = spec[i];
17
+ if (ch === '\\' && (spec[i + 1] === ',' || spec[i + 1] === '\\')) out[out.length - 1] += spec[(i += 1)];
18
+ else if (ch === ',') out.push('');
19
+ else out[out.length - 1] += ch;
20
+ }
21
+ return out;
22
+ }
23
+
24
+ // --only "dialplans:main,queues:*" → [{kind, name}] ("*": every object of the kind; a name may hold "/" and ":",
25
+ // and "," written \,).
26
+ export function parseOnly(spec) {
27
+ return splitList(String(spec))
28
+ .map((s) => s.trim())
29
+ .filter(Boolean)
30
+ .map((s) => {
31
+ const at = s.indexOf(':');
32
+ const kind = at < 0 ? s : s.slice(0, at);
33
+ if (!Object.hasOwn(TRANSFER_KINDS, kind))
34
+ throw new UsageError(`Unknown kind: ${kind}. Known: ${Object.keys(TRANSFER_KINDS).join(', ')}`);
35
+ return {kind, name: at < 0 ? '*' : s.slice(at + 1)};
36
+ });
37
+ }
38
+
39
+ // The families of the dialplan database the dialplans read or write: DB(family/key), DB_EXISTS(…), DB_DELETE(…).
40
+ export function dbFamiliesOf(dialplans = []) {
41
+ const text = JSON.stringify(dialplans.map((d) => d.data));
42
+ return [...new Set([...text.matchAll(/DB(?:_EXISTS|_DELETE)?\(([A-Za-z0-9_.:+@-]+)\//g)].map((m) => m[1]))];
43
+ }
44
+
45
+ const boundKeys = (portable) => new Set((portable.bound ?? []).map((b) => keyOf(b.kind, b.name)));
46
+
47
+ // The keys and everything they need, transitively (only objects of the export).
48
+ export function closure(portable, keys) {
49
+ const have = new Set(Object.entries(portable.kinds).flatMap(([k, list]) => list.map((o) => keyOf(k, o.name))));
50
+ const out = new Set();
51
+ const add = (key) => {
52
+ if (out.has(key) || !have.has(key)) return;
53
+ out.add(key);
54
+ for (const n of portable.needs?.[key] ?? []) add(n);
55
+ };
56
+ for (const k of keys) add(k);
57
+ return out;
58
+ }
59
+
60
+ // The groups of the picker: objects by group of their kind; bound objects cannot be chosen; users not ticked unless
61
+ // asked for.
62
+ export function pickerGroups(portable, {users = false} = {}) {
63
+ const bound = new Map((portable.bound ?? []).map((b) => [keyOf(b.kind, b.name), b]));
64
+ return GROUPS.map((g) => ({
65
+ ...g,
66
+ items: Object.entries(portable.kinds)
67
+ .filter(([kind]) => TRANSFER_KINDS[kind].group === g.id)
68
+ .flatMap(([kind, list]) =>
69
+ list.map((o) => {
70
+ const key = keyOf(kind, o.name);
71
+ const b = bound.get(key);
72
+ return {
73
+ id: key,
74
+ label: `${TRANSFER_KINDS[kind].label} ${o.name}`,
75
+ checked: !b && (kind !== 'users' || users),
76
+ ...(b ? {disabled: `bound to the source PBX: ${b.found} in ${b.path}`} : {}),
77
+ };
78
+ }),
79
+ ),
80
+ })).filter((g) => g.items.length);
81
+ }
82
+
83
+ // The keys to export: --all (users with --users), --only (with what they need), else the picker.
84
+ export async function chooseExport(portable, values, {interactive, select}) {
85
+ const bound = boundKeys(portable);
86
+ const all = Object.entries(portable.kinds).flatMap(([kind, list]) => list.map((o) => keyOf(kind, o.name)));
87
+ let keys;
88
+ if (values.all) keys = all.filter((k) => !k.startsWith('users/') || values.users);
89
+ else if (values.only) {
90
+ const wanted = parseOnly(values.only);
91
+ keys = all.filter((k) =>
92
+ wanted.some((w) => k.startsWith(`${w.kind}/`) && (w.name === '*' || k === keyOf(w.kind, w.name))),
93
+ );
94
+ } else if (interactive) {
95
+ const chosen = await select({
96
+ title: 'What to export (space: tick, enter: done)',
97
+ groups: pickerGroups(portable, {users: values.users}),
98
+ requires: (key) => (portable.needs?.[key] ?? []).filter((k) => all.includes(k)),
99
+ });
100
+ keys = [...chosen];
101
+ } else throw new UsageError('Without a terminal, choose what to export with --all or --only KIND:NAME,…');
102
+ return new Set([...closure(portable, keys)].filter((k) => !bound.has(k)));
103
+ }
104
+
105
+ // The export with only the chosen objects (and the notes about them).
106
+ export function selectFrom(portable, keys) {
107
+ const kinds = {};
108
+ for (const [kind, list] of Object.entries(portable.kinds)) {
109
+ const kept = list.filter((o) => keys.has(keyOf(kind, o.name)));
110
+ if (kept.length) kinds[kind] = kept;
111
+ }
112
+ const mine = (x) => keys.has(keyOf(x.kind, x.name));
113
+ const pick = (map) => Object.fromEntries(Object.entries(map ?? {}).filter(([k]) => keys.has(k)));
114
+ return {
115
+ kinds,
116
+ errors: portable.errors,
117
+ bound: portable.bound,
118
+ external: (portable.external ?? []).filter(mine),
119
+ unresolved: (portable.unresolved ?? []).filter(mine),
120
+ outside: (portable.outside ?? []).filter((o) => keys.has(o.from)),
121
+ needs: pick(portable.needs),
122
+ soft: pick(portable.soft),
123
+ };
124
+ }
package/src/secret.mjs DELETED
@@ -1,42 +0,0 @@
1
- // Reads a secret (an API key, a token, a password) without echo: hidden typing in a terminal, else the first line
2
- // of stdin (`… | wx-admin pbx add …`). Never from a command line, where other processes can read it.
3
- import {UsageError} from 'wldx-core/errors';
4
-
5
- export async function readSecret(ctx, prompt) {
6
- const input = ctx.stdin;
7
- if (input.isTTY && typeof input.setRawMode === 'function') {
8
- ctx.stderr.write(prompt);
9
- input.setRawMode(true);
10
- input.resume();
11
- input.setEncoding('utf8');
12
- try {
13
- return await new Promise((resolve, reject) => {
14
- let value = '';
15
- const onData = (chunk) => {
16
- for (const ch of chunk) {
17
- if (ch === '\r' || ch === '\n') {
18
- input.off('data', onData);
19
- ctx.stderr.write('\n');
20
- return resolve(value);
21
- }
22
- if (ch === '\u0003') {
23
- input.off('data', onData);
24
- ctx.stderr.write('\n');
25
- return reject(new UsageError('Cancelled'));
26
- }
27
- if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
28
- else value += ch;
29
- }
30
- return undefined;
31
- };
32
- input.on('data', onData);
33
- });
34
- } finally {
35
- input.setRawMode(false);
36
- input.pause();
37
- }
38
- }
39
- const chunks = [];
40
- for await (const c of input) chunks.push(Buffer.from(c));
41
- return Buffer.concat(chunks).toString('utf8').split(/\r?\n/)[0];
42
- }