wldx-admin-cli 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/wx-admin.mjs +5 -0
- package/package.json +36 -3
- package/src/cli.mjs +71 -0
- package/src/commands/acl.mjs +376 -0
- package/src/commands/api.mjs +66 -0
- package/src/commands/config.mjs +527 -0
- package/src/commands/device.mjs +275 -0
- package/src/commands/dialplan.mjs +488 -0
- package/src/commands/index.mjs +85 -0
- package/src/commands/pbx.mjs +197 -0
- package/src/commands/phonebook.mjs +558 -0
- package/src/commands/queue.mjs +456 -0
- package/src/commands/records.mjs +623 -0
- package/src/commands/routing.mjs +1059 -0
- package/src/commands/sound.mjs +232 -0
- package/src/commands/system.mjs +668 -0
- package/src/commands/trunk.mjs +808 -0
- package/src/commands/user.mjs +574 -0
- package/src/match.mjs +15 -0
- package/src/pbx.mjs +369 -0
- 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/version.mjs +3 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// wx-admin config: the configuration of a PBX as files (one JSON per object), and the differences between a folder
|
|
2
|
+
// and a PBX or between two PBX. Configuration as code: back up, keep in git, compare; nothing is written to a PBX.
|
|
3
|
+
//
|
|
4
|
+
// What is kept: dialplans (rules, included dialplans by name), the general dialplan settings, timetables, IVR menus,
|
|
5
|
+
// switches, paging groups, queues (call groups), trunks (SIP, PSTN, FXO; passwords masked), trunk groups and ACL
|
|
6
|
+
// groups. What is left out as it differs between PBX or changes by itself: ids, ACL dns, trunk registration status.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {UsageError} from 'wldx-core/errors';
|
|
10
|
+
import {refuseUnsafeWrite} from 'wldx-core/files';
|
|
11
|
+
import {oneLine, paint} from 'wldx-core/format';
|
|
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';
|
|
20
|
+
import {maskSecrets} from './trunk.mjs';
|
|
21
|
+
|
|
22
|
+
const listOf = (r) => (Array.isArray(r) ? r : (r?.records ?? []));
|
|
23
|
+
|
|
24
|
+
// The kinds of objects: where to read them, their scope, and the name an object is known by.
|
|
25
|
+
export const KINDS = {
|
|
26
|
+
dialplans: {
|
|
27
|
+
path: '/PBX/Dialplans/',
|
|
28
|
+
scope: 'dialplan',
|
|
29
|
+
name: (r) => r.name,
|
|
30
|
+
detail: (r) => `/PBX/Dialplans/${Number(r.id)}/`,
|
|
31
|
+
},
|
|
32
|
+
'dialplan-settings': {path: '/Dialplan/GeneralSettings/', scope: 'dialplan', single: true},
|
|
33
|
+
timetables: {path: '/Dialplan/timeTables/', scope: 'dialplan', name: (r) => r.title},
|
|
34
|
+
ivr: {path: '/Dialplan/Ivr/', scope: 'dialplan', name: (r) => r.title},
|
|
35
|
+
switches: {path: '/Dialplan/Switches/', scope: 'dialplan', name: (r) => r.title},
|
|
36
|
+
paging: {path: '/Dialplan/PagingGroups/', scope: 'dialplan', name: (r) => r.title},
|
|
37
|
+
queues: {path: '/Dialplan/CallGroups/', scope: 'callgroups', name: (r) => r.title},
|
|
38
|
+
'trunks-sip': {path: '/Trunks/Sip/', scope: 'trunks', name: (r) => r.title ?? r.name, mask: true},
|
|
39
|
+
'trunks-pstn': {path: '/Trunks/Pstn/', scope: 'trunks', name: (r) => r.title ?? r.name, mask: true},
|
|
40
|
+
'trunks-fxo': {path: '/Trunks/Fxo/', scope: 'trunks', name: (r) => r.title ?? r.username, mask: true},
|
|
41
|
+
'trunk-groups': {path: '/Trunks/Groups/', scope: 'trunks', name: (r) => r.name ?? r.title},
|
|
42
|
+
'acl-groups': {path: '/PBX/AclGroups/', scope: 'aclgroups', name: (r) => r.name},
|
|
43
|
+
};
|
|
44
|
+
const VOLATILE = new Set(['id', 'dn', 'status']);
|
|
45
|
+
|
|
46
|
+
function kindsOf(only) {
|
|
47
|
+
if (!only) return Object.keys(KINDS);
|
|
48
|
+
const list = only
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((s) => s.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
const bad = list.filter((k) => !Object.hasOwn(KINDS, k));
|
|
53
|
+
if (bad.length) throw new UsageError(`Unknown kinds: ${bad.join(', ')}. Known: ${Object.keys(KINDS).join(', ')}`);
|
|
54
|
+
return list;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const strip = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !VOLATILE.has(k)));
|
|
58
|
+
|
|
59
|
+
// The configuration of the PBX: {kind: {name: object}} (a single object under the name "settings").
|
|
60
|
+
export async function snapshot(client, kinds, info = () => {}) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const kind of kinds) {
|
|
63
|
+
const k = KINDS[kind];
|
|
64
|
+
let answer;
|
|
65
|
+
try {
|
|
66
|
+
answer = await client.call('GET', k.path, undefined, {scope: k.scope});
|
|
67
|
+
} catch (e) {
|
|
68
|
+
info(`${kind}: not read (${oneLine(e.message, 140)})`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (k.single) {
|
|
72
|
+
out[kind] = {settings: answer};
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const records = listOf(answer);
|
|
76
|
+
const objects = {};
|
|
77
|
+
let dialplanNames;
|
|
78
|
+
if (kind === 'dialplans') dialplanNames = new Map(records.map((r) => [r.id, r.name]));
|
|
79
|
+
for (const r of records) {
|
|
80
|
+
let full = k.detail ? await client.call('GET', k.detail(r), undefined, {scope: k.scope}) : r;
|
|
81
|
+
if (kind === 'dialplans') {
|
|
82
|
+
// included dialplans by name (ids differ between PBX); negative ids are built-in and the same everywhere
|
|
83
|
+
full = {
|
|
84
|
+
...full,
|
|
85
|
+
includedDialplans: (full.includedDialplans ?? []).map((x) =>
|
|
86
|
+
x.dialplanId > 0 && dialplanNames.has(x.dialplanId) ? {dialplan: dialplanNames.get(x.dialplanId)} : x,
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (k.mask) full = maskSecrets(full);
|
|
91
|
+
let name = String(k.name(r) ?? r.id);
|
|
92
|
+
while (Object.hasOwn(objects, name)) name += '~';
|
|
93
|
+
objects[name] = strip(full);
|
|
94
|
+
}
|
|
95
|
+
out[kind] = objects;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fileName = (name) =>
|
|
101
|
+
`${
|
|
102
|
+
String(name)
|
|
103
|
+
.replace(/[^A-Za-z0-9_.-]/g, '_')
|
|
104
|
+
.replace(/^\.+/, '_')
|
|
105
|
+
.slice(0, 120) || '_'
|
|
106
|
+
}.json`;
|
|
107
|
+
|
|
108
|
+
// Each object as it is in its own file; manifest.json maps the files to the names the objects are known by.
|
|
109
|
+
function writeBackup(dir, data, manifest) {
|
|
110
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
111
|
+
if (fs.readdirSync(dir).length)
|
|
112
|
+
throw new UsageError(`${dir} is not empty: back up into a new or empty folder (nothing was overwritten)`);
|
|
113
|
+
const index = {};
|
|
114
|
+
for (const [kind, objects] of Object.entries(data)) {
|
|
115
|
+
const sub = path.join(dir, kind);
|
|
116
|
+
fs.mkdirSync(sub);
|
|
117
|
+
index[kind] = {};
|
|
118
|
+
for (const [name, obj] of Object.entries(objects)) {
|
|
119
|
+
let file = fileName(name);
|
|
120
|
+
while (Object.hasOwn(index[kind], file)) file = file.replace(/\.json$/, '_.json');
|
|
121
|
+
index[kind][file] = name;
|
|
122
|
+
fs.writeFileSync(path.join(sub, file), `${JSON.stringify(obj, null, 2)}\n`, {flag: 'wx'});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify({...manifest, objects: index}, null, 2)}\n`, {
|
|
126
|
+
flag: 'wx',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A backup folder read back: {kind: {name: object}}.
|
|
131
|
+
export function readBackup(dir, kinds) {
|
|
132
|
+
let manifest;
|
|
133
|
+
try {
|
|
134
|
+
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
|
135
|
+
} catch {
|
|
136
|
+
throw new UsageError(`${dir} is not a backup of wx-admin config backup (no readable manifest.json)`);
|
|
137
|
+
}
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const kind of kinds) {
|
|
140
|
+
const sub = path.join(dir, kind);
|
|
141
|
+
if (!fs.existsSync(sub)) continue;
|
|
142
|
+
const names = manifest.objects?.[kind] ?? {};
|
|
143
|
+
const objects = {};
|
|
144
|
+
for (const f of fs
|
|
145
|
+
.readdirSync(sub)
|
|
146
|
+
.filter((n) => n.endsWith('.json'))
|
|
147
|
+
.sort()) {
|
|
148
|
+
objects[Object.hasOwn(names, f) ? names[f] : f.slice(0, -5)] = JSON.parse(
|
|
149
|
+
fs.readFileSync(path.join(sub, f), 'utf8'),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
out[kind] = objects;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The paths where two JSON values differ: [{path, from, to}] (at most `max`).
|
|
158
|
+
export function differences(a, b, at = '', out = [], max = 50) {
|
|
159
|
+
if (out.length >= max) return out;
|
|
160
|
+
const isObj = (v) => v && typeof v === 'object';
|
|
161
|
+
if (isObj(a) && isObj(b) && Array.isArray(a) === Array.isArray(b)) {
|
|
162
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)]))
|
|
163
|
+
differences(a[k], b[k], Array.isArray(a) ? `${at}[${k}]` : at ? `${at}.${k}` : k, out, max);
|
|
164
|
+
} else if (JSON.stringify(a) !== JSON.stringify(b)) out.push({path: at || '.', from: a, to: b});
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Only the kinds both sides have are compared (a kind one side could not read is `skipped`, not "all added").
|
|
169
|
+
export function compare(left, right) {
|
|
170
|
+
const added = [];
|
|
171
|
+
const removed = [];
|
|
172
|
+
const changed = [];
|
|
173
|
+
const skipped = [...new Set([...Object.keys(left), ...Object.keys(right)])].filter(
|
|
174
|
+
(k) => !Object.hasOwn(left, k) || !Object.hasOwn(right, k),
|
|
175
|
+
);
|
|
176
|
+
const compared = Object.keys(left).filter((k) => Object.hasOwn(right, k));
|
|
177
|
+
for (const kind of compared) {
|
|
178
|
+
const l = left[kind] ?? {};
|
|
179
|
+
const r = right[kind] ?? {};
|
|
180
|
+
for (const name of Object.keys(r)) if (!Object.hasOwn(l, name)) added.push({kind, name});
|
|
181
|
+
for (const name of Object.keys(l)) if (!Object.hasOwn(r, name)) removed.push({kind, name});
|
|
182
|
+
for (const name of Object.keys(l)) {
|
|
183
|
+
if (!Object.hasOwn(r, name)) continue;
|
|
184
|
+
const paths = differences(l[name], r[name]);
|
|
185
|
+
if (paths.length) changed.push({kind, name, paths});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
same: compared.length > 0 && !added.length && !removed.length && !changed.length,
|
|
190
|
+
compared,
|
|
191
|
+
added,
|
|
192
|
+
removed,
|
|
193
|
+
changed,
|
|
194
|
+
skipped,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const short = (v) => (v === undefined ? '(none)' : oneLine(JSON.stringify(v), 60));
|
|
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
|
+
|
|
309
|
+
export const groups = {
|
|
310
|
+
config: {
|
|
311
|
+
summary: 'the configuration as files: back up a PBX to a folder, compare a folder or another PBX with a PBX',
|
|
312
|
+
commands: {
|
|
313
|
+
backup: {
|
|
314
|
+
summary: 'write the configuration of the PBX to a new folder, one JSON file per object (passwords masked)',
|
|
315
|
+
usage: 'wx-admin config backup --to DIR [--only KIND,KIND…]',
|
|
316
|
+
details: `Kinds: ${Object.keys(KINDS).join(', ')}. A kind the access may not read is skipped with a note.`,
|
|
317
|
+
options: {
|
|
318
|
+
to: {type: 'string', placeholder: 'DIR', description: 'a new or empty folder'},
|
|
319
|
+
only: {type: 'string', placeholder: 'KINDS', description: 'only these kinds (comma-separated)'},
|
|
320
|
+
},
|
|
321
|
+
args: [0, 0],
|
|
322
|
+
async run(ctx) {
|
|
323
|
+
if (!ctx.values.to) throw new UsageError('--to is needed', ctx.usage());
|
|
324
|
+
const dir = path.resolve(ctx.cwd ?? process.cwd(), ctx.values.to);
|
|
325
|
+
refuseUnsafeWrite(path.join(dir, 'manifest.json'), ctx.env);
|
|
326
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length)
|
|
327
|
+
throw new UsageError(
|
|
328
|
+
`${ctx.values.to} is not empty: back up into a new or empty folder (nothing was overwritten)`,
|
|
329
|
+
);
|
|
330
|
+
const kinds = kindsOf(ctx.values.only);
|
|
331
|
+
const client = await pbxClient(ctx);
|
|
332
|
+
const data = await snapshot(client, kinds, ctx.info);
|
|
333
|
+
const counts = Object.fromEntries(Object.entries(data).map(([k, v]) => [k, Object.keys(v).length]));
|
|
334
|
+
writeBackup(dir, data, {
|
|
335
|
+
pbx: client.target.host,
|
|
336
|
+
at: new Date(ctx.clock()).toISOString(),
|
|
337
|
+
tool: 'wx-admin config backup',
|
|
338
|
+
kinds: counts,
|
|
339
|
+
});
|
|
340
|
+
return {dir, pbx: client.target.host, kinds: counts};
|
|
341
|
+
},
|
|
342
|
+
text: (r) => [
|
|
343
|
+
`Backed up ${r.pbx} to ${r.dir}`,
|
|
344
|
+
...Object.entries(r.kinds).map(([k, n]) => ` ${k.padEnd(18)} ${n}`),
|
|
345
|
+
],
|
|
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
|
+
},
|
|
478
|
+
diff: {
|
|
479
|
+
summary: 'what differs between a backup folder (or another PBX) and the PBX',
|
|
480
|
+
usage: 'wx-admin config diff (<dir> | --with PBX) [--only KIND,KIND…]',
|
|
481
|
+
details: [
|
|
482
|
+
'+ is only on the PBX, - only in the folder (or the other PBX), ~ differs (the paths that do).',
|
|
483
|
+
'With --all-pbx, every PBX is compared with the same folder.',
|
|
484
|
+
].join('\n'),
|
|
485
|
+
options: {
|
|
486
|
+
with: {
|
|
487
|
+
type: 'string',
|
|
488
|
+
placeholder: 'PBX',
|
|
489
|
+
description: 'compare with another PBX instead of a folder',
|
|
490
|
+
complete: 'pbxs',
|
|
491
|
+
},
|
|
492
|
+
only: {type: 'string', placeholder: 'KINDS', description: 'only these kinds (comma-separated)'},
|
|
493
|
+
},
|
|
494
|
+
args: [0, 1],
|
|
495
|
+
readOnly: true,
|
|
496
|
+
async run(ctx) {
|
|
497
|
+
if (!ctx.positionals[0] === !ctx.values.with)
|
|
498
|
+
throw new UsageError('Give a backup folder or --with PBX', ctx.usage());
|
|
499
|
+
const kinds = kindsOf(ctx.values.only);
|
|
500
|
+
const client = await pbxClient(ctx);
|
|
501
|
+
const left = ctx.values.with
|
|
502
|
+
? await snapshot(await pbxClient({...ctx, pbx: ctx.values.with}), kinds, ctx.info)
|
|
503
|
+
: readBackup(path.resolve(ctx.cwd ?? process.cwd(), ctx.positionals[0]), kinds);
|
|
504
|
+
const right = await snapshot(client, kinds, ctx.info);
|
|
505
|
+
return {pbx: client.target.host, against: ctx.values.with ?? ctx.positionals[0], ...compare(left, right)};
|
|
506
|
+
},
|
|
507
|
+
text(r, ctx) {
|
|
508
|
+
const c = paint(ctx);
|
|
509
|
+
const skipped = r.skipped?.length
|
|
510
|
+
? [c.dim(`not compared (not read on one side): ${r.skipped.join(', ')}`)]
|
|
511
|
+
: [];
|
|
512
|
+
if (!r.compared.length) return [`${r.pbx}: nothing to compare with ${r.against}`, ...skipped];
|
|
513
|
+
if (r.same) return [`${r.pbx}: no differences with ${r.against}`, ...skipped];
|
|
514
|
+
const lines = [`${r.pbx} compared with ${r.against}:`, ...skipped];
|
|
515
|
+
for (const x of r.added) lines.push(c.green(`+ ${x.kind}/${x.name}`));
|
|
516
|
+
for (const x of r.removed) lines.push(c.red(`- ${x.kind}/${x.name}`));
|
|
517
|
+
for (const x of r.changed) {
|
|
518
|
+
lines.push(c.yellow(`~ ${x.kind}/${x.name}`));
|
|
519
|
+
for (const p of x.paths.slice(0, 10)) lines.push(` ${p.path}: ${short(p.from)} → ${short(p.to)}`);
|
|
520
|
+
if (x.paths.length > 10) lines.push(c.dim(` … ${x.paths.length - 10} more`));
|
|
521
|
+
}
|
|
522
|
+
return lines;
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
};
|