wldx-admin-cli 1.1.0 → 1.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 +57 -9
- package/package.json +2 -2
- package/src/commands/config.mjs +248 -1
- package/src/commands/pbx.mjs +1 -1
- package/src/commands/system.mjs +1 -1
- package/src/commands/trunk.mjs +1 -1
- package/src/transfer/apply.mjs +109 -0
- package/src/transfer/folder.mjs +77 -0
- package/src/transfer/kinds.mjs +259 -0
- package/src/transfer/log.mjs +35 -0
- package/src/transfer/next-steps.mjs +251 -0
- package/src/transfer/plan.mjs +153 -0
- package/src/transfer/refs.mjs +251 -0
- package/src/transfer/run.mjs +308 -0
- package/src/transfer/selection.mjs +124 -0
- package/src/secret.mjs +0 -42
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# wldx-admin-cli — `wx-admin`, an admin command line for the Wildix PBX
|
|
2
2
|
|
|
3
|
-
**Version 1.1
|
|
3
|
+
**Version 1.2.1** · unofficial · MIT
|
|
4
4
|
|
|
5
5
|
`wx-admin` manages a Wildix PBX through its REST API (`https://<pbx>/api/v1`): the system and its settings, users,
|
|
6
6
|
ACL, queues, dialplans and the objects they use (timetables, IVR, switches, paging, the dialplan database), trunks,
|
|
@@ -18,8 +18,9 @@ npx wldx-admin-cli --help # without installing
|
|
|
18
18
|
|
|
19
19
|
Requires Node.js 22 or newer. One dependency: [`wldx-core`](https://github.com/gorets/wildix-cli/tree/main/packages/core), the core it shares with `wx`, from the
|
|
20
20
|
same repository and of the same version.
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
Unofficial: not made or supported by Wildix. It uses the PBX API as documented on the PBX
|
|
23
|
+
(`https://<pbx>/api/v1/doc/`), which may change between WMS versions.
|
|
23
24
|
|
|
24
25
|
## Access to a PBX
|
|
25
26
|
|
|
@@ -39,10 +40,6 @@ refused while it is set.
|
|
|
39
40
|
|
|
40
41
|
### Several PBX
|
|
41
42
|
|
|
42
|
-
`--all-pbx` runs a command that only reads on every PBX (saved access, then the x-bees login's) and prints each
|
|
43
|
-
result under its PBX: `wx-admin --all-pbx system info`, `wx-admin --all-pbx user registrations`.
|
|
44
|
-
|
|
45
|
-
|
|
46
43
|
Which PBX a command works on:
|
|
47
44
|
|
|
48
45
|
1. `--pbx PBX` (anywhere on the command line): `name.wildixin.com`, a bare `name` (= `name.wildixin.com`), an IP
|
|
@@ -54,6 +51,9 @@ Which PBX a command works on:
|
|
|
54
51
|
`wx-admin pbx list` shows them (`*` is the default), `wx-admin pbx check` says which one a command would use and
|
|
55
52
|
whether it accepts the access. Saved access lives in `~/.wildix/admin/pbx/<host>.json` (folder 0700, files 0600).
|
|
56
53
|
|
|
54
|
+
`--all-pbx` runs a command that only reads on every PBX (saved access, then the x-bees login's) and prints each
|
|
55
|
+
result under its PBX: `wx-admin --all-pbx system info`, `wx-admin --all-pbx user registrations`.
|
|
56
|
+
|
|
57
57
|
## Dialplans
|
|
58
58
|
|
|
59
59
|
A dialplan (procedure) is `{name, description, numbers, includedDialplans}`; its rules are `numbers`, run in order,
|
|
@@ -76,6 +76,37 @@ ACL groups. Ids and runtime fields are left out and included dialplans are named
|
|
|
76
76
|
in git; `wx-admin config diff DIR` shows what changed on the PBX since (`+` only on the PBX, `-` only in the folder,
|
|
77
77
|
`~` changed, with the paths), `--with OTHERPBX` compares two PBX, `--all-pbx` compares every PBX with one folder.
|
|
78
78
|
|
|
79
|
+
## Moving configuration to another PBX
|
|
80
|
+
|
|
81
|
+
A WMS backup restored on another PBX carries LDAP and values bound to the serial of the first one, and breaks the
|
|
82
|
+
second. `wx-admin` carries a chosen part of the configuration instead, with nothing bound to the source:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
wx-admin config export --pbx old --to ./move # tick what to carry (or --all, --only KIND:NAME,…; --users)
|
|
86
|
+
wx-admin config import ./move --pbx new --dry-run # the plan: new, the same, differs, blocked (and why)
|
|
87
|
+
wx-admin config import ./move --pbx new # tick, type the trunk passwords, confirm, write
|
|
88
|
+
wx-admin config copy --from old --to new # the same without the folder
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- **What is carried:** dialplans (with included dialplans), general dialplan settings, timetables, IVR menus,
|
|
92
|
+
switches, paging groups, queues, dialplan database keys (the families the dialplans use; more with
|
|
93
|
+
`--db-family`), ACL groups, SIP trunks, trunk groups, price lists, and users with `--users` (portable fields
|
|
94
|
+
only). **Never:** devices, licenses, PSTN and FXO trunks, call history, recordings, voicemails, LDAP ids, jids,
|
|
95
|
+
passwords, system settings.
|
|
96
|
+
- **References** between objects are kept by name (`{"$ref": "queues/Support", "as": "id"}`) and become the ids,
|
|
97
|
+
names or LDAP dns of the new PBX on import; ACL admin rules about the old PBX are about the new one. An object
|
|
98
|
+
where the serial or the host of the old PBX is still found is not carried (listed as bound).
|
|
99
|
+
- **The folder** is one JSON Lines file per kind (`users.jsonl`: one object per line) and `manifest.json`; it holds
|
|
100
|
+
no password and not the serial.
|
|
101
|
+
- **The import** compares every object with the new PBX by name: new ones are ticked, one that differs is
|
|
102
|
+
overwritten only when ticked (or `--overwrite`), blocked ones say why (a reference to nothing, no free license,
|
|
103
|
+
an extension taken). Passwords of the chosen trunks are typed hidden (or `--secrets FILE`). Objects are written in
|
|
104
|
+
the order they need each other; one that fails does not stop the rest, and running the same import again writes
|
|
105
|
+
only what is left.
|
|
106
|
+
- **At the end**, a list of what is left to do on the new PBX (where in WMS, which `wx-admin` command), the blocking
|
|
107
|
+
ones first, and a log file `wx-admin-import-<host>-<time>.log` (here, or `--log DIR`), written every time, also
|
|
108
|
+
with `--dry-run`. Without a terminal: `--all` or `--only`, and `--yes` to write.
|
|
109
|
+
|
|
79
110
|
## Commands
|
|
80
111
|
|
|
81
112
|
Every command has `-h`, takes `--pbx HOST` and `--profile EMAIL`, and prints text or, with `--output json`, JSON.
|
|
@@ -331,6 +362,9 @@ The configuration as files: back up a PBX to a folder, compare a folder or anoth
|
|
|
331
362
|
| Command | What it does |
|
|
332
363
|
| --- | --- |
|
|
333
364
|
| `wx-admin config backup --to DIR [--only KIND,KIND…]` | write the configuration of the PBX to a new folder, one JSON file per object (passwords masked) |
|
|
365
|
+
| `wx-admin config import <dir> [--all \| --only KIND:NAME,…] [--overwrite] [--secrets FILE] [--dry-run] [--yes] [--log DIR]` | write a config export to this PBX: a plan, what to write (chosen), the passwords, a log and what is left to do |
|
|
366
|
+
| `wx-admin config copy --from PBX --to PBX [--all \| --only KIND:NAME,…] [--users] [--overwrite] [--secrets FILE] [--dry-run] [--yes] [--log DIR]` | carry the configuration of one PBX to another: export in memory, then import (plan, choice, log) |
|
|
367
|
+
| `wx-admin config export --to DIR [--all \| --only KIND:NAME,…] [--users] [--db-family FAMILY]...` | choose what to carry to another PBX and write it to a new folder, without anything bound to this PBX |
|
|
334
368
|
| `wx-admin config diff (<dir> \| --with PBX) [--only KIND,KIND…]` | what differs between a backup folder (or another PBX) and the PBX |
|
|
335
369
|
|
|
336
370
|
### wx-admin pbx
|
|
@@ -354,11 +388,16 @@ Your profiles (the e-mails you are logged in with) and the default one.
|
|
|
354
388
|
| `wx-admin profile list` | your profiles; * marks the default one |
|
|
355
389
|
| `wx-admin profile use <e-mail>` | make a profile the default one (used when no --profile is given) |
|
|
356
390
|
|
|
357
|
-
###
|
|
391
|
+
### wx-admin api
|
|
358
392
|
|
|
359
393
|
| Command | What it does |
|
|
360
394
|
| --- | --- |
|
|
361
395
|
| `wx-admin api <method> <path> [--data JSON \| --data-file FILE\|-] [--query KEY=VALUE]... [--yes]` | call any PBX API operation (GET reads; other methods change the PBX and need --yes) |
|
|
396
|
+
|
|
397
|
+
### Login and profiles
|
|
398
|
+
|
|
399
|
+
| Command | What it does |
|
|
400
|
+
| --- | --- |
|
|
362
401
|
| `wx-admin login [<e-mail>] [--send-code \| --code CODE]` | log in with a one-time code sent to your e-mail |
|
|
363
402
|
| `wx-admin logout [--profile EMAIL] [--everywhere]` | log out: revoke the session of this device, or of every device |
|
|
364
403
|
| `wx-admin status [--profile EMAIL] [--check]` | who you are logged in as, until when, and the version of wx-admin |
|
|
@@ -371,6 +410,13 @@ As in the internal Wildix CLI: text for people by default, `--output json` for s
|
|
|
371
410
|
PBX, `{"type":"result","result":X}` unwrapped to X), `--pretty`, `--no-color`; they may stand anywhere. Messages for
|
|
372
411
|
people (notes, prompts) go to stderr.
|
|
373
412
|
|
|
413
|
+
## Updates
|
|
414
|
+
|
|
415
|
+
Once a day, while a command runs in a terminal, `wx-admin` asks npm for its latest version (in the background, never
|
|
416
|
+
holding the command); when there is a newer one, a line after the output says so (`npm i -g wldx-admin-cli`).
|
|
417
|
+
Nothing is installed by itself. Off in CI, under `npx`, without a terminal, with `--output json`, or with
|
|
418
|
+
`WX_NO_UPDATE_CHECK=1`.
|
|
419
|
+
|
|
374
420
|
## Exit codes
|
|
375
421
|
|
|
376
422
|
`0` done · `1` error (network, the PBX refused the request) · `2` not logged in, or the PBX refused the access ·
|
|
@@ -397,7 +443,9 @@ pnpm --filter wldx-admin-cli test # node:test, no network (fake PBX)
|
|
|
397
443
|
node packages/wx-admin/bin/wx-admin.mjs -h # this checkout, without installing
|
|
398
444
|
```
|
|
399
445
|
|
|
400
|
-
See [AGENTS.md](https://github.com/gorets/wildix-cli/blob/main/packages/wx-admin/AGENTS.md).
|
|
446
|
+
See [AGENTS.md](https://github.com/gorets/wildix-cli/blob/main/packages/wx-admin/AGENTS.md). In `packages/wx-admin`: `node test/dev.mjs <module> <args>`
|
|
447
|
+
runs a command module that is not listed yet; after changing commands, regenerate the reference with
|
|
448
|
+
`node tools/readme-commands.mjs` and paste it between the markers.
|
|
401
449
|
|
|
402
450
|
## Releasing
|
|
403
451
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wldx-admin-cli",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Unofficial admin command line for the Wildix PBX (dialplans and the PBX REST API), with an x-bees login or a Company API Key",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wildix",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=22"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"wldx-core": "1.1
|
|
34
|
+
"wldx-core": "1.2.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "node --test test/*.test.mjs"
|
package/src/commands/config.mjs
CHANGED
|
@@ -9,7 +9,14 @@ import path from 'node:path';
|
|
|
9
9
|
import {UsageError} from 'wldx-core/errors';
|
|
10
10
|
import {refuseUnsafeWrite} from 'wldx-core/files';
|
|
11
11
|
import {oneLine, paint} from 'wldx-core/format';
|
|
12
|
-
import {
|
|
12
|
+
import {select} from 'wldx-core/select';
|
|
13
|
+
import {pbxClient, readSerial} from '../pbx.mjs';
|
|
14
|
+
import {readExport, writeExport} from '../transfer/folder.mjs';
|
|
15
|
+
import {readKinds, TRANSFER_KINDS} from '../transfer/kinds.mjs';
|
|
16
|
+
import {shellArg} from '../transfer/next-steps.mjs';
|
|
17
|
+
import {runImport} from '../transfer/run.mjs';
|
|
18
|
+
import {toPortable} from '../transfer/refs.mjs';
|
|
19
|
+
import {chooseExport, closure, dbFamiliesOf, selectFrom} from '../transfer/selection.mjs';
|
|
13
20
|
import {maskSecrets} from './trunk.mjs';
|
|
14
21
|
|
|
15
22
|
const listOf = (r) => (Array.isArray(r) ? r : (r?.records ?? []));
|
|
@@ -190,6 +197,115 @@ export function compare(left, right) {
|
|
|
190
197
|
|
|
191
198
|
const short = (v) => (v === undefined ? '(none)' : oneLine(JSON.stringify(v), 60));
|
|
192
199
|
|
|
200
|
+
const interactive = (ctx) => Boolean(ctx.stdin?.isTTY && ctx.stderr?.isTTY);
|
|
201
|
+
|
|
202
|
+
// A PBX read for a transfer: its objects turned portable (markers instead of references), with where it is.
|
|
203
|
+
export async function exportOf(ctx, client, {users, dbFamily = []}) {
|
|
204
|
+
const serial = await readSerial(client);
|
|
205
|
+
// The safety scan and the ACL admin rules need the serial: without it, nothing is carried.
|
|
206
|
+
if (!serial)
|
|
207
|
+
throw new UsageError(
|
|
208
|
+
`The serial of ${client.target.host} is not readable with this access (GET /PBX/settings/, scope pbx:read): it is needed to leave out everything bound to it. Use an access that can read it.`,
|
|
209
|
+
);
|
|
210
|
+
let wms;
|
|
211
|
+
try {
|
|
212
|
+
wms = (await client.call('GET', '/PBX/version/', undefined, {scope: 'info'}))?.version;
|
|
213
|
+
} catch {
|
|
214
|
+
wms = undefined;
|
|
215
|
+
}
|
|
216
|
+
const kinds = Object.keys(TRANSFER_KINDS).filter((k) => k !== 'dialplan-db' && (k !== 'users' || users));
|
|
217
|
+
const snap = await readKinds(client, kinds, {info: ctx.info});
|
|
218
|
+
const families = [...new Set([...dbFamiliesOf(Array.isArray(snap.dialplans) ? snap.dialplans : []), ...dbFamily])];
|
|
219
|
+
Object.assign(snap, await readKinds(client, ['dialplan-db'], {dbFamilies: families, info: ctx.info}));
|
|
220
|
+
return {
|
|
221
|
+
portable: toPortable(snap, {serial, host: client.target.host}),
|
|
222
|
+
source: {host: client.target.host, wms},
|
|
223
|
+
serial,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const CHOICE_OPTIONS = {
|
|
228
|
+
all: {type: 'boolean', description: 'everything (users only with --users)'},
|
|
229
|
+
only: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
placeholder: 'KIND:NAME,…',
|
|
232
|
+
description: 'only these objects and what they need (NAME * for every one of a kind)',
|
|
233
|
+
},
|
|
234
|
+
users: {
|
|
235
|
+
type: 'boolean',
|
|
236
|
+
description: 'include the users (portable fields only: no ids, passwords or anything of the PBX)',
|
|
237
|
+
},
|
|
238
|
+
'db-family': {
|
|
239
|
+
type: 'string',
|
|
240
|
+
multiple: true,
|
|
241
|
+
placeholder: 'FAMILY',
|
|
242
|
+
description: 'a family of the dialplan database to carry (besides those the dialplans name)',
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const IMPORT_OPTIONS = {
|
|
247
|
+
all: {type: 'boolean', description: 'every new object (and those that differ, with --overwrite)'},
|
|
248
|
+
only: CHOICE_OPTIONS.only,
|
|
249
|
+
overwrite: {type: 'boolean', description: 'without the list: overwrite the objects that differ (else they are left)'},
|
|
250
|
+
secrets: {
|
|
251
|
+
type: 'string',
|
|
252
|
+
placeholder: 'FILE',
|
|
253
|
+
description: 'without a terminal: the passwords, {"trunks-sip/<name>": {"password": "…"}}',
|
|
254
|
+
},
|
|
255
|
+
'dry-run': {type: 'boolean', description: 'the plan and the next steps only; nothing is written (the log is)'},
|
|
256
|
+
yes: {type: 'boolean', description: 'without a terminal: write without asking'},
|
|
257
|
+
log: {type: 'string', placeholder: 'DIR', description: 'where the log file goes (default: here)'},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
function checkImportValues(ctx) {
|
|
261
|
+
const v = ctx.values;
|
|
262
|
+
if (v.all && v.only) throw new UsageError('Give --all or --only, not both', ctx.usage());
|
|
263
|
+
if (!v.all && !v.only && !interactive(ctx))
|
|
264
|
+
throw new UsageError(
|
|
265
|
+
'Without a terminal, choose what to import with --all or --only KIND:NAME,… (and --yes to write)',
|
|
266
|
+
ctx.usage(),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The result printed; exit 1 when an object failed.
|
|
271
|
+
function finish(ctx, r) {
|
|
272
|
+
if (!r.results.some((x) => x.action === 'failed')) return r;
|
|
273
|
+
ctx.emit(r);
|
|
274
|
+
const err = new Error(`${r.results.filter((x) => x.action === 'failed').length} objects failed (log: ${r.log})`);
|
|
275
|
+
err.exitCode = 1;
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function importText(r, ctx) {
|
|
280
|
+
const c = paint(ctx);
|
|
281
|
+
const lines = [];
|
|
282
|
+
const count = (a) => r.results.filter((x) => x.action === a).length;
|
|
283
|
+
if (r.dryRun) lines.push(c.bold(`Dry run: nothing was written to ${r.pbx}`));
|
|
284
|
+
else if (!r.written) lines.push(c.bold(`Nothing was written to ${r.pbx}`));
|
|
285
|
+
else
|
|
286
|
+
lines.push(
|
|
287
|
+
c.bold(
|
|
288
|
+
`Written to ${r.pbx}: ${count('create')} created, ${count('overwrite')} overwritten, ${count('skip')} skipped, ${count('failed')} failed`,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
lines.push(
|
|
292
|
+
`Plan from ${r.from}: ${Object.entries(r.plan.counts)
|
|
293
|
+
.map(([k, n]) => `${n} ${k}`)
|
|
294
|
+
.join(', ')}`,
|
|
295
|
+
);
|
|
296
|
+
for (const i of r.plan.items.filter((x) => x.state === 'blocked'))
|
|
297
|
+
lines.push(c.yellow(` blocked ${i.key}: ${i.reason}`));
|
|
298
|
+
for (const x of r.results.filter((y) => y.action === 'failed')) lines.push(c.red(` failed ${x.key}: ${x.error}`));
|
|
299
|
+
if (r.nextSteps.length) lines.push('', c.bold('Next steps:'));
|
|
300
|
+
for (const [n, s] of r.nextSteps.entries()) {
|
|
301
|
+
lines.push(`${n + 1}. ${s.blocking ? c.yellow('[needed] ') : ''}${s.what}`, ` ${c.dim(s.why)}`);
|
|
302
|
+
if (s.wms) lines.push(` ${c.dim(`WMS: ${s.wms}`)}`);
|
|
303
|
+
if (s.command) lines.push(` ${s.command}`);
|
|
304
|
+
}
|
|
305
|
+
lines.push('', `Log: ${r.log}`);
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
193
309
|
export const groups = {
|
|
194
310
|
config: {
|
|
195
311
|
summary: 'the configuration as files: back up a PBX to a folder, compare a folder or another PBX with a PBX',
|
|
@@ -228,6 +344,137 @@ export const groups = {
|
|
|
228
344
|
...Object.entries(r.kinds).map(([k, n]) => ` ${k.padEnd(18)} ${n}`),
|
|
229
345
|
],
|
|
230
346
|
},
|
|
347
|
+
import: {
|
|
348
|
+
summary:
|
|
349
|
+
'write a config export to this PBX: a plan, what to write (chosen), the passwords, a log and what is left to do',
|
|
350
|
+
usage:
|
|
351
|
+
'wx-admin config import <dir> [--all | --only KIND:NAME,…] [--overwrite] [--secrets FILE] [--dry-run] [--yes] [--log DIR]',
|
|
352
|
+
details: [
|
|
353
|
+
'Every object of the folder is compared with this PBX: new, the same, differs, or blocked (with the reason). In a',
|
|
354
|
+
'terminal, a list to tick opens (new ones ticked; ticking one that differs overwrites it), then the passwords',
|
|
355
|
+
'the chosen trunks need, then a confirmation. Objects are written in the order they need each other; one that',
|
|
356
|
+
'fails does not stop the rest. A log file is written every time (--log DIR, else here), also with --dry-run.',
|
|
357
|
+
'Without a terminal: --all or --only, and --yes to write.',
|
|
358
|
+
].join('\n'),
|
|
359
|
+
options: IMPORT_OPTIONS,
|
|
360
|
+
args: [1, 1],
|
|
361
|
+
async run(ctx) {
|
|
362
|
+
checkImportValues(ctx);
|
|
363
|
+
const {manifest, kinds} = await readExport(ctx, ctx.positionals[0]);
|
|
364
|
+
const portable = {...manifest, kinds};
|
|
365
|
+
const client = await pbxClient(ctx);
|
|
366
|
+
const rerun = `wx-admin config import ${shellArg(ctx.positionals[0])} --pbx ${shellArg(client.target.host)}`;
|
|
367
|
+
return finish(
|
|
368
|
+
ctx,
|
|
369
|
+
await runImport(ctx, {
|
|
370
|
+
portable,
|
|
371
|
+
from: `${ctx.positionals[0]} (exported from ${manifest.source?.host ?? 'a PBX'})`,
|
|
372
|
+
client,
|
|
373
|
+
values: ctx.values,
|
|
374
|
+
rerun,
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
},
|
|
378
|
+
text: importText,
|
|
379
|
+
},
|
|
380
|
+
copy: {
|
|
381
|
+
summary: 'carry the configuration of one PBX to another: export in memory, then import (plan, choice, log)',
|
|
382
|
+
usage:
|
|
383
|
+
'wx-admin config copy --from PBX --to PBX [--all | --only KIND:NAME,…] [--users] [--overwrite] [--secrets FILE] [--dry-run] [--yes] [--log DIR]',
|
|
384
|
+
details: 'As config export then config import, without a folder. --from and --to take what --pbx takes.',
|
|
385
|
+
options: {
|
|
386
|
+
from: {type: 'string', placeholder: 'PBX', description: 'the PBX to read', complete: 'pbxs'},
|
|
387
|
+
to: {type: 'string', placeholder: 'PBX', description: 'the PBX to write', complete: 'pbxs'},
|
|
388
|
+
users: CHOICE_OPTIONS.users,
|
|
389
|
+
'db-family': CHOICE_OPTIONS['db-family'],
|
|
390
|
+
...IMPORT_OPTIONS,
|
|
391
|
+
},
|
|
392
|
+
args: [0, 0],
|
|
393
|
+
async run(ctx) {
|
|
394
|
+
const v = ctx.values;
|
|
395
|
+
if (!v.from || !v.to) throw new UsageError('--from and --to are needed', ctx.usage());
|
|
396
|
+
if (ctx.pbx) throw new UsageError('copy takes --from and --to, not --pbx', ctx.usage());
|
|
397
|
+
checkImportValues(ctx);
|
|
398
|
+
const source = await pbxClient({...ctx, pbx: v.from});
|
|
399
|
+
const client = await pbxClient({...ctx, pbx: v.to});
|
|
400
|
+
if (source.target.host === client.target.host)
|
|
401
|
+
throw new UsageError(`--from and --to are the same PBX: ${client.target.host}`);
|
|
402
|
+
const [a, b] = [await readSerial(source), await readSerial(client)];
|
|
403
|
+
if (a && b && a === b) throw new UsageError(`--from and --to are the same PBX (serial ${a})`);
|
|
404
|
+
const {portable} = await exportOf(ctx, source, {users: v.users, dbFamily: v['db-family']});
|
|
405
|
+
const bound = new Set(portable.bound.map((x) => `${x.kind}/${x.name}`));
|
|
406
|
+
const all = Object.entries(portable.kinds).flatMap(([kind, list]) => list.map((o) => `${kind}/${o.name}`));
|
|
407
|
+
const carried = selectFrom(portable, new Set([...closure(portable, all)].filter((k) => !bound.has(k))));
|
|
408
|
+
for (const x of portable.bound)
|
|
409
|
+
ctx.info(`not carried, bound to ${source.target.host}: ${x.kind}/${x.name} (${x.found} in ${x.path})`);
|
|
410
|
+
const rerun = `wx-admin config copy --from ${shellArg(v.from)} --to ${shellArg(v.to)}${v.users ? ' --users' : ''}`;
|
|
411
|
+
return finish(
|
|
412
|
+
ctx,
|
|
413
|
+
await runImport(ctx, {portable: carried, from: source.target.host, client, values: v, rerun}),
|
|
414
|
+
);
|
|
415
|
+
},
|
|
416
|
+
text: importText,
|
|
417
|
+
},
|
|
418
|
+
export: {
|
|
419
|
+
summary: 'choose what to carry to another PBX and write it to a new folder, without anything bound to this PBX',
|
|
420
|
+
usage: 'wx-admin config export --to DIR [--all | --only KIND:NAME,…] [--users] [--db-family FAMILY]...',
|
|
421
|
+
details: [
|
|
422
|
+
'In a terminal, a list to tick opens (an object brings what it needs). References between objects are kept by',
|
|
423
|
+
'name; ids, LDAP dns, the serial and passwords of this PBX are left out. An object where the serial or the host',
|
|
424
|
+
`of this PBX is still found is not exported (listed as bound). Kinds: ${Object.keys(TRANSFER_KINDS).join(', ')}.`,
|
|
425
|
+
'Then: wx-admin config import DIR --pbx OTHER.',
|
|
426
|
+
].join('\n'),
|
|
427
|
+
options: {to: {type: 'string', placeholder: 'DIR', description: 'a new or empty folder'}, ...CHOICE_OPTIONS},
|
|
428
|
+
args: [0, 0],
|
|
429
|
+
async run(ctx) {
|
|
430
|
+
const v = ctx.values;
|
|
431
|
+
if (!v.to) throw new UsageError('--to is needed', ctx.usage());
|
|
432
|
+
if (v.all && v.only) throw new UsageError('Give --all or --only, not both', ctx.usage());
|
|
433
|
+
if (!v.all && !v.only && !interactive(ctx))
|
|
434
|
+
throw new UsageError(
|
|
435
|
+
'Without a terminal, choose what to export with --all or --only KIND:NAME,…',
|
|
436
|
+
ctx.usage(),
|
|
437
|
+
);
|
|
438
|
+
const target = path.resolve(ctx.cwd ?? process.cwd(), v.to);
|
|
439
|
+
refuseUnsafeWrite(target, ctx.env);
|
|
440
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length)
|
|
441
|
+
throw new UsageError(`${v.to} is not empty: export into a new or empty folder (nothing was overwritten)`);
|
|
442
|
+
const client = await pbxClient(ctx);
|
|
443
|
+
const usersAsked = v.users || (v.only && /(^|,)\s*users(:|,|$)/.test(v.only));
|
|
444
|
+
const {portable, source} = await exportOf(ctx, client, {users: usersAsked, dbFamily: v['db-family']});
|
|
445
|
+
const keys = await chooseExport(portable, v, {interactive: interactive(ctx), select: (o) => select(ctx, o)});
|
|
446
|
+
const chosen = selectFrom(portable, keys);
|
|
447
|
+
const {dir, counts} = writeExport(ctx, target, {source, portable: chosen});
|
|
448
|
+
return {
|
|
449
|
+
dir,
|
|
450
|
+
pbx: source.host,
|
|
451
|
+
kinds: counts,
|
|
452
|
+
bound: portable.bound,
|
|
453
|
+
external: chosen.external,
|
|
454
|
+
unresolved: chosen.unresolved,
|
|
455
|
+
errors: portable.errors,
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
text(r, ctx) {
|
|
459
|
+
const c = paint(ctx);
|
|
460
|
+
const lines = [
|
|
461
|
+
`Exported from ${r.pbx} to ${r.dir}`,
|
|
462
|
+
...Object.entries(r.kinds).map(([k, n]) => ` ${k.padEnd(18)} ${n}`),
|
|
463
|
+
];
|
|
464
|
+
for (const [k, e] of Object.entries(r.errors ?? {}))
|
|
465
|
+
lines.push(c.yellow(` ${k}: not read (${oneLine(e, 100)})`));
|
|
466
|
+
for (const b of r.bound)
|
|
467
|
+
lines.push(c.red(` not exported, bound to ${r.pbx}: ${b.kind}/${b.name} (${b.found} in ${b.path})`));
|
|
468
|
+
for (const x of r.external)
|
|
469
|
+
lines.push(c.yellow(` outside the PBX, kept as it is: ${x.kind}/${x.name} ${x.path} (${x.what})`));
|
|
470
|
+
for (const x of r.unresolved)
|
|
471
|
+
lines.push(
|
|
472
|
+
c.yellow(` a reference to nothing on ${r.pbx}, kept: ${x.kind}/${x.name} ${x.path} = ${x.value}`),
|
|
473
|
+
);
|
|
474
|
+
lines.push(`Next: wx-admin config import ${r.dir} --pbx OTHER`);
|
|
475
|
+
return lines;
|
|
476
|
+
},
|
|
477
|
+
},
|
|
231
478
|
diff: {
|
|
232
479
|
summary: 'what differs between a backup folder (or another PBX) and the PBX',
|
|
233
480
|
usage: 'wx-admin config diff (<dir> | --with PBX) [--only KIND,KIND…]',
|
package/src/commands/pbx.mjs
CHANGED
package/src/commands/system.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {UsageError} from 'wldx-core/errors';
|
|
|
15
15
|
import {localTime, oneLine, paint, table} from 'wldx-core/format';
|
|
16
16
|
import {apiPath} from 'wldx-core/http';
|
|
17
17
|
import {pbxClient} from '../pbx.mjs';
|
|
18
|
-
import {readSecret} from '
|
|
18
|
+
import {readSecret} from 'wldx-core/secret';
|
|
19
19
|
|
|
20
20
|
// The features PUT /PBX/settings/ takes (one per request).
|
|
21
21
|
export const FEATURES = [
|
package/src/commands/trunk.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {readForUpload, refuseUnsafeWrite} from 'wldx-core/files';
|
|
|
18
18
|
import {oneLine, paint, table} from 'wldx-core/format';
|
|
19
19
|
import {apiPath} from 'wldx-core/http';
|
|
20
20
|
import {pbxClient} from '../pbx.mjs';
|
|
21
|
-
import {readSecret} from '
|
|
21
|
+
import {readSecret} from 'wldx-core/secret';
|
|
22
22
|
import {findRecord, readJsonInput, wholeNumber} from './routing.mjs';
|
|
23
23
|
|
|
24
24
|
const SCOPE = {scope: 'trunks'};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Writing a config import: the chosen objects of a plan, in the order of TRANSFER_KINDS (what others point at
|
|
2
|
+
// first). ACL groups and dialplans point at each other (in cycles too), so the new ones are first made bare and get
|
|
3
|
+
// their content in their turn. Every marker becomes the value of the target (ids of objects made in this run
|
|
4
|
+
// included); an object that fails is `failed`, and everything that needs it `skip`ped with it as `because`.
|
|
5
|
+
import {TRANSFER_KINDS} from './kinds.mjs';
|
|
6
|
+
import {fromPortable, keyOf, MissingValue} from './refs.mjs';
|
|
7
|
+
|
|
8
|
+
const TWO_PASS = ['acl-groups', 'dialplans'];
|
|
9
|
+
|
|
10
|
+
// plan: makePlan(); chosen: Set of keys; secrets: {[key]: {[path]: value}}; selfDn: "pbxid=<serial of the target>,…"
|
|
11
|
+
// (or null when not readable); onEvent(result) after each result. Returns [{key, action, id?, error?, because?}].
|
|
12
|
+
export async function applyPlan(client, plan, chosen, {secrets = {}, selfDn, onEvent = () => {}} = {}) {
|
|
13
|
+
const table = {};
|
|
14
|
+
for (const [key, t] of Object.entries(plan.target)) table[key] = {...t};
|
|
15
|
+
const results = [];
|
|
16
|
+
const done = new Map(); // key → result of this run
|
|
17
|
+
const bare = new Set(); // made bare in pass 1, content not written yet
|
|
18
|
+
const report = (r) => {
|
|
19
|
+
// An object made bare whose content was not written stays on the target empty: said so, to be finished.
|
|
20
|
+
if (bare.has(r.key) && (r.action === 'skip' || r.action === 'failed')) r.bare = true;
|
|
21
|
+
results.push(r);
|
|
22
|
+
done.set(r.key, r);
|
|
23
|
+
onEvent(r);
|
|
24
|
+
};
|
|
25
|
+
const items = plan.items.filter((i) => chosen.has(i.key) && (i.state === 'new' || i.state === 'differs'));
|
|
26
|
+
const order = Object.keys(TRANSFER_KINDS);
|
|
27
|
+
items.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind));
|
|
28
|
+
|
|
29
|
+
const resolveFor = (it) => {
|
|
30
|
+
const resolve = ({kind, name, as}) => {
|
|
31
|
+
const t = table[keyOf(kind, name)];
|
|
32
|
+
if (!t) return undefined;
|
|
33
|
+
if (as === 'id') return t.id;
|
|
34
|
+
if (as === 'dn') return t.dn;
|
|
35
|
+
if (as === 'login') return t.login;
|
|
36
|
+
return name;
|
|
37
|
+
};
|
|
38
|
+
resolve.self = () => {
|
|
39
|
+
if (!selfDn) throw new MissingValue('the serial of the target PBX is not readable');
|
|
40
|
+
return selfDn;
|
|
41
|
+
};
|
|
42
|
+
resolve.secret = (path) => secrets[it.key]?.[path];
|
|
43
|
+
return resolve;
|
|
44
|
+
};
|
|
45
|
+
const failedNeed = (it) => it.needs.find((n) => ['failed', 'skip'].includes(done.get(n)?.action));
|
|
46
|
+
const remember = (it, id) => {
|
|
47
|
+
table[it.key] = {...table[it.key], id: String(id), name: it.name};
|
|
48
|
+
if (it.kind === 'trunks-sip' && typeof it.data.name === 'string') table[it.key].login = it.data.name;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Pass 1: the new ACL groups and dialplans, bare, so that everything can point at them.
|
|
52
|
+
for (const it of items.filter((i) => i.state === 'new' && TWO_PASS.includes(i.kind))) {
|
|
53
|
+
const empty = it.kind === 'acl-groups' ? {name: it.name} : {name: it.name, description: it.data.description ?? ''};
|
|
54
|
+
try {
|
|
55
|
+
remember(it, await TRANSFER_KINDS[it.kind].create(client, empty, it.name));
|
|
56
|
+
bare.add(it.key);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
report({key: it.key, action: 'failed', error: e.message});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// The dns of the ACL groups just made (rules and users point at groups by dn).
|
|
62
|
+
if (items.some((i) => i.kind === 'acl-groups' && i.state === 'new')) {
|
|
63
|
+
for (const g of await TRANSFER_KINDS['acl-groups'].read(client)) {
|
|
64
|
+
const key = keyOf('acl-groups', g.name);
|
|
65
|
+
table[key] = {...table[key], id: g.id, dn: g.dn, name: g.name};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pass 2: every object, whole, in order.
|
|
70
|
+
for (const it of items) {
|
|
71
|
+
if (done.has(it.key)) continue;
|
|
72
|
+
const blocker = failedNeed(it);
|
|
73
|
+
if (blocker) {
|
|
74
|
+
report({key: it.key, action: 'skip', because: blocker});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
data = fromPortable(it.kind, it.data, resolveFor(it));
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (!(e instanceof MissingValue)) throw e;
|
|
82
|
+
const secret = /the secret (\S+) was not given/.exec(e.message);
|
|
83
|
+
const missing = /: (\S+) is not on the target/.exec(e.message);
|
|
84
|
+
report({
|
|
85
|
+
key: it.key,
|
|
86
|
+
action: 'skip',
|
|
87
|
+
because: secret ? `no password: ${secret[1]}` : (missing?.[1] ?? e.message),
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const kind = TRANSFER_KINDS[it.kind];
|
|
92
|
+
const existing = table[it.key]?.id;
|
|
93
|
+
try {
|
|
94
|
+
if (it.state === 'differs' || (TWO_PASS.includes(it.kind) && existing)) {
|
|
95
|
+
await kind.update(client, existing, data);
|
|
96
|
+
remember(it, existing);
|
|
97
|
+
bare.delete(it.key);
|
|
98
|
+
report({key: it.key, action: it.state === 'differs' ? 'overwrite' : 'create', id: existing});
|
|
99
|
+
} else {
|
|
100
|
+
const id = await kind.create(client, data, it.name);
|
|
101
|
+
remember(it, id);
|
|
102
|
+
report({key: it.key, action: 'create', id: String(id)});
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
report({key: it.key, action: 'failed', error: e.message});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The folder of a config export: one JSON Lines file per kind (<kind>.jsonl, one {"name", "data"} per line) and
|
|
2
|
+
// manifest.json (the format, where it comes from, the counts, what the safety scan and the references found). A
|
|
3
|
+
// transport, not something to edit: the serial of the source is never in it.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {UsageError} from 'wldx-core/errors';
|
|
7
|
+
import {readForUpload, refuseUnsafeWrite} from 'wldx-core/files';
|
|
8
|
+
import {TRANSFER_KINDS} from './kinds.mjs';
|
|
9
|
+
|
|
10
|
+
export const FORMAT = 'wx-admin-export/1';
|
|
11
|
+
|
|
12
|
+
export function writeExport(ctx, dir, {source, portable}) {
|
|
13
|
+
const target = path.resolve(ctx.cwd ?? process.cwd(), dir);
|
|
14
|
+
refuseUnsafeWrite(target, ctx.env); // before anything is made
|
|
15
|
+
fs.mkdirSync(target, {recursive: true, mode: 0o700});
|
|
16
|
+
if (fs.readdirSync(target).length)
|
|
17
|
+
throw new UsageError(`${dir} is not empty: export into a new or empty folder (nothing was overwritten)`);
|
|
18
|
+
const counts = {};
|
|
19
|
+
for (const [kind, objects] of Object.entries(portable.kinds)) {
|
|
20
|
+
if (!Object.hasOwn(TRANSFER_KINDS, kind)) throw new UsageError(`Unknown kind: ${kind}`);
|
|
21
|
+
const text = objects.map((o) => JSON.stringify({name: o.name, data: o.data})).join('\n');
|
|
22
|
+
fs.writeFileSync(path.join(target, `${kind}.jsonl`), objects.length ? `${text}\n` : '', {flag: 'wx', mode: 0o600});
|
|
23
|
+
counts[kind] = objects.length;
|
|
24
|
+
}
|
|
25
|
+
const manifest = {
|
|
26
|
+
format: FORMAT,
|
|
27
|
+
source: {host: source.host, wms: source.wms, exportedAt: new Date(ctx.clock?.() ?? Date.now()).toISOString()},
|
|
28
|
+
kinds: counts,
|
|
29
|
+
errors: portable.errors ?? {},
|
|
30
|
+
bound: portable.bound ?? [],
|
|
31
|
+
external: portable.external ?? [],
|
|
32
|
+
unresolved: portable.unresolved ?? [],
|
|
33
|
+
outside: portable.outside ?? [],
|
|
34
|
+
needs: portable.needs ?? {},
|
|
35
|
+
soft: portable.soft ?? {},
|
|
36
|
+
};
|
|
37
|
+
fs.writeFileSync(path.join(target, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
38
|
+
flag: 'wx',
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
return {dir: target, counts};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The folder read back: {manifest, kinds: {[kind]: [{name, data}]}}. Each file goes through the checks of anything
|
|
45
|
+
// read to be sent (no credentials, nothing hidden in the home folder); a line that is not JSON is named by its number,
|
|
46
|
+
// never by its content.
|
|
47
|
+
export async function readExport(ctx, dir) {
|
|
48
|
+
const base = path.resolve(ctx.cwd ?? process.cwd(), dir);
|
|
49
|
+
let manifest;
|
|
50
|
+
try {
|
|
51
|
+
manifest = JSON.parse(readForUpload(path.join(base, 'manifest.json'), ctx.env).toString('utf8'));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e instanceof UsageError && !/File not found/.test(e.message)) throw e;
|
|
54
|
+
manifest = undefined;
|
|
55
|
+
}
|
|
56
|
+
if (manifest?.format !== FORMAT) throw new UsageError(`${dir} is not a folder of wx-admin config export (${FORMAT})`);
|
|
57
|
+
const kinds = {};
|
|
58
|
+
for (const kind of Object.keys(manifest.kinds ?? {})) {
|
|
59
|
+
if (!Object.hasOwn(TRANSFER_KINDS, kind)) throw new UsageError(`Unknown kind in ${dir}: ${kind}`);
|
|
60
|
+
const file = `${kind}.jsonl`;
|
|
61
|
+
const lines = readForUpload(path.join(base, file), ctx.env).toString('utf8').split('\n');
|
|
62
|
+
kinds[kind] = [];
|
|
63
|
+
for (const [i, line] of lines.entries()) {
|
|
64
|
+
if (!line.trim()) continue;
|
|
65
|
+
let o;
|
|
66
|
+
try {
|
|
67
|
+
o = JSON.parse(line);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new UsageError(`${file}:${i + 1} is not JSON`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof o?.name !== 'string' || !o.data || typeof o.data !== 'object')
|
|
72
|
+
throw new UsageError(`${file}:${i + 1} is not an object of the export ({"name", "data"})`);
|
|
73
|
+
kinds[kind].push({name: o.name, data: o.data});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {manifest, kinds};
|
|
77
|
+
}
|