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,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
|
+
};
|