wldx-admin-cli 1.2.1 → 1.3.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/src/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import {AuthError, UsageError} from 'wldx-core/errors';
6
6
  import {clean, paint} from 'wldx-core/format';
7
7
  import {program, setProgram} from 'wldx-core/program';
8
8
  import {COMMANDS, GLOBAL_OPTIONS, GROUPS} from './commands/index.mjs';
9
+ import {SERVICES} from './cloud/env.mjs';
9
10
  import {loginPbxOf, savedPbxs} from './pbx.mjs';
10
11
  import {VERSION} from './version.mjs';
11
12
 
@@ -57,11 +58,13 @@ export const SPEC = {
57
58
  ' --pbx PBX the PBX: name.wildixin.com, name, an IP, or the serial of a known PBX; else the',
58
59
  ' default one (wx-admin pbx use), else the PBX of the x-bees login',
59
60
  " --all-pbx run a command that reads on every PBX: saved access, then the x-bees login's",
61
+ " --env ENV the environment of the cloud services: prod, stable or stage (default: the PBX's)",
60
62
  ],
61
63
  exitCodes: 'Exit codes: 0 done, 1 error, 2 not logged in or the access was refused, 64 wrong usage.',
62
64
  },
63
65
  // The PBX with saved access, by host and by serial.
64
66
  completers: {
67
+ cloudServices: () => Object.entries(SERVICES).map(([name, s]) => [name, s.label]),
65
68
  pbxs: (env) => savedPbxs(env).flatMap((p) => [[p.host, p.auth], ...(p.serial ? [[p.serial, p.host]] : [])]),
66
69
  },
67
70
  around: (cmd, ctx, io, run) => (ctx.allPbx ? runOnAllPbx(cmd, ctx, io) : run()),
@@ -0,0 +1,75 @@
1
+ // Requests to a Wildix cloud service with the CPA token of the PBX: only to the base URL of that service (a path,
2
+ // never a URL, is accepted), redirects refused, the token cut out of every error. raw() is for presigned links
3
+ // (uploads, downloads): it never sends the token.
4
+ import {AuthError, UsageError} from 'wldx-core/errors';
5
+ import {query} from 'wldx-core/http';
6
+ import {userAgent} from 'wldx-core/program';
7
+ import {baseOf, envOf, SERVICES} from './env.mjs';
8
+ import {cloudToken} from './token.mjs';
9
+
10
+ const PATH = /^\/(?!\/)[^\s#\\]*$/;
11
+
12
+ export async function cloudClient(ctx, service) {
13
+ if (!Object.hasOwn(SERVICES, service)) baseOf(service, 'prod'); // the error of an unknown service
14
+ if (ctx.cloudEnv) baseOf(service, ctx.cloudEnv); // an environment the service lacks: refused before any request
15
+ const first = await cloudToken(ctx);
16
+ const env = ctx.cloudEnv ?? envOf(first.portal);
17
+ const base = baseOf(service, env);
18
+ const fetchImpl = ctx.fetch ?? globalThis.fetch;
19
+ const seen = new Set([first.token]); // every token this client used: none may show in an error
20
+ const scrub = (s) => [...seen].reduce((out, token) => out.replaceAll(token, '<token>'), String(s ?? ''));
21
+
22
+ async function call(method, path, {query: params, json} = {}) {
23
+ if (typeof path !== 'string' || !PATH.test(path) || path.split(/[/?]/).includes('..'))
24
+ throw new UsageError(`Not a path of ${service}: ${path}`);
25
+ // The token of each request: the one in memory, or a new one from the PBX when it is near its end (a long watch).
26
+ const t = await cloudToken(ctx);
27
+ seen.add(t.token);
28
+ const url = `${base}${path}${params ? query(params) : ''}`;
29
+ const headers = {'user-agent': userAgent(), accept: 'application/json', authorization: `Bearer ${t.token}`};
30
+ if (json !== undefined) headers['content-type'] = 'application/json';
31
+ let res;
32
+ try {
33
+ res = await fetchImpl(url, {
34
+ method,
35
+ headers,
36
+ body: json === undefined ? undefined : JSON.stringify(json),
37
+ redirect: 'error',
38
+ signal: ctx.signal,
39
+ });
40
+ } catch (e) {
41
+ throw new Error(`${service} (${base}) is not reachable: ${scrub(e.message)}`);
42
+ }
43
+ const text = scrub(await res.text());
44
+ if (!res.ok) {
45
+ let reason = text.slice(0, 300);
46
+ try {
47
+ const j = JSON.parse(text);
48
+ reason = j.message ?? j.reason ?? j.error ?? reason;
49
+ } catch {
50
+ // not JSON: the text as it is
51
+ }
52
+ if (res.status === 401 || res.status === 403)
53
+ throw new AuthError(
54
+ `the cloud refused the token of ${t.company} for ${service} (${res.status}): ${scrub(reason)}`,
55
+ );
56
+ const err = new Error(`${service} answered ${res.status}: ${scrub(reason)}`);
57
+ err.status = res.status;
58
+ err.exitCode = 1;
59
+ throw err;
60
+ }
61
+ if (!text) return null;
62
+ try {
63
+ return JSON.parse(text);
64
+ } catch {
65
+ return text;
66
+ }
67
+ }
68
+
69
+ async function raw(method, url, {headers = {}, body} = {}) {
70
+ if (!/^https:\/\//.test(String(url))) throw new UsageError('A presigned link must be https');
71
+ return fetchImpl(url, {method, headers, body, redirect: 'error', signal: ctx.signal});
72
+ }
73
+
74
+ return {env, base, company: first.company, companyName: first.companyName, pbx: first.pbx, call, raw};
75
+ }
@@ -0,0 +1,60 @@
1
+ // The Wildix cloud services wx-admin reaches with the CPA token of a PBX, and their addresses per environment (the
2
+ // `servers` of their OpenAPI, 2026-11-07, corrected where the service does not exist: the knowledge base has no
3
+ // stable, although its spec names one).
4
+ import {UsageError} from 'wldx-core/errors';
5
+
6
+ export const ENVIRONMENTS = ['prod', 'stable', 'stage'];
7
+
8
+ export const SERVICES = {
9
+ kb: {
10
+ label: 'the knowledge base',
11
+ bases: {prod: 'https://kb.wim.wildix.com', stage: 'https://kb.wim-stage.wildix.com'},
12
+ },
13
+ voicebots: {
14
+ label: 'voicebots',
15
+ bases: {
16
+ prod: 'https://wim.wildix.com',
17
+ stable: 'https://wim-stable.wildix.com',
18
+ stage: 'https://wim-stage.wildix.com',
19
+ },
20
+ },
21
+ tools: {
22
+ label: 'WIM tools',
23
+ bases: {
24
+ prod: 'https://tools.wim.wildix.com',
25
+ stable: 'https://tools.wim-stable.wildix.com',
26
+ stage: 'https://tools.wim-stage.wildix.com',
27
+ },
28
+ },
29
+ wilma: {
30
+ label: 'Wilma',
31
+ bases: {
32
+ prod: 'https://api.wilma.wildix.com',
33
+ stable: 'https://api-stable.wilma.wildix.com',
34
+ stage: 'https://api-stage.wilma.wildix.com',
35
+ },
36
+ },
37
+ };
38
+
39
+ // The environment of the PBX, from the address of its WIM console (the `portal` of the CPA route).
40
+ export function envOf(portal) {
41
+ const host = (() => {
42
+ try {
43
+ return new URL(String(portal)).hostname;
44
+ } catch {
45
+ return '';
46
+ }
47
+ })();
48
+ if (host.includes('-stage.')) return 'stage';
49
+ if (host.includes('-stable.')) return 'stable';
50
+ return 'prod';
51
+ }
52
+
53
+ export function baseOf(service, env) {
54
+ if (!Object.hasOwn(SERVICES, service))
55
+ throw new UsageError(`Unknown cloud service: ${service}. Known: ${Object.keys(SERVICES).join(', ')}`);
56
+ const s = SERVICES[service];
57
+ if (!Object.hasOwn(s.bases, env))
58
+ throw new UsageError(`${s.label} has no ${env} environment: ${Object.keys(s.bases).join(' or ')}`);
59
+ return s.bases[env];
60
+ }
@@ -0,0 +1,39 @@
1
+ // Secrets in the answers of the cloud services, masked: fields whose name says secret (keys, tokens, passwords,
2
+ // credentials), the value of a header whose name does (headers as [{name, value}]), every custom header, every
3
+ // sandbox environment value, a key or token in the query of a URI; never the cursors and page tokens of paged lists.
4
+ // The shapes come from the specs of WIM voicebots, WIM tools and Wilma (docs/research of 2026-11-07).
5
+ export const MASK = '********';
6
+
7
+ const SECRET = /secret|password|passwd|^register$|^key$|api_?key|token|credentials|private_?key|authorization|cookie/i;
8
+ const PAGING = /^(?:(?:next|prev|previous)(?:page)?|page)(?:token|cursor)$|^cursor$/i;
9
+ const SECRET_HEADER = /authorization|api-?key|token|secret|cookie|auth/i;
10
+ const URI_SECRET = /([?&](?:api_?key|access_?token|token|key|secret)=)[^&#\s]+/gi;
11
+
12
+ const isSecretKey = (k) => SECRET.test(k) && !PAGING.test(k);
13
+ const hide = (v) => (v === null || v === undefined || v === '' ? v : MASK);
14
+
15
+ export function maskCloud(value, key = '') {
16
+ if (Array.isArray(value)) {
17
+ if (/^headers$/i.test(key))
18
+ return value.map((h) =>
19
+ h && typeof h === 'object' && SECRET_HEADER.test(String(h.name ?? h.key ?? ''))
20
+ ? {...maskCloud(h), value: hide(h.value)}
21
+ : maskCloud(h),
22
+ );
23
+ if (/^env$/i.test(key))
24
+ return value.map((e) => (e && typeof e === 'object' ? {...maskCloud(e), value: hide(e.value)} : e));
25
+ return value.map((v) => maskCloud(v));
26
+ }
27
+ if (value && typeof value === 'object') {
28
+ if (/^customHeaders$/i.test(key)) return Object.fromEntries(Object.keys(value).map((k) => [k, hide(value[k])]));
29
+ return Object.fromEntries(
30
+ Object.entries(value).map(([k, v]) => {
31
+ if (isSecretKey(k) && v && typeof v === 'object') return [k, MASK]; // a whole credential (a service account)
32
+ if (isSecretKey(k) && typeof v === 'string') return [k, hide(v)];
33
+ return [k, maskCloud(v, k)];
34
+ }),
35
+ );
36
+ }
37
+ if (typeof value === 'string' && /[?&]/.test(value)) return value.replace(URI_SECRET, `$1${MASK}`);
38
+ return value;
39
+ }
@@ -0,0 +1,26 @@
1
+ // The operations of the cloud services that accept the CPA token, from the OpenAPI bundled in specs/ (made by
2
+ // tools/fetch-cloud-specs.mjs): operationId → {method, path, summary, params, body}.
3
+ import fs from 'node:fs';
4
+
5
+ const cache = new Map();
6
+
7
+ export function operationsOf(service) {
8
+ if (cache.has(service)) return cache.get(service);
9
+ const spec = JSON.parse(fs.readFileSync(new URL(`../../specs/${service}.openapi.json`, import.meta.url), 'utf8'));
10
+ const ops = new Map();
11
+ for (const [path, item] of Object.entries(spec.paths ?? {})) {
12
+ for (const [method, op] of Object.entries(item)) {
13
+ ops.set(op.operationId, {
14
+ method: method.toUpperCase(),
15
+ path,
16
+ summary: op.summary ?? '',
17
+ params: (op.parameters ?? []).map((p) => ({name: p.name, in: p.in, required: p.required})),
18
+ body: Boolean(op.requestBody),
19
+ bodyRequired: Boolean(op.requestBody?.required),
20
+ schema: op.requestBody?.schema,
21
+ });
22
+ }
23
+ }
24
+ cache.set(service, ops);
25
+ return ops;
26
+ }
@@ -0,0 +1,47 @@
1
+ // The CPA token (Cognito PBX Auth): the cloud identity of a PBX, a Cognito ID token whose user is the PBX itself
2
+ // (cognito:username = its serial) in the company of the PBX. The PBX gives it to an administrator on an internal route
3
+ // of its WMS interface and keeps it until it expires (12 hours). Kept here in the memory of the process only, per PBX,
4
+ // and taken again a minute before its end.
5
+ import {jwtClaims} from 'wldx-core/auth';
6
+ import {AuthError} from 'wldx-core/errors';
7
+ import {PbxError, pbxClient} from '../pbx.mjs';
8
+
9
+ const ROUTE = '/PBX/integration/x-bees?skip-basic-auth-header=true';
10
+ const EARLY_MS = 60_000;
11
+ const tokens = new Map(); // PBX host → {token, company, companyName, portal, expiresAt, pbx}
12
+
13
+ export function clearCloudTokens() {
14
+ tokens.clear();
15
+ }
16
+
17
+ export async function cloudToken(ctx) {
18
+ const client = await pbxClient(ctx);
19
+ const host = client.target.host;
20
+ const now = ctx.clock?.() ?? Date.now();
21
+ const kept = tokens.get(host);
22
+ if (kept && kept.expiresAt - now > EARLY_MS) return kept;
23
+ let answer;
24
+ try {
25
+ answer = await client.call('GET', ROUTE);
26
+ } catch (e) {
27
+ if (e instanceof PbxError && (e.status === 401 || e.status === 403))
28
+ throw new AuthError(
29
+ `${host} gives its cloud token only to an administrator: use an access with the Admin role (wx-admin pbx check)`,
30
+ );
31
+ throw e;
32
+ }
33
+ const token = answer?.token;
34
+ if (typeof token !== 'string' || token.split('.').length !== 3)
35
+ throw new Error(`${host} answered no cloud token (WMS too old, or the PBX not linked to the cloud)`);
36
+ const claims = jwtClaims(token);
37
+ const t = {
38
+ token,
39
+ company: claims['custom:company_id'],
40
+ companyName: claims['custom:company_name'],
41
+ portal: answer.portal,
42
+ expiresAt: Number(claims.exp) * 1000,
43
+ pbx: host,
44
+ };
45
+ tokens.set(host, t);
46
+ return t;
47
+ }
@@ -0,0 +1,122 @@
1
+ // wx-admin cloud: any operation of the Wildix cloud services that accept the CPA token of the PBX (the knowledge
2
+ // base, voicebots, WIM tools, Wilma), from their OpenAPI bundled in specs/. GET reads; every other method needs
3
+ // --yes. Answers are masked (keys, tokens, passwords) unless --show-secrets.
4
+ import {UsageError} from 'wldx-core/errors';
5
+ import {readJsonInput} from 'wldx-core/files';
6
+ import {apiPath} from 'wldx-core/http';
7
+ import {cloudClient} from '../cloud/client.mjs';
8
+ import {SERVICES} from '../cloud/env.mjs';
9
+ import {operationsOf} from '../cloud/specs.mjs';
10
+ import {maskCloud} from '../cloud/mask.mjs';
11
+
12
+ const segment = (value) => apiPath(['', ''], value);
13
+
14
+ // The request of an operation: the path with its parameters, the query, the body; usage errors before any request.
15
+ export async function requestOf(ctx, service, operationId) {
16
+ const ops = operationsOf(service);
17
+ const op = ops.get(operationId);
18
+ if (!op)
19
+ throw new UsageError(`Unknown operation of ${service}: ${operationId}. Its operations: wx-admin cloud ${service}`);
20
+ const given = new Map();
21
+ for (const kv of ctx.values.param ?? []) {
22
+ const at = kv.indexOf('=');
23
+ if (at < 1) throw new UsageError(`--param is NAME=VALUE: ${kv}`);
24
+ const name = kv.slice(0, at);
25
+ if (!op.params.some((p) => p.name === name))
26
+ throw new UsageError(
27
+ `${operationId} has no parameter ${name}. Its parameters: ${op.params.map((p) => p.name).join(', ') || 'none'}`,
28
+ );
29
+ given.set(name, kv.slice(at + 1));
30
+ }
31
+ for (const p of op.params)
32
+ if (p.required && !given.has(p.name)) throw new UsageError(`${operationId} needs --param ${p.name}=…`);
33
+ const path = op.path.replace(/\{([^}]+)\}/g, (_, name) => segment(given.get(name)));
34
+ const query = Object.fromEntries(
35
+ op.params.filter((p) => p.in === 'query' && given.has(p.name)).map((p) => [p.name, given.get(p.name)]),
36
+ );
37
+ let json;
38
+ const v = ctx.values;
39
+ if (v.data !== undefined && v['data-file'] !== undefined)
40
+ throw new UsageError('Give --data or --data-file, not both');
41
+ if (v.data !== undefined || v['data-file'] !== undefined) {
42
+ if (!op.body) throw new UsageError(`${operationId} takes no body`);
43
+ if (v['data-file'] !== undefined) json = await readJsonInput(ctx, v['data-file']);
44
+ else {
45
+ try {
46
+ json = JSON.parse(v.data);
47
+ } catch {
48
+ throw new UsageError('--data is not JSON');
49
+ }
50
+ }
51
+ } else if (op.bodyRequired) throw new UsageError(`${operationId} needs a body: --data JSON or --data-file FILE`);
52
+ if (op.method !== 'GET' && !v.yes)
53
+ throw new UsageError(
54
+ `${operationId} is a ${op.method}: it changes or runs something in the cloud, confirm with --yes`,
55
+ );
56
+ return {op, path, query, json};
57
+ }
58
+
59
+ export const commands = {
60
+ cloud: {
61
+ summary:
62
+ 'any operation of the cloud services (knowledge base, voicebots, WIM tools, Wilma) with the token of the PBX',
63
+ usage:
64
+ 'wx-admin cloud [<service> [<operation>]] [--param NAME=VALUE]... [--data JSON | --data-file FILE|-] [--yes] [--show-secrets]',
65
+ details: [
66
+ 'Without a service: the services. With a service: its operations. The PBX (--pbx, the default one…) gives its',
67
+ 'cloud token to an administrator; the environment is the one of the PBX, or --env prod|stable|stage.',
68
+ 'Methods other than GET need --yes. Answers are masked (keys, tokens, passwords) unless --show-secrets.',
69
+ ].join('\n'),
70
+ options: {
71
+ param: {type: 'string', multiple: true, placeholder: 'NAME=VALUE', description: 'a path or query parameter'},
72
+ data: {type: 'string', placeholder: 'JSON', description: 'the JSON body'},
73
+ 'data-file': {type: 'string', placeholder: 'FILE', description: 'the JSON body from a file, or - for stdin'},
74
+ yes: {type: 'boolean', description: 'yes, run an operation that is not a GET'},
75
+ 'show-secrets': {
76
+ type: 'boolean',
77
+ description: 'show keys, tokens and passwords in the answer (masked otherwise)',
78
+ },
79
+ },
80
+ args: [0, 2],
81
+ complete: 'cloudServices',
82
+ async run(ctx) {
83
+ const [service, operationId] = ctx.positionals;
84
+ if (!service)
85
+ return {
86
+ services: Object.entries(SERVICES).map(([name, s]) => ({
87
+ name,
88
+ label: s.label,
89
+ environments: Object.keys(s.bases),
90
+ operations: operationsOf(name).size,
91
+ })),
92
+ };
93
+ if (!Object.hasOwn(SERVICES, service))
94
+ throw new UsageError(`Unknown cloud service: ${service}. Known: ${Object.keys(SERVICES).join(', ')}`);
95
+ if (!operationId)
96
+ return {
97
+ service,
98
+ operations: [...operationsOf(service)].map(([id, o]) => ({
99
+ operationId: id,
100
+ method: o.method,
101
+ path: o.path,
102
+ summary: o.summary,
103
+ })),
104
+ };
105
+ const {op, path, query, json} = await requestOf(ctx, service, operationId);
106
+ const client = await cloudClient(ctx, service);
107
+ const answer = await client.call(op.method, path, {query, json});
108
+ return ctx.values['show-secrets'] ? answer : maskCloud(answer);
109
+ },
110
+ text(r) {
111
+ if (r?.services)
112
+ return r.services.map(
113
+ (s) => `${s.name.padEnd(10)} ${s.label} · ${s.environments.join(', ')} · ${s.operations} operations`,
114
+ );
115
+ if (r?.operations && r.service)
116
+ return r.operations.map(
117
+ (o) => `${o.operationId.padEnd(40)} ${o.method.padEnd(6)} ${o.path}${o.summary ? ` ${o.summary}` : ''}`,
118
+ );
119
+ return JSON.stringify(r, null, 2);
120
+ },
121
+ },
122
+ };
@@ -4,10 +4,12 @@
4
4
  // offers the PBX with saved access. `readOnly: true` on a command that only reads lets it run with --all-pbx
5
5
  // (READ_ONLY below sets it).
6
6
  import {commands as api} from './api.mjs';
7
+ import {commands as cloud} from './cloud.mjs';
7
8
  import {groups as acl} from './acl.mjs';
8
9
  import {groups as config} from './config.mjs';
9
10
  import {groups as device} from './device.mjs';
10
11
  import {groups as dialplan} from './dialplan.mjs';
12
+ import {groups as kb} from './kb.mjs';
11
13
  import {login, logout} from 'wldx-core/commands/login';
12
14
  import {groups as pbx} from './pbx.mjs';
13
15
  import {groups as phonebook} from './phonebook.mjs';
@@ -39,11 +41,12 @@ export const GROUPS = {
39
41
  ...sound,
40
42
  ...records,
41
43
  ...config,
44
+ ...kb,
42
45
  ...pbx,
43
46
  ...profile,
44
47
  };
45
48
 
46
- export const COMMANDS = {...api, login, logout, status};
49
+ export const COMMANDS = {...api, ...cloud, login, logout, status};
47
50
 
48
51
  // The commands that only read the PBX (no change there, no file written): they may run with --all-pbx.
49
52
  const READ_ONLY = {
@@ -82,4 +85,10 @@ export const GLOBAL_OPTIONS = {
82
85
  complete: 'pbxs',
83
86
  },
84
87
  'all-pbx': {type: 'boolean', description: 'run a command that reads on every PBX'},
88
+ env: {
89
+ type: 'string',
90
+ choices: ['prod', 'stable', 'stage'],
91
+ key: 'cloudEnv',
92
+ description: 'the environment of the cloud services (default: the one of the PBX)',
93
+ },
85
94
  };