wldx-admin-cli 0.0.0-stage → 1.1.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,280 @@
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 {pbxClient} from '../pbx.mjs';
13
+ import {maskSecrets} from './trunk.mjs';
14
+
15
+ const listOf = (r) => (Array.isArray(r) ? r : (r?.records ?? []));
16
+
17
+ // The kinds of objects: where to read them, their scope, and the name an object is known by.
18
+ export const KINDS = {
19
+ dialplans: {
20
+ path: '/PBX/Dialplans/',
21
+ scope: 'dialplan',
22
+ name: (r) => r.name,
23
+ detail: (r) => `/PBX/Dialplans/${Number(r.id)}/`,
24
+ },
25
+ 'dialplan-settings': {path: '/Dialplan/GeneralSettings/', scope: 'dialplan', single: true},
26
+ timetables: {path: '/Dialplan/timeTables/', scope: 'dialplan', name: (r) => r.title},
27
+ ivr: {path: '/Dialplan/Ivr/', scope: 'dialplan', name: (r) => r.title},
28
+ switches: {path: '/Dialplan/Switches/', scope: 'dialplan', name: (r) => r.title},
29
+ paging: {path: '/Dialplan/PagingGroups/', scope: 'dialplan', name: (r) => r.title},
30
+ queues: {path: '/Dialplan/CallGroups/', scope: 'callgroups', name: (r) => r.title},
31
+ 'trunks-sip': {path: '/Trunks/Sip/', scope: 'trunks', name: (r) => r.title ?? r.name, mask: true},
32
+ 'trunks-pstn': {path: '/Trunks/Pstn/', scope: 'trunks', name: (r) => r.title ?? r.name, mask: true},
33
+ 'trunks-fxo': {path: '/Trunks/Fxo/', scope: 'trunks', name: (r) => r.title ?? r.username, mask: true},
34
+ 'trunk-groups': {path: '/Trunks/Groups/', scope: 'trunks', name: (r) => r.name ?? r.title},
35
+ 'acl-groups': {path: '/PBX/AclGroups/', scope: 'aclgroups', name: (r) => r.name},
36
+ };
37
+ const VOLATILE = new Set(['id', 'dn', 'status']);
38
+
39
+ function kindsOf(only) {
40
+ if (!only) return Object.keys(KINDS);
41
+ const list = only
42
+ .split(',')
43
+ .map((s) => s.trim())
44
+ .filter(Boolean);
45
+ const bad = list.filter((k) => !Object.hasOwn(KINDS, k));
46
+ if (bad.length) throw new UsageError(`Unknown kinds: ${bad.join(', ')}. Known: ${Object.keys(KINDS).join(', ')}`);
47
+ return list;
48
+ }
49
+
50
+ const strip = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !VOLATILE.has(k)));
51
+
52
+ // The configuration of the PBX: {kind: {name: object}} (a single object under the name "settings").
53
+ export async function snapshot(client, kinds, info = () => {}) {
54
+ const out = {};
55
+ for (const kind of kinds) {
56
+ const k = KINDS[kind];
57
+ let answer;
58
+ try {
59
+ answer = await client.call('GET', k.path, undefined, {scope: k.scope});
60
+ } catch (e) {
61
+ info(`${kind}: not read (${oneLine(e.message, 140)})`);
62
+ continue;
63
+ }
64
+ if (k.single) {
65
+ out[kind] = {settings: answer};
66
+ continue;
67
+ }
68
+ const records = listOf(answer);
69
+ const objects = {};
70
+ let dialplanNames;
71
+ if (kind === 'dialplans') dialplanNames = new Map(records.map((r) => [r.id, r.name]));
72
+ for (const r of records) {
73
+ let full = k.detail ? await client.call('GET', k.detail(r), undefined, {scope: k.scope}) : r;
74
+ if (kind === 'dialplans') {
75
+ // included dialplans by name (ids differ between PBX); negative ids are built-in and the same everywhere
76
+ full = {
77
+ ...full,
78
+ includedDialplans: (full.includedDialplans ?? []).map((x) =>
79
+ x.dialplanId > 0 && dialplanNames.has(x.dialplanId) ? {dialplan: dialplanNames.get(x.dialplanId)} : x,
80
+ ),
81
+ };
82
+ }
83
+ if (k.mask) full = maskSecrets(full);
84
+ let name = String(k.name(r) ?? r.id);
85
+ while (Object.hasOwn(objects, name)) name += '~';
86
+ objects[name] = strip(full);
87
+ }
88
+ out[kind] = objects;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ const fileName = (name) =>
94
+ `${
95
+ String(name)
96
+ .replace(/[^A-Za-z0-9_.-]/g, '_')
97
+ .replace(/^\.+/, '_')
98
+ .slice(0, 120) || '_'
99
+ }.json`;
100
+
101
+ // Each object as it is in its own file; manifest.json maps the files to the names the objects are known by.
102
+ function writeBackup(dir, data, manifest) {
103
+ fs.mkdirSync(dir, {recursive: true});
104
+ if (fs.readdirSync(dir).length)
105
+ throw new UsageError(`${dir} is not empty: back up into a new or empty folder (nothing was overwritten)`);
106
+ const index = {};
107
+ for (const [kind, objects] of Object.entries(data)) {
108
+ const sub = path.join(dir, kind);
109
+ fs.mkdirSync(sub);
110
+ index[kind] = {};
111
+ for (const [name, obj] of Object.entries(objects)) {
112
+ let file = fileName(name);
113
+ while (Object.hasOwn(index[kind], file)) file = file.replace(/\.json$/, '_.json');
114
+ index[kind][file] = name;
115
+ fs.writeFileSync(path.join(sub, file), `${JSON.stringify(obj, null, 2)}\n`, {flag: 'wx'});
116
+ }
117
+ }
118
+ fs.writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify({...manifest, objects: index}, null, 2)}\n`, {
119
+ flag: 'wx',
120
+ });
121
+ }
122
+
123
+ // A backup folder read back: {kind: {name: object}}.
124
+ export function readBackup(dir, kinds) {
125
+ let manifest;
126
+ try {
127
+ manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
128
+ } catch {
129
+ throw new UsageError(`${dir} is not a backup of wx-admin config backup (no readable manifest.json)`);
130
+ }
131
+ const out = {};
132
+ for (const kind of kinds) {
133
+ const sub = path.join(dir, kind);
134
+ if (!fs.existsSync(sub)) continue;
135
+ const names = manifest.objects?.[kind] ?? {};
136
+ const objects = {};
137
+ for (const f of fs
138
+ .readdirSync(sub)
139
+ .filter((n) => n.endsWith('.json'))
140
+ .sort()) {
141
+ objects[Object.hasOwn(names, f) ? names[f] : f.slice(0, -5)] = JSON.parse(
142
+ fs.readFileSync(path.join(sub, f), 'utf8'),
143
+ );
144
+ }
145
+ out[kind] = objects;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // The paths where two JSON values differ: [{path, from, to}] (at most `max`).
151
+ export function differences(a, b, at = '', out = [], max = 50) {
152
+ if (out.length >= max) return out;
153
+ const isObj = (v) => v && typeof v === 'object';
154
+ if (isObj(a) && isObj(b) && Array.isArray(a) === Array.isArray(b)) {
155
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)]))
156
+ differences(a[k], b[k], Array.isArray(a) ? `${at}[${k}]` : at ? `${at}.${k}` : k, out, max);
157
+ } else if (JSON.stringify(a) !== JSON.stringify(b)) out.push({path: at || '.', from: a, to: b});
158
+ return out;
159
+ }
160
+
161
+ // Only the kinds both sides have are compared (a kind one side could not read is `skipped`, not "all added").
162
+ export function compare(left, right) {
163
+ const added = [];
164
+ const removed = [];
165
+ const changed = [];
166
+ const skipped = [...new Set([...Object.keys(left), ...Object.keys(right)])].filter(
167
+ (k) => !Object.hasOwn(left, k) || !Object.hasOwn(right, k),
168
+ );
169
+ const compared = Object.keys(left).filter((k) => Object.hasOwn(right, k));
170
+ for (const kind of compared) {
171
+ const l = left[kind] ?? {};
172
+ const r = right[kind] ?? {};
173
+ for (const name of Object.keys(r)) if (!Object.hasOwn(l, name)) added.push({kind, name});
174
+ for (const name of Object.keys(l)) if (!Object.hasOwn(r, name)) removed.push({kind, name});
175
+ for (const name of Object.keys(l)) {
176
+ if (!Object.hasOwn(r, name)) continue;
177
+ const paths = differences(l[name], r[name]);
178
+ if (paths.length) changed.push({kind, name, paths});
179
+ }
180
+ }
181
+ return {
182
+ same: compared.length > 0 && !added.length && !removed.length && !changed.length,
183
+ compared,
184
+ added,
185
+ removed,
186
+ changed,
187
+ skipped,
188
+ };
189
+ }
190
+
191
+ const short = (v) => (v === undefined ? '(none)' : oneLine(JSON.stringify(v), 60));
192
+
193
+ export const groups = {
194
+ config: {
195
+ summary: 'the configuration as files: back up a PBX to a folder, compare a folder or another PBX with a PBX',
196
+ commands: {
197
+ backup: {
198
+ summary: 'write the configuration of the PBX to a new folder, one JSON file per object (passwords masked)',
199
+ usage: 'wx-admin config backup --to DIR [--only KIND,KIND…]',
200
+ details: `Kinds: ${Object.keys(KINDS).join(', ')}. A kind the access may not read is skipped with a note.`,
201
+ options: {
202
+ to: {type: 'string', placeholder: 'DIR', description: 'a new or empty folder'},
203
+ only: {type: 'string', placeholder: 'KINDS', description: 'only these kinds (comma-separated)'},
204
+ },
205
+ args: [0, 0],
206
+ async run(ctx) {
207
+ if (!ctx.values.to) throw new UsageError('--to is needed', ctx.usage());
208
+ const dir = path.resolve(ctx.cwd ?? process.cwd(), ctx.values.to);
209
+ refuseUnsafeWrite(path.join(dir, 'manifest.json'), ctx.env);
210
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length)
211
+ throw new UsageError(
212
+ `${ctx.values.to} is not empty: back up into a new or empty folder (nothing was overwritten)`,
213
+ );
214
+ const kinds = kindsOf(ctx.values.only);
215
+ const client = await pbxClient(ctx);
216
+ const data = await snapshot(client, kinds, ctx.info);
217
+ const counts = Object.fromEntries(Object.entries(data).map(([k, v]) => [k, Object.keys(v).length]));
218
+ writeBackup(dir, data, {
219
+ pbx: client.target.host,
220
+ at: new Date(ctx.clock()).toISOString(),
221
+ tool: 'wx-admin config backup',
222
+ kinds: counts,
223
+ });
224
+ return {dir, pbx: client.target.host, kinds: counts};
225
+ },
226
+ text: (r) => [
227
+ `Backed up ${r.pbx} to ${r.dir}`,
228
+ ...Object.entries(r.kinds).map(([k, n]) => ` ${k.padEnd(18)} ${n}`),
229
+ ],
230
+ },
231
+ diff: {
232
+ summary: 'what differs between a backup folder (or another PBX) and the PBX',
233
+ usage: 'wx-admin config diff (<dir> | --with PBX) [--only KIND,KIND…]',
234
+ details: [
235
+ '+ is only on the PBX, - only in the folder (or the other PBX), ~ differs (the paths that do).',
236
+ 'With --all-pbx, every PBX is compared with the same folder.',
237
+ ].join('\n'),
238
+ options: {
239
+ with: {
240
+ type: 'string',
241
+ placeholder: 'PBX',
242
+ description: 'compare with another PBX instead of a folder',
243
+ complete: 'pbxs',
244
+ },
245
+ only: {type: 'string', placeholder: 'KINDS', description: 'only these kinds (comma-separated)'},
246
+ },
247
+ args: [0, 1],
248
+ readOnly: true,
249
+ async run(ctx) {
250
+ if (!ctx.positionals[0] === !ctx.values.with)
251
+ throw new UsageError('Give a backup folder or --with PBX', ctx.usage());
252
+ const kinds = kindsOf(ctx.values.only);
253
+ const client = await pbxClient(ctx);
254
+ const left = ctx.values.with
255
+ ? await snapshot(await pbxClient({...ctx, pbx: ctx.values.with}), kinds, ctx.info)
256
+ : readBackup(path.resolve(ctx.cwd ?? process.cwd(), ctx.positionals[0]), kinds);
257
+ const right = await snapshot(client, kinds, ctx.info);
258
+ return {pbx: client.target.host, against: ctx.values.with ?? ctx.positionals[0], ...compare(left, right)};
259
+ },
260
+ text(r, ctx) {
261
+ const c = paint(ctx);
262
+ const skipped = r.skipped?.length
263
+ ? [c.dim(`not compared (not read on one side): ${r.skipped.join(', ')}`)]
264
+ : [];
265
+ if (!r.compared.length) return [`${r.pbx}: nothing to compare with ${r.against}`, ...skipped];
266
+ if (r.same) return [`${r.pbx}: no differences with ${r.against}`, ...skipped];
267
+ const lines = [`${r.pbx} compared with ${r.against}:`, ...skipped];
268
+ for (const x of r.added) lines.push(c.green(`+ ${x.kind}/${x.name}`));
269
+ for (const x of r.removed) lines.push(c.red(`- ${x.kind}/${x.name}`));
270
+ for (const x of r.changed) {
271
+ lines.push(c.yellow(`~ ${x.kind}/${x.name}`));
272
+ for (const p of x.paths.slice(0, 10)) lines.push(` ${p.path}: ${short(p.from)} → ${short(p.to)}`);
273
+ if (x.paths.length > 10) lines.push(c.dim(` … ${x.paths.length - 10} more`));
274
+ }
275
+ return lines;
276
+ },
277
+ },
278
+ },
279
+ },
280
+ };
@@ -0,0 +1,275 @@
1
+ // wx-admin device: the devices (phones, W-AIR bases, Wizyconf…) of the PBX, over the PBX REST API.
2
+ // Operations (OpenAPI tags Devices, Device), scope devices (the spec names none for PUT, DELETE, connect and
3
+ // disconnect: devices:*):
4
+ // GET /Devices/ the devices (count, start)
5
+ // POST /Devices/ add devices by MAC {macs}
6
+ // PUT /Devices/{mac}/ change {description, user, multipleUsers, settings: {dns, ntp, syslogServer…}}
7
+ // DELETE /Devices/{mac}/ delete
8
+ // POST /Devices/scan/ look for devices in a range of addresses {range}
9
+ // GET /Devices/verify/ check one device: ?address=&port= (the spec says a body with range and port;
10
+ // the PBX reads the query, and "address", not "range")
11
+ // POST|DELETE /Devices/{mac}/connect/ open or close the remote web access to a device
12
+ // POST /Device/ResetToken/ reset the token of a Wizyconf device {pin}
13
+ // A MAC is given with or without separators (9C:75:14:02:8F:24, 9c7514028f24); the PBX takes 12 lower-case hex digits.
14
+ // The token of a device (its password for provisioning) is never shown as text, and in JSON only with --show-secrets.
15
+ import {readJsonInput as readJsonFile} from 'wldx-core/files';
16
+ import {UsageError} from 'wldx-core/errors';
17
+ import {oneLine, paint, table} from 'wldx-core/format';
18
+ import {apiPath, query} from 'wldx-core/http';
19
+ import {pbxClient} from '../pbx.mjs';
20
+
21
+ const SCOPE = {scope: 'devices'};
22
+ const HOST = /^[A-Za-z0-9]([A-Za-z0-9.-]{0,252})$|^[0-9a-fA-F:]+$/;
23
+ const EXTENSION = /^[0-9A-Za-z*#+_-]{1,32}$/;
24
+
25
+ // "9C:75:14:02:8F:24" → "9c7514028f24".
26
+ export function normalizeMac(input) {
27
+ const mac = String(input ?? '')
28
+ .toLowerCase()
29
+ .replace(/[:.-]/g, '');
30
+ if (!/^[0-9a-f]{12}$/.test(mac)) throw new UsageError(`Not a MAC address: ${input}`);
31
+ return mac;
32
+ }
33
+
34
+ const showSecrets = {
35
+ 'show-secrets': {type: 'boolean', description: 'with --output json: keep the device tokens (never shown as text)'},
36
+ };
37
+
38
+ // A device without its token, unless JSON with --show-secrets; tokenSet says whether it has one.
39
+ function hideToken(d, ctx) {
40
+ if (!d || typeof d !== 'object' || (ctx.values['show-secrets'] && ctx.output === 'json')) return d;
41
+ const {token, ...rest} = d;
42
+ return 'token' in d ? {...rest, tokenSet: Boolean(token)} : rest;
43
+ }
44
+
45
+ async function readJsonInput(ctx, from) {
46
+ const data = await readJsonFile(ctx, from); // no credentials, nothing hidden in the home folder
47
+ return data;
48
+ }
49
+
50
+ const count = (v, name) => {
51
+ if (v === undefined) return undefined;
52
+ const n = Number(v);
53
+ if (!Number.isInteger(n) || n < 0) throw new UsageError(`--${name} is a whole number: ${v}`);
54
+ return n;
55
+ };
56
+
57
+ const checkHost = (v, name) => {
58
+ if (v !== '' && !HOST.test(v)) throw new UsageError(`--${name} is a host name or an IP address: ${v}`);
59
+ return v;
60
+ };
61
+
62
+ const users = (d) =>
63
+ [...new Set([d.user, ...(Array.isArray(d.multipleUsers) ? d.multipleUsers : [])].filter(Boolean))].join(',');
64
+
65
+ const deviceRow = (d, c) => [
66
+ d.mac ?? '',
67
+ d.model ?? '',
68
+ users(d) || c.dim('no user'),
69
+ d.isOnline ? c.green('online') : c.dim('offline'),
70
+ d.address ?? '',
71
+ d.firmware ?? '',
72
+ oneLine(d.description ?? '', 40),
73
+ ];
74
+
75
+ export const groups = {
76
+ device: {
77
+ summary: 'devices of the PBX: list, add, change, delete, scan the network, remote access, Wizyconf token',
78
+ commands: {
79
+ list: {
80
+ summary: 'the devices of the PBX: MAC, model, users, online, address, firmware',
81
+ usage: 'wx-admin device list [--count N] [--start N] [--show-secrets]',
82
+ options: {
83
+ count: {type: 'string', placeholder: 'N', description: 'at most this many devices'},
84
+ start: {type: 'string', placeholder: 'N', description: 'skip this many first'},
85
+ ...showSecrets,
86
+ },
87
+ args: [0, 0],
88
+ async run(ctx) {
89
+ const q = query({count: count(ctx.values.count, 'count'), start: count(ctx.values.start, 'start')});
90
+ const r = await (await pbxClient(ctx)).call('GET', `/Devices/${q}`, undefined, SCOPE);
91
+ return {...r, records: (r?.records ?? []).map((d) => hideToken(d, ctx))};
92
+ },
93
+ text: (r, ctx) => (r.records.length ? table(r.records.map((d) => deviceRow(d, paint(ctx)))) : 'No devices'),
94
+ },
95
+ add: {
96
+ summary: 'add devices by MAC address (they get their configuration from the PBX)',
97
+ usage: 'wx-admin device add <mac>...',
98
+ options: {},
99
+ args: [1, 100],
100
+ async run(ctx) {
101
+ const macs = ctx.positionals.map(normalizeMac);
102
+ return (await pbxClient(ctx)).call('POST', '/Devices/', {macs}, SCOPE);
103
+ },
104
+ text: (r) => {
105
+ const recs = r?.records ?? [];
106
+ if (!recs.length) return 'No devices added';
107
+ return ['Added:', ...table(recs.map((x) => [` ${x.mac}`, x.value?.vendor ?? '', x.value?.model ?? '']))];
108
+ },
109
+ },
110
+ update: {
111
+ summary: 'change a device: description, user(s), DNS, NTP, syslog, or anything from a JSON file',
112
+ usage:
113
+ 'wx-admin device update <mac> [--description TEXT] [--user EXT] [--users EXT,EXT] [--dns PRIMARY[,SECONDARY]] [--ntp HOST] [--timezone TZ] [--syslog HOST] [--from FILE|-]',
114
+ details: [
115
+ 'Only what is given is sent. --from takes the fields of the API as JSON, e.g.',
116
+ '{"settings": {"voiceVLAN": {"voice": "on", "data": 10}, "useDst": true}}; the options win over it.',
117
+ ].join('\n'),
118
+ options: {
119
+ description: {type: 'string', placeholder: 'TEXT', description: 'the description of the device'},
120
+ user: {
121
+ type: 'string',
122
+ placeholder: 'EXT',
123
+ description: 'the extension of the user of the device (empty: none)',
124
+ },
125
+ users: {
126
+ type: 'string',
127
+ placeholder: 'EXT,EXT',
128
+ description: 'the extensions of the users of a device with several (W-AIR)',
129
+ },
130
+ dns: {type: 'string', placeholder: 'PRIMARY[,SECONDARY]', description: 'the DNS servers of the device'},
131
+ ntp: {type: 'string', placeholder: 'HOST', description: 'the time server of the device'},
132
+ timezone: {type: 'string', placeholder: 'TZ', description: 'the time zone of the device'},
133
+ syslog: {type: 'string', placeholder: 'HOST', description: 'the log server of the device'},
134
+ from: {type: 'string', placeholder: 'FILE', description: 'fields as JSON (a file, or - for stdin)'},
135
+ },
136
+ args: [1, 1],
137
+ async run(ctx) {
138
+ const v = ctx.values;
139
+ const mac = normalizeMac(ctx.positionals[0]);
140
+ const body = v.from ? await readJsonInput(ctx, v.from) : {};
141
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new UsageError('--from is a JSON object');
142
+ delete body.token; // a token reset is device reset-token, not a field to send by accident
143
+ const settings = {...(body.settings ?? {})};
144
+ if (v.description !== undefined) body.description = v.description;
145
+ if (v.user !== undefined) {
146
+ if (v.user && !EXTENSION.test(v.user)) throw new UsageError(`Not an extension: ${v.user}`);
147
+ body.user = v.user;
148
+ }
149
+ if (v.users !== undefined) {
150
+ const list = v.users
151
+ .split(',')
152
+ .map((s) => s.trim())
153
+ .filter(Boolean);
154
+ for (const e of list) if (!EXTENSION.test(e)) throw new UsageError(`Not an extension: ${e}`);
155
+ body.multipleUsers = list;
156
+ }
157
+ if (v.dns !== undefined) {
158
+ const [primary, secondary] = v.dns.split(',').map((s) => checkHost(s.trim(), 'dns'));
159
+ settings.dns = {...(settings.dns ?? {}), primary, ...(secondary !== undefined ? {secondary} : {})};
160
+ }
161
+ if (v.ntp !== undefined || v.timezone !== undefined) {
162
+ settings.ntp = {
163
+ ...(settings.ntp ?? {}),
164
+ ...(v.ntp !== undefined ? {server: checkHost(v.ntp, 'ntp')} : {}),
165
+ ...(v.timezone !== undefined ? {timezone: v.timezone} : {}),
166
+ };
167
+ }
168
+ if (v.syslog !== undefined) settings.syslogServer = checkHost(v.syslog, 'syslog');
169
+ if (Object.keys(settings).length) body.settings = settings;
170
+ else delete body.settings;
171
+ if (!Object.keys(body).length) throw new UsageError('Nothing to change', ctx.usage());
172
+ const result = await (await pbxClient(ctx)).call('PUT', apiPath`/Devices/${mac}/`, body, SCOPE);
173
+ return {mac, changed: body, result};
174
+ },
175
+ text: (r) => `Updated the device ${r.mac}: ${Object.keys(r.changed).join(', ')}`,
176
+ },
177
+ delete: {
178
+ summary: 'delete a device from the PBX (needs --yes: it stops working with the PBX)',
179
+ usage: 'wx-admin device delete <mac> --yes',
180
+ options: {yes: {type: 'boolean', description: 'yes, delete it'}},
181
+ args: [1, 1],
182
+ async run(ctx) {
183
+ const mac = normalizeMac(ctx.positionals[0]);
184
+ if (!ctx.values.yes)
185
+ throw new UsageError('A deleted device stops working with the PBX: confirm with --yes', ctx.usage());
186
+ const result = await (await pbxClient(ctx)).call('DELETE', apiPath`/Devices/${mac}/`, undefined, SCOPE);
187
+ return {deleted: mac, result};
188
+ },
189
+ text: (r) => `Deleted the device ${r.deleted}`,
190
+ },
191
+ scan: {
192
+ summary: 'look for devices in a range of addresses of the network of the PBX',
193
+ usage: 'wx-admin device scan <range>',
194
+ details:
195
+ 'A range as WMS takes it: 192.168.1.1-254, or one address. Then: wx-admin device verify <address> --port N.',
196
+ options: {},
197
+ args: [1, 1],
198
+ async run(ctx) {
199
+ const range = ctx.positionals[0];
200
+ if (!/^[0-9a-fA-F.:/-]{3,64}$/.test(range)) throw new UsageError(`Not a range of addresses: ${range}`);
201
+ return (await pbxClient(ctx)).call('POST', '/Devices/scan/', {range}, SCOPE);
202
+ },
203
+ text: (r) =>
204
+ r?.records?.length ? r.records.map((d) => `${d.address}${d.port ? `:${d.port}` : ''}`) : 'No devices found',
205
+ },
206
+ verify: {
207
+ summary: 'check a device at an address: its MAC, model, and whether the PBX may provision it',
208
+ usage: 'wx-admin device verify <address> [--port N]',
209
+ options: {port: {type: 'string', placeholder: 'N', description: 'the port of its web interface (default 80)'}},
210
+ args: [1, 1],
211
+ async run(ctx) {
212
+ const address = checkHost(ctx.positionals[0], 'address');
213
+ const port = Number(ctx.values.port ?? 80);
214
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
215
+ throw new UsageError(`--port is a port (1-65535): ${ctx.values.port}`);
216
+ return (await pbxClient(ctx)).call('GET', `/Devices/verify/${query({address, port})}`, undefined, SCOPE);
217
+ },
218
+ text: (r) =>
219
+ `${r.address}:${r.port} ${r.mac || 'no MAC'} ${r.model || 'unknown model'} ${r.allow ? 'can be provisioned' : 'cannot be provisioned'}`,
220
+ },
221
+ connect: {
222
+ summary: 'open the remote access to the web interface of a device, through the PBX',
223
+ usage: 'wx-admin device connect <mac>',
224
+ options: {},
225
+ args: [1, 1],
226
+ async run(ctx) {
227
+ const mac = normalizeMac(ctx.positionals[0]);
228
+ return {
229
+ mac,
230
+ ...((await (await pbxClient(ctx)).call('POST', apiPath`/Devices/${mac}/connect/`, undefined, SCOPE)) ?? {}),
231
+ };
232
+ },
233
+ text: (r) =>
234
+ `Connected to ${r.mac}${r.host ? ` (${r.host}${r.port ? `:${r.port}` : ''}${r.ssl ? ', TLS' : ''})` : ''}${r.opt ? `: ${r.opt}` : ''} — close it with wx-admin device disconnect ${r.mac} --yes`,
235
+ },
236
+ disconnect: {
237
+ summary: 'close the remote access to a device (needs --yes: whoever uses it loses it)',
238
+ usage: 'wx-admin device disconnect <mac> --yes',
239
+ options: {yes: {type: 'boolean', description: 'yes, close it'}},
240
+ args: [1, 1],
241
+ async run(ctx) {
242
+ const mac = normalizeMac(ctx.positionals[0]);
243
+ if (!ctx.values.yes)
244
+ throw new UsageError('Closing the remote access cuts whoever uses it: confirm with --yes', ctx.usage());
245
+ const result = await (await pbxClient(ctx)).call(
246
+ 'DELETE',
247
+ apiPath`/Devices/${mac}/connect/`,
248
+ undefined,
249
+ SCOPE,
250
+ );
251
+ return {disconnected: mac, result};
252
+ },
253
+ text: (r) => `Closed the remote access to ${r.disconnected}`,
254
+ },
255
+ 'reset-token': {
256
+ summary: 'reset the token of a Wizyconf device, by the PIN it shows, to provision it again (needs --yes)',
257
+ usage: 'wx-admin device reset-token <pin> --yes',
258
+ options: {yes: {type: 'boolean', description: 'yes, reset it'}},
259
+ args: [1, 1],
260
+ async run(ctx) {
261
+ const pin = ctx.positionals[0];
262
+ if (!/^\d{4,12}$/.test(pin)) throw new UsageError('The PIN is the digits the Wizyconf device shows');
263
+ if (!ctx.values.yes)
264
+ throw new UsageError(
265
+ 'The device must be provisioned again after its token is reset: confirm with --yes',
266
+ ctx.usage(),
267
+ );
268
+ const result = await (await pbxClient(ctx)).call('POST', '/Device/ResetToken/', {pin}, SCOPE);
269
+ return {reset: true, result};
270
+ },
271
+ text: () => 'The token of the device was reset: provision it again',
272
+ },
273
+ },
274
+ },
275
+ };