wldx-cli 1.0.0 → 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/README.md +19 -22
- package/package.json +9 -6
- package/src/chat/format.mjs +40 -8
- package/src/chat/lib.mjs +71 -23
- package/src/chat/watch.mjs +46 -15
- package/src/cli.mjs +13 -252
- package/src/commands/calls.mjs +135 -32
- package/src/commands/channel.mjs +99 -21
- package/src/commands/chat.mjs +77 -22
- package/src/commands/conversations.mjs +146 -29
- package/src/commands/file.mjs +13 -5
- package/src/commands/inbox.mjs +20 -13
- package/src/commands/index.mjs +5 -29
- package/src/commands/live.mjs +82 -24
- package/src/commands/meetings.mjs +114 -27
- package/src/commands/phone.mjs +145 -33
- package/src/commands/reports.mjs +219 -44
- package/src/commands/section.mjs +49 -8
- package/src/commands/user.mjs +42 -13
- package/src/history/text.mjs +30 -4
- package/src/history/wda.mjs +20 -10
- package/src/live/socket.mjs +25 -7
- package/src/live/stats.mjs +41 -9
- package/src/live/text.mjs +122 -23
- package/src/live/views.mjs +93 -23
- package/src/phone/control.mjs +93 -33
- package/src/phone/events.mjs +38 -7
- package/src/phone/pbx.mjs +29 -11
- package/src/phone/sip.mjs +87 -17
- package/src/phone/xml.mjs +2 -1
- package/src/stream.mjs +10 -5
- package/src/version.mjs +0 -1
- package/src/auth.mjs +0 -274
- package/src/commands/login.mjs +0 -126
- package/src/commands/profile.mjs +0 -51
- package/src/commands/status.mjs +0 -95
- package/src/completion.mjs +0 -132
- package/src/errors.mjs +0 -30
- package/src/files.mjs +0 -98
- package/src/format.mjs +0 -82
- package/src/http.mjs +0 -64
- package/src/services.mjs +0 -58
package/src/commands/profile.mjs
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// wx profile: the profiles (logged-in e-mails) and which one is the default. No network; never prints a token.
|
|
2
|
-
import {defaultProfile, sessions, setDefaultProfile} from '../auth.mjs';
|
|
3
|
-
import {AuthError} from '../errors.mjs';
|
|
4
|
-
import {localTime, paint, table} from '../format.mjs';
|
|
5
|
-
|
|
6
|
-
const profiles = (ctx) => {
|
|
7
|
-
const byDefault = defaultProfile(ctx.env);
|
|
8
|
-
return sessions(ctx.env).map((s) => ({
|
|
9
|
-
email: s.email,
|
|
10
|
-
default: s.email === byDefault,
|
|
11
|
-
tokenValidUntil: s.expiresAt ? new Date(s.expiresAt).toISOString() : null,
|
|
12
|
-
expired: s.expiresAt <= ctx.now,
|
|
13
|
-
}));
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export const groups = {
|
|
17
|
-
profile: {
|
|
18
|
-
summary: 'your profiles (the e-mails you are logged in with) and the default one',
|
|
19
|
-
commands: {
|
|
20
|
-
list: {
|
|
21
|
-
summary: 'your profiles; * marks the default one',
|
|
22
|
-
usage: 'wx profile list',
|
|
23
|
-
options: {},
|
|
24
|
-
args: [0, 0],
|
|
25
|
-
run: (ctx) => ({profiles: profiles(ctx)}),
|
|
26
|
-
text(r, ctx) {
|
|
27
|
-
if (!r.profiles.length) return 'Not logged in. Run: wx login <e-mail>';
|
|
28
|
-
const c = paint(ctx);
|
|
29
|
-
const lines = table(r.profiles.map((p) => [p.default ? '*' : ' ', p.email, p.expired ? c.dim('token renewed on next use') : `token valid until ${localTime(p.tokenValidUntil)}`]));
|
|
30
|
-
if (r.profiles.length > 1 && !r.profiles.some((p) => p.default)) lines.push(c.yellow('No default profile: set one with wx profile use <e-mail>'));
|
|
31
|
-
return lines;
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
use: {
|
|
35
|
-
summary: 'make a profile the default one (used when no --profile is given)',
|
|
36
|
-
usage: 'wx profile use <e-mail>',
|
|
37
|
-
options: {},
|
|
38
|
-
args: [1, 1],
|
|
39
|
-
complete: 'profiles',
|
|
40
|
-
run(ctx) {
|
|
41
|
-
const wanted = ctx.positionals[0];
|
|
42
|
-
const found = sessions(ctx.env).find((s) => s.email.toLowerCase() === wanted.toLowerCase());
|
|
43
|
-
if (!found) throw new AuthError(`No profile ${wanted}: log in first: wx login ${wanted}`);
|
|
44
|
-
setDefaultProfile(found.email, ctx.env);
|
|
45
|
-
return {defaultProfile: found.email};
|
|
46
|
-
},
|
|
47
|
-
text: (r) => `Default profile: ${r.defaultProfile}`,
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
};
|
package/src/commands/status.mjs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// wx status: the version, the profiles and who the current one is (from the claims of its token). No
|
|
2
|
-
// network, unless --check asks Wildix whether it accepts the token. Never prints a token.
|
|
3
|
-
import {chooseProfile, defaultProfile, getToken, jwtClaims, savedSession, sessions} from '../auth.mjs';
|
|
4
|
-
import {AuthError, HttpError} from '../errors.mjs';
|
|
5
|
-
import {localTime, paint, table} from '../format.mjs';
|
|
6
|
-
import {request} from '../http.mjs';
|
|
7
|
-
import {XBEES_API} from '../services.mjs';
|
|
8
|
-
import {VERSION} from '../version.mjs';
|
|
9
|
-
|
|
10
|
-
function whoami(claims) {
|
|
11
|
-
const port = claims['custom:pbx_port'];
|
|
12
|
-
return {
|
|
13
|
-
email: claims.email,
|
|
14
|
-
name: claims.name,
|
|
15
|
-
company: claims['custom:company_name'],
|
|
16
|
-
license: claims['custom:company_license_type'],
|
|
17
|
-
pbx: claims['custom:pbx_domain']
|
|
18
|
-
? {
|
|
19
|
-
host: claims['custom:pbx_domain'] + (port && String(port) !== '443' ? `:${port}` : ''),
|
|
20
|
-
serial: claims['custom:pbx_serial'],
|
|
21
|
-
extension: claims['custom:pbx_user_extension'],
|
|
22
|
-
role: claims['custom:pbx_user_role'],
|
|
23
|
-
}
|
|
24
|
-
: undefined,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export default {
|
|
29
|
-
summary: 'who you are logged in as, until when, and the version of wx',
|
|
30
|
-
usage: 'wx status [--profile EMAIL] [--check]',
|
|
31
|
-
options: {
|
|
32
|
-
check: {type: 'boolean', description: 'also ask Wildix whether it accepts the token (renews it when needed)'},
|
|
33
|
-
},
|
|
34
|
-
args: [0, 0],
|
|
35
|
-
async run(ctx) {
|
|
36
|
-
const {env, values} = ctx;
|
|
37
|
-
const byDefault = defaultProfile(env);
|
|
38
|
-
const all = sessions(env).map((s) => ({email: s.email, default: s.email === byDefault, tokenValidUntil: s.expiresAt ? new Date(s.expiresAt).toISOString() : null, expired: s.expiresAt <= ctx.now}));
|
|
39
|
-
const out = {version: VERSION, loggedIn: false, profile: null, user: null, tokenFromEnvironment: Boolean(env.WILDIX_ID_TOKEN), profiles: all};
|
|
40
|
-
let token;
|
|
41
|
-
if (env.WILDIX_ID_TOKEN) {
|
|
42
|
-
token = env.WILDIX_ID_TOKEN;
|
|
43
|
-
} else {
|
|
44
|
-
let email;
|
|
45
|
-
try {
|
|
46
|
-
const chosen = chooseProfile(env, values.profile);
|
|
47
|
-
email = chosen.email;
|
|
48
|
-
out.profile = {email, by: chosen.by};
|
|
49
|
-
} catch (e) {
|
|
50
|
-
if (!(e instanceof AuthError)) throw e;
|
|
51
|
-
out.problem = e.message;
|
|
52
|
-
return out;
|
|
53
|
-
}
|
|
54
|
-
const session = all.find((s) => s.email === email);
|
|
55
|
-
if (!session) {
|
|
56
|
-
out.problem = `No saved session for ${email}. Log in with: wx login ${email}`;
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
out.tokenValidUntil = session.tokenValidUntil;
|
|
60
|
-
token = values.check ? await getToken({profile: email, env, fetchImpl: ctx.fetch, now: ctx.clock()}) : savedSession(email, env)?.IdToken;
|
|
61
|
-
}
|
|
62
|
-
out.loggedIn = true;
|
|
63
|
-
out.user = whoami(jwtClaims(token));
|
|
64
|
-
if (values.check) {
|
|
65
|
-
try {
|
|
66
|
-
await request(`${XBEES_API}/v2/conversations/inbox/state`, {token, fetchImpl: ctx.fetch, service: 'x-bees'});
|
|
67
|
-
out.tokenAccepted = true;
|
|
68
|
-
} catch (e) {
|
|
69
|
-
if (!(e instanceof HttpError) || ![401, 403].includes(e.status)) throw e;
|
|
70
|
-
out.tokenAccepted = false;
|
|
71
|
-
out.problem = `Wildix rejected the token (HTTP ${e.status}). Log in again: wx login ${out.user.email ?? '<e-mail>'}`;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
},
|
|
76
|
-
text(r, ctx) {
|
|
77
|
-
const c = paint(ctx);
|
|
78
|
-
const rows = [['wx', r.version]];
|
|
79
|
-
if (r.user) {
|
|
80
|
-
const a = r.user;
|
|
81
|
-
const why = r.profile ? {option: 'chosen with --profile', environment: 'chosen with WILDIX_EMAIL', default: 'the default', only: 'the only one'}[r.profile.by] : 'from WILDIX_ID_TOKEN';
|
|
82
|
-
rows.push(['Profile', `${a.email ?? ''}${a.name ? ` (${a.name})` : ''} · ${why}`]);
|
|
83
|
-
if (a.company) rows.push(['Company', `${a.company}${a.license ? ` · ${a.license}` : ''}`]);
|
|
84
|
-
if (a.pbx) rows.push(['PBX', [a.pbx.host, a.pbx.serial && `serial ${a.pbx.serial}`, a.pbx.extension && `extension ${a.pbx.extension}`, a.pbx.role].filter(Boolean).join(' · ')]);
|
|
85
|
-
}
|
|
86
|
-
if (r.tokenFromEnvironment) rows.push(['Token', 'from WILDIX_ID_TOKEN']);
|
|
87
|
-
else if (r.tokenValidUntil) rows.push(['Token', `valid until ${localTime(r.tokenValidUntil)} (renewed automatically)`]);
|
|
88
|
-
if (r.tokenAccepted !== undefined) rows.push(['Check', r.tokenAccepted ? c.green('Wildix accepts the token') : c.red('rejected')]);
|
|
89
|
-
if (r.profiles.length > 1) rows.push(['Profiles', `${r.profiles.map((p) => `${p.email}${p.default ? ' (default)' : ''}`).join(', ')} · wx profile use <e-mail>`]);
|
|
90
|
-
const lines = table(rows);
|
|
91
|
-
if (r.problem) lines.push(c.yellow(r.problem));
|
|
92
|
-
else if (!r.loggedIn) lines.push(c.yellow('Not logged in. Run: wx login <e-mail>'));
|
|
93
|
-
return lines;
|
|
94
|
-
},
|
|
95
|
-
};
|
package/src/completion.mjs
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
// Shell completion. `wx completion <shell>` prints a small script; on every Tab it runs
|
|
2
|
-
// `wx __complete -- <words after wx, the last one being the word under the cursor>`, which prints the candidates,
|
|
3
|
-
// one per line as "value<TAB>description". The candidates come from the command definitions (and the saved
|
|
4
|
-
// profiles for --profile), so completion follows the commands without a regenerated script. No network.
|
|
5
|
-
import {sessions} from './auth.mjs';
|
|
6
|
-
import {COMMON_OPTIONS, GLOBAL_OPTIONS, REGISTRY, commandOf, groupOf, singleOf} from './commands/index.mjs';
|
|
7
|
-
|
|
8
|
-
export const SHELLS = ['bash', 'zsh', 'fish'];
|
|
9
|
-
|
|
10
|
-
export const TOP_LEVEL = {
|
|
11
|
-
completion: 'print the shell completion script (bash, zsh, fish)',
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const TOP_OPTIONS = {'--help': 'help of wx', '--version': 'the version of wx', '--profile': 'the profile (a logged-in e-mail) to use', '--output': 'text or json'};
|
|
15
|
-
|
|
16
|
-
function valuesOf(option, env) {
|
|
17
|
-
if (option.choices) return option.choices.map((v) => [v]);
|
|
18
|
-
if (option.complete === 'profiles') return sessions(env).map((s) => [s.email]);
|
|
19
|
-
return [];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// The candidates for the word under the cursor: ["value\tdescription", ...].
|
|
23
|
-
export function complete(words, env = process.env, registry = REGISTRY) {
|
|
24
|
-
const cur = words.length ? words[words.length - 1] : '';
|
|
25
|
-
const offer = (pairs) => pairs.filter(([v]) => v.startsWith(cur)).map(([v, d]) => (d ? `${v}\t${d}` : v));
|
|
26
|
-
// The global options may stand anywhere: the value of one is offered, and they are left out of what names the
|
|
27
|
-
// command (wx --profile a@b.c chat <Tab> completes like wx chat <Tab>).
|
|
28
|
-
const globalOf = (word) => {
|
|
29
|
-
const name = word?.startsWith('--') ? word.slice(2).split('=')[0] : undefined;
|
|
30
|
-
return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? name : undefined;
|
|
31
|
-
};
|
|
32
|
-
const before = words.slice(0, -1);
|
|
33
|
-
const last = before[before.length - 1];
|
|
34
|
-
if (globalOf(last) && !last.includes('=') && GLOBAL_OPTIONS[globalOf(last)].type === 'string') {
|
|
35
|
-
return offer(valuesOf(GLOBAL_OPTIONS[globalOf(last)], env));
|
|
36
|
-
}
|
|
37
|
-
const done = [];
|
|
38
|
-
for (let i = 0; i < before.length; i += 1) {
|
|
39
|
-
const name = globalOf(before[i]);
|
|
40
|
-
if (name) {
|
|
41
|
-
if (GLOBAL_OPTIONS[name].type === 'string' && !before[i].includes('=')) i += 1;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
done.push(before[i]);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (done.length === 0) {
|
|
48
|
-
return offer([
|
|
49
|
-
...Object.entries(registry.groups).map(([name, g]) => [name, g.summary]),
|
|
50
|
-
...Object.entries(registry.commands).map(([name, c]) => [name, c.summary]),
|
|
51
|
-
...Object.entries(TOP_LEVEL),
|
|
52
|
-
...(cur.startsWith('-') ? Object.entries(TOP_OPTIONS) : []),
|
|
53
|
-
]);
|
|
54
|
-
}
|
|
55
|
-
if (done[0] === 'completion') return done.length === 1 ? offer(SHELLS.map((s) => [s, `the script for ${s}`])) : [];
|
|
56
|
-
|
|
57
|
-
// The command and how many words name it: 1 for a single command, 2 for a group and its command.
|
|
58
|
-
let cmd = singleOf(done[0], registry);
|
|
59
|
-
let depth = 1;
|
|
60
|
-
if (!cmd) {
|
|
61
|
-
const group = groupOf(done[0], registry);
|
|
62
|
-
if (!group) return [];
|
|
63
|
-
if (done.length === 1) return offer(Object.entries(group.commands).map(([name, c]) => [name, c.summary]));
|
|
64
|
-
cmd = commandOf(group, done[1]);
|
|
65
|
-
depth = 2;
|
|
66
|
-
}
|
|
67
|
-
if (!cmd) return [];
|
|
68
|
-
const options = {...cmd.options, ...COMMON_OPTIONS, ...GLOBAL_OPTIONS};
|
|
69
|
-
const prev = done[done.length - 1];
|
|
70
|
-
if (done.length > depth && prev.startsWith('--')) {
|
|
71
|
-
const option = Object.hasOwn(options, prev.slice(2)) ? options[prev.slice(2)] : undefined;
|
|
72
|
-
if (option?.type === 'string') return offer(valuesOf(option, env));
|
|
73
|
-
}
|
|
74
|
-
if (cur.startsWith('-')) {
|
|
75
|
-
const used = new Set(done.slice(depth).filter((w) => w.startsWith('--')).map((w) => w.slice(2)));
|
|
76
|
-
return offer(Object.entries(options).filter(([name, o]) => o.multiple || !used.has(name)).map(([name, o]) => [`--${name}`, o.description]));
|
|
77
|
-
}
|
|
78
|
-
// A command whose argument is a profile (wx profile use <Tab>).
|
|
79
|
-
if (cmd.complete === 'profiles') return offer(valuesOf({complete: 'profiles'}, env));
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const SCRIPTS = {
|
|
84
|
-
bash: `# wx completion for bash. Add to ~/.bashrc: eval "$(wx completion bash)"
|
|
85
|
-
_wx_complete() {
|
|
86
|
-
# The words are taken as an array first: bash 3.2 (macOS) joins the elements of a quoted array slice when IFS
|
|
87
|
-
# is a newline.
|
|
88
|
-
local words=("\${COMP_WORDS[@]:1:COMP_CWORD}") out line
|
|
89
|
-
COMPREPLY=()
|
|
90
|
-
out=$(wx __complete -- "\${words[@]}" 2>/dev/null) || return 0
|
|
91
|
-
# Line by line from a here-string: no word splitting and no glob expansion of the candidates.
|
|
92
|
-
while IFS= read -r line; do
|
|
93
|
-
[[ -n $line ]] && COMPREPLY+=("\${line%%$'\\t'*}")
|
|
94
|
-
done <<< "$out"
|
|
95
|
-
}
|
|
96
|
-
complete -F _wx_complete wx
|
|
97
|
-
`,
|
|
98
|
-
zsh: `# wx completion for zsh. Add to ~/.zshrc (after compinit): eval "$(wx completion zsh)"
|
|
99
|
-
_wx() {
|
|
100
|
-
local -a lines items
|
|
101
|
-
local line
|
|
102
|
-
lines=("\${(@f)$(wx __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)}")
|
|
103
|
-
for line in $lines; do
|
|
104
|
-
[[ -n $line ]] || continue
|
|
105
|
-
if [[ $line == *$'\\t'* ]]; then items+=("\${\${line%%$'\\t'*}//:/\\\\:}:\${line#*$'\\t'}")
|
|
106
|
-
else items+=("\${line//:/\\\\:}"); fi
|
|
107
|
-
done
|
|
108
|
-
(( \${#items} )) && _describe -t wx-candidates 'wx' items
|
|
109
|
-
}
|
|
110
|
-
(( \${+functions[compdef]} )) || { autoload -Uz compinit && compinit; }
|
|
111
|
-
compdef _wx wx
|
|
112
|
-
`,
|
|
113
|
-
fish: `# wx completion for fish. Save as ~/.config/fish/completions/wx.fish: wx completion fish > ~/.config/fish/completions/wx.fish
|
|
114
|
-
complete -c wx -f -a '(wx __complete -- (commandline -opc)[2..-1] (commandline -ct) 2>/dev/null)'
|
|
115
|
-
`,
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
export function completionScript(shell) {
|
|
119
|
-
return Object.hasOwn(SCRIPTS, shell) ? SCRIPTS[shell] : undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function completionUsage() {
|
|
123
|
-
return [
|
|
124
|
-
'Usage: wx completion [bash|zsh|fish] (default: the shell of $SHELL)',
|
|
125
|
-
'',
|
|
126
|
-
'Prints the completion script. To turn completion on for good:',
|
|
127
|
-
' bash: echo \'eval "$(wx completion bash)"\' >> ~/.bashrc',
|
|
128
|
-
' zsh: echo \'eval "$(wx completion zsh)"\' >> ~/.zshrc',
|
|
129
|
-
' fish: wx completion fish > ~/.config/fish/completions/wx.fish',
|
|
130
|
-
'then open a new terminal. It completes an installed `wx` (npm i -g wldx-cli), not `npx wldx-cli`.',
|
|
131
|
-
].join('\n');
|
|
132
|
-
}
|
package/src/errors.mjs
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// Errors that map to exit codes: UsageError 64 (EX_USAGE), AuthError 2, anything else 1.
|
|
2
|
-
|
|
3
|
-
export class UsageError extends Error {
|
|
4
|
-
constructor(message, usage) {
|
|
5
|
-
super(message);
|
|
6
|
-
this.name = 'UsageError';
|
|
7
|
-
this.usage = usage;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class AuthError extends Error {
|
|
12
|
-
constructor(message) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = 'AuthError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const NOT_IMPLEMENTED = /is not implemented/;
|
|
19
|
-
|
|
20
|
-
export class HttpError extends Error {
|
|
21
|
-
constructor(status, method, path, body, service = 'the service') {
|
|
22
|
-
const lines = [`HTTP ${status} from ${service} (${method} ${path})`];
|
|
23
|
-
if (body) lines.push(body.length > 2000 ? `${body.slice(0, 2000)}…` : body);
|
|
24
|
-
if (status === 401) lines.push('The token was rejected: log in again.');
|
|
25
|
-
if (NOT_IMPLEMENTED.test(body ?? '')) lines.push('The service does not implement this operation yet. Do not retry.');
|
|
26
|
-
super(lines.join('\n'));
|
|
27
|
-
this.name = 'HttpError';
|
|
28
|
-
this.status = status;
|
|
29
|
-
}
|
|
30
|
-
}
|
package/src/files.mjs
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// Local files that commands read or write on behalf of the user, where the path may have come from fetched text
|
|
2
|
-
// (an agent asked to "save the attachment to …" or to "attach …"): the guards that keep secrets in and startup
|
|
3
|
-
// files out.
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import {configDir} from './auth.mjs';
|
|
8
|
-
import {UsageError} from './errors.mjs';
|
|
9
|
-
|
|
10
|
-
// macOS and Windows file systems ignore case by default: compare paths without it there.
|
|
11
|
-
const FOLD = process.platform === 'darwin' || process.platform === 'win32';
|
|
12
|
-
const norm = (p) => (FOLD ? p.toLowerCase() : p);
|
|
13
|
-
const within = (child, parent) => norm(child) === norm(parent) || norm(child).startsWith(norm(parent) + path.sep);
|
|
14
|
-
|
|
15
|
-
// The real path (symlinks resolved, true case on macOS) of a path that may not exist yet: the nearest existing
|
|
16
|
-
// ancestor resolved, the rest appended.
|
|
17
|
-
export function realPath(p) {
|
|
18
|
-
const abs = path.resolve(p);
|
|
19
|
-
let head = abs;
|
|
20
|
-
const rest = [];
|
|
21
|
-
for (;;) {
|
|
22
|
-
try {
|
|
23
|
-
return path.join(fs.realpathSync.native(head), ...rest);
|
|
24
|
-
} catch (e) {
|
|
25
|
-
if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') throw e;
|
|
26
|
-
const up = path.dirname(head);
|
|
27
|
-
if (up === head) return abs;
|
|
28
|
-
rest.unshift(path.basename(head));
|
|
29
|
-
head = up;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const homeOf = (env) => env.HOME || os.homedir();
|
|
35
|
-
|
|
36
|
-
// Where a file written for the user must never land: anything hidden (a segment that starts with ".": ~/.ssh,
|
|
37
|
-
// ~/.zshenv, .git/hooks, node_modules/.bin…) and the macOS startup folders. A downloaded file there could give
|
|
38
|
-
// someone a login or run code; a person who really wants it there can move it.
|
|
39
|
-
const STARTUP = ['Library/LaunchAgents', 'Library/LaunchDaemons', 'Library/StartupItems'];
|
|
40
|
-
|
|
41
|
-
// Returns the real path; throws a UsageError for a path that is refused.
|
|
42
|
-
export function refuseUnsafeWrite(target, env = process.env) {
|
|
43
|
-
const real = realPath(target);
|
|
44
|
-
const hidden = real.split(path.sep).find((s) => s.startsWith('.') && s !== '.' && s !== '..');
|
|
45
|
-
if (hidden) throw new UsageError(`Refusing to write into a hidden file or folder (${hidden}): ${target}. Choose a visible path.`);
|
|
46
|
-
const homes = [homeOf(env), realPath(homeOf(env))];
|
|
47
|
-
if (homes.some((h) => STARTUP.some((d) => within(real, path.join(h, d)) || within(real, path.join('/', d))))) {
|
|
48
|
-
throw new UsageError(`Refusing to write into a startup folder: ${target}`);
|
|
49
|
-
}
|
|
50
|
-
return real;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Credentials: folders and files that hold keys and tokens, and names that look like secrets.
|
|
54
|
-
const SECRET_FOLDERS = ['.wildix', '.ssh', '.aws', '.gnupg', '.config/gcloud', '.kube', '.docker', '.claude', '.agents'];
|
|
55
|
-
const SECRET_FILES = ['.netrc', '.npmrc'];
|
|
56
|
-
const SECRET_NAME = /^(\.env|\.env\..*|.*\.pem|.*\.key|.*\.p12|.*\.pfx|id_rsa.*|id_ed25519.*|id_ecdsa.*|.*\.kdbx|credentials.*\.json|.*secret.*|.*\.token)$/is;
|
|
57
|
-
|
|
58
|
-
// A file to send out of the machine (an attachment) is refused when it is, or points to, a credential: its real
|
|
59
|
-
// path (symlinks resolved, true case) in a credentials folder, a secret-looking name (given or real), or a file with
|
|
60
|
-
// other hard links (which could be one). The user can copy the file elsewhere if they really mean it.
|
|
61
|
-
export function refuseSecretPath(file, env = process.env) {
|
|
62
|
-
let real;
|
|
63
|
-
let stat;
|
|
64
|
-
try {
|
|
65
|
-
real = fs.realpathSync.native(file);
|
|
66
|
-
stat = fs.statSync(real);
|
|
67
|
-
} catch {
|
|
68
|
-
real = realPath(file); // not there (yet): its folder still counts
|
|
69
|
-
}
|
|
70
|
-
const home = homeOf(env);
|
|
71
|
-
const roots = [home, realPath(home)];
|
|
72
|
-
const folders = [configDir(env), realPath(configDir(env)), ...roots.flatMap((h) => SECRET_FOLDERS.map((d) => path.join(h, d)))];
|
|
73
|
-
const files = roots.flatMap((h) => SECRET_FILES.map((f) => path.join(h, f)));
|
|
74
|
-
if (folders.some((d) => within(real, d)) || files.some((f) => norm(f) === norm(real))) {
|
|
75
|
-
throw new UsageError(`Refusing to attach a file from a credentials folder: ${file}`);
|
|
76
|
-
}
|
|
77
|
-
for (const name of new Set([path.basename(file), path.basename(real)])) {
|
|
78
|
-
if (SECRET_NAME.test(name)) throw new UsageError(`Refusing to attach what looks like a secret: ${name}`);
|
|
79
|
-
}
|
|
80
|
-
if (stat?.isFile() && stat.nlink > 1) throw new UsageError(`Refusing to attach a file with other hard links (it could be a credential): ${file}`);
|
|
81
|
-
return {real, stat};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Reads a file to send out: checked with refuseSecretPath, then read from the descriptor that was opened, which
|
|
85
|
-
// must still be the file that was checked (nothing can be swapped in between).
|
|
86
|
-
export function readForUpload(file, env = process.env) {
|
|
87
|
-
const {real, stat} = refuseSecretPath(file, env);
|
|
88
|
-
if (!stat) throw new UsageError(`File not found: ${file}`);
|
|
89
|
-
if (!stat.isFile()) throw new UsageError(`Not a file: ${file}`);
|
|
90
|
-
const fd = fs.openSync(real, 'r');
|
|
91
|
-
try {
|
|
92
|
-
const now = fs.fstatSync(fd);
|
|
93
|
-
if (now.ino !== stat.ino || now.dev !== stat.dev || now.nlink > 1) throw new UsageError(`The file changed while it was checked: ${file}`);
|
|
94
|
-
return fs.readFileSync(fd);
|
|
95
|
-
} finally {
|
|
96
|
-
fs.closeSync(fd);
|
|
97
|
-
}
|
|
98
|
-
}
|
package/src/format.mjs
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// Text output: what `--output text` (the default) prints. A command gives `text(result, ctx)` (and `textEvent(event,
|
|
2
|
-
// ctx)` for a streaming one) returning a string or an array of lines; without one, toText() prints the data as
|
|
3
|
-
// indented "key: value" lines. Helpers for formatters: colors (only on a terminal), times, tables.
|
|
4
|
-
|
|
5
|
-
// Colors: on when ctx.color (a terminal, no --no-color, no NO_COLOR).
|
|
6
|
-
const CODES = {bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], blue: [34, 39], cyan: [36, 39]};
|
|
7
|
-
export function paint(ctx) {
|
|
8
|
-
const on = Boolean(ctx?.color);
|
|
9
|
-
return Object.fromEntries(Object.entries(CODES).map(([name, [a, b]]) => [name, (s) => (on ? `\x1b[${a}m${s}\x1b[${b}m` : String(s))]));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const pad2 = (n) => String(n).padStart(2, '0');
|
|
13
|
-
|
|
14
|
-
// "2026-11-07 10:23" in the local time zone of the machine (ISO string or ms); '' for nothing.
|
|
15
|
-
export function localTime(value, {seconds = false} = {}) {
|
|
16
|
-
if (value === undefined || value === null || value === '') return '';
|
|
17
|
-
const d = new Date(typeof value === 'number' || /^\d+$/.test(String(value)) ? Number(value) : value);
|
|
18
|
-
if (Number.isNaN(d.getTime())) return String(value);
|
|
19
|
-
const t = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
20
|
-
return seconds ? `${t}:${pad2(d.getSeconds())}` : t;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// "1:05:09" / "5:09" from seconds.
|
|
24
|
-
export function duration(seconds) {
|
|
25
|
-
if (!Number.isFinite(seconds)) return '';
|
|
26
|
-
const s = Math.max(0, Math.round(seconds));
|
|
27
|
-
const h = Math.floor(s / 3600);
|
|
28
|
-
const m = Math.floor((s % 3600) / 60);
|
|
29
|
-
return h ? `${h}:${pad2(m)}:${pad2(s % 60)}` : `${m}:${pad2(s % 60)}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// One line: newlines folded, cut to max characters.
|
|
33
|
-
export function oneLine(text, max = 120) {
|
|
34
|
-
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
35
|
-
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Aligned columns: rows are arrays of cells; the last column is not padded. Colors in cells are not counted.
|
|
39
|
-
export function table(rows) {
|
|
40
|
-
const visible = (s) => String(s ?? '').replace(/\x1b\[\d+m/g, '').length;
|
|
41
|
-
const widths = [];
|
|
42
|
-
for (const row of rows) row.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, visible(c))));
|
|
43
|
-
return rows.map((row) => row.map((c, i) => (i === row.length - 1 ? String(c ?? '') : String(c ?? '') + ' '.repeat(widths[i] - visible(c)))).join(' ').trimEnd());
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const scalar = (v) => v === null || typeof v !== 'object';
|
|
47
|
-
|
|
48
|
-
// Any data as indented "key: value" lines (the fallback of commands without a formatter).
|
|
49
|
-
export function toText(value, indent = '') {
|
|
50
|
-
if (value === undefined) return [];
|
|
51
|
-
if (scalar(value)) return [`${indent}${value}`];
|
|
52
|
-
const lines = [];
|
|
53
|
-
if (Array.isArray(value)) {
|
|
54
|
-
if (value.every(scalar)) return [`${indent}${value.join(', ')}`];
|
|
55
|
-
value.forEach((item, i) => {
|
|
56
|
-
if (i && !scalar(item)) lines.push('');
|
|
57
|
-
lines.push(...toText(item, indent));
|
|
58
|
-
});
|
|
59
|
-
return lines;
|
|
60
|
-
}
|
|
61
|
-
for (const [k, v] of Object.entries(value)) {
|
|
62
|
-
if (v === undefined) continue;
|
|
63
|
-
if (scalar(v)) lines.push(`${indent}${k}: ${v}`);
|
|
64
|
-
else if (Array.isArray(v) && v.every(scalar)) lines.push(`${indent}${k}: ${v.join(', ')}`);
|
|
65
|
-
else if (Array.isArray(v) && !v.length) lines.push(`${indent}${k}: (none)`);
|
|
66
|
-
else lines.push(`${indent}${k}:`, ...toText(v, `${indent} `));
|
|
67
|
-
}
|
|
68
|
-
return lines;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Text for a terminal from data that may hold control sequences (a message, a name, an error of a service): kept
|
|
72
|
-
// are newlines, tabs and the color codes paint() itself writes; every other control character is dropped, so a
|
|
73
|
-
// fetched text cannot write the clipboard (OSC 52), move the cursor, rewrite lines or hide text.
|
|
74
|
-
const OWN_SGR = new Set(Object.values(CODES).flat().map(String));
|
|
75
|
-
export function clean(text) {
|
|
76
|
-
// NUL goes first: it then marks the color codes that are kept, and no input can forge such a mark.
|
|
77
|
-
return String(text)
|
|
78
|
-
.replace(/\u0000/g, '')
|
|
79
|
-
.replace(/\x1b\[(\d+)m/g, (m, code) => (OWN_SGR.has(code) ? `\u0000${code}\u0000` : ''))
|
|
80
|
-
.replace(/[\x01-\x08\x0b-\x1f\x7f-\x9f]/g, '')
|
|
81
|
-
.replace(/\u0000(\d+)\u0000/g, '\x1b[$1m');
|
|
82
|
-
}
|
package/src/http.mjs
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import {HttpError} from './errors.mjs';
|
|
2
|
-
import {USER_AGENT} from './version.mjs';
|
|
3
|
-
|
|
4
|
-
const SEGMENT = /^[A-Za-z0-9_+][A-Za-z0-9_.:+@-]*$/;
|
|
5
|
-
|
|
6
|
-
// A tagged template for request paths: every interpolated value is one path segment of id characters (no "/",
|
|
7
|
-
// "?", "#", "..", nothing empty), so an id taken from fetched data cannot rewrite the path.
|
|
8
|
-
// apiPath`/channels/${id}/messages`
|
|
9
|
-
export function apiPath(strings, ...values) {
|
|
10
|
-
return strings.reduce((out, s, i) => {
|
|
11
|
-
if (i === 0) return s;
|
|
12
|
-
const v = String(values[i - 1] ?? '');
|
|
13
|
-
if (!SEGMENT.test(v) || v.includes('..')) throw new Error(`An id is not safe in a request path: ${JSON.stringify(v)}`);
|
|
14
|
-
return out + encodeURIComponent(v) + s;
|
|
15
|
-
}, '');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// "?a=1&b=2" from an object; undefined, null, false and '' are left out; an array repeats the parameter.
|
|
19
|
-
export function query(params) {
|
|
20
|
-
const q = new URLSearchParams();
|
|
21
|
-
for (const [k, v] of Object.entries(params)) {
|
|
22
|
-
for (const item of Array.isArray(v) ? v : [v]) if (item !== undefined && item !== null && item !== false && item !== '') q.append(k, String(item));
|
|
23
|
-
}
|
|
24
|
-
const s = q.toString();
|
|
25
|
-
return s ? `?${s}` : '';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// One request. Redirects are refused, so the token never follows one to another host.
|
|
29
|
-
// body sent as JSON
|
|
30
|
-
// raw {data, contentType}: sent as it is (an upload); never with a token to a host the caller did not choose
|
|
31
|
-
// headers more headers
|
|
32
|
-
// responseType 'json' (default: the parsed answer, {ok: true} when empty), 'text', 'buffer' (a Buffer), 'response'
|
|
33
|
-
// Throws HttpError on a status other than 2xx (except with responseType 'response').
|
|
34
|
-
export async function request(url, {method = 'GET', token, body, raw, headers = {}, responseType = 'json', service, fetchImpl = globalThis.fetch, timeoutMs = 60_000, signal} = {}) {
|
|
35
|
-
const h = {'user-agent': USER_AGENT, accept: responseType === 'json' ? 'application/json' : '*/*', ...headers};
|
|
36
|
-
if (token) h.authorization = `Bearer ${token}`;
|
|
37
|
-
let payload;
|
|
38
|
-
if (raw) {
|
|
39
|
-
payload = raw.data;
|
|
40
|
-
if (raw.contentType) h['content-type'] = raw.contentType;
|
|
41
|
-
} else if (body !== undefined) {
|
|
42
|
-
payload = JSON.stringify(body);
|
|
43
|
-
h['content-type'] = 'application/json';
|
|
44
|
-
}
|
|
45
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
46
|
-
const res = await fetchImpl(url, {
|
|
47
|
-
method,
|
|
48
|
-
headers: h,
|
|
49
|
-
body: payload,
|
|
50
|
-
redirect: 'error',
|
|
51
|
-
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
52
|
-
});
|
|
53
|
-
if (responseType === 'response') return res;
|
|
54
|
-
if (!res.ok) throw new HttpError(res.status, method, new URL(url).pathname, await res.text().catch(() => ''), service);
|
|
55
|
-
if (responseType === 'buffer') return Buffer.from(await res.arrayBuffer());
|
|
56
|
-
const text = await res.text();
|
|
57
|
-
if (responseType === 'text') return text;
|
|
58
|
-
if (!text) return {ok: true};
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(text);
|
|
61
|
-
} catch {
|
|
62
|
-
return {text};
|
|
63
|
-
}
|
|
64
|
-
}
|
package/src/services.mjs
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// The addresses of the Wildix services. The token is sent only to these: https/wss hosts under wildix.com, the
|
|
2
|
-
// x-bees API, and the user's own PBX named in the claims of the login token. Overrides (tests against a local
|
|
3
|
-
// server) are honoured only with WILDIX_ALLOW_CUSTOM_URL=1, except WILDIX_STREAM_URL and WILDIX_WDA_URL, which may
|
|
4
|
-
// name another https host under wildix.com.
|
|
5
|
-
import {jwtClaims} from './auth.mjs';
|
|
6
|
-
import {UsageError} from './errors.mjs';
|
|
7
|
-
|
|
8
|
-
export const XBEES_API = 'https://api.x-bees.com';
|
|
9
|
-
|
|
10
|
-
const UNDER_WILDIX = (scheme) => new RegExp(`^${scheme}://[a-z0-9-]+(\\.[a-z0-9-]+)*\\.wildix\\.com(:[0-9]+)?$`);
|
|
11
|
-
const HTTPS_WILDIX = UNDER_WILDIX('https');
|
|
12
|
-
const HOST = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
13
|
-
|
|
14
|
-
const custom = (env) => env.WILDIX_ALLOW_CUSTOM_URL === '1';
|
|
15
|
-
const trim = (url) => url.replace(/\/+$/, '');
|
|
16
|
-
|
|
17
|
-
// base(variable, default, env, {wildixOverride}): the default, or the override in `variable` when allowed.
|
|
18
|
-
function base(variable, fallback, env, {wildixOverride = false} = {}) {
|
|
19
|
-
const given = env[variable];
|
|
20
|
-
if (!given) return fallback;
|
|
21
|
-
const url = trim(given);
|
|
22
|
-
if (custom(env)) return url;
|
|
23
|
-
if (wildixOverride && HTTPS_WILDIX.test(url)) return url;
|
|
24
|
-
throw new UsageError(
|
|
25
|
-
wildixOverride
|
|
26
|
-
? `${variable} must be https://<host>.wildix.com (another address only with WILDIX_ALLOW_CUSTOM_URL=1), got: ${url}`
|
|
27
|
-
: `${variable} is honoured only with WILDIX_ALLOW_CUSTOM_URL=1 (tests), got: ${url}`,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function region(env) {
|
|
32
|
-
const r = env.WILDIX_STREAM_REGION || 'eu-1';
|
|
33
|
-
if (!/^[a-z0-9-]{1,32}$/.test(r)) throw new UsageError(`WILDIX_STREAM_REGION is not a region: ${r}`);
|
|
34
|
-
return r;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Chats (the stream service): https://<region>.stream.wildix.com, the live feed wss://<region>-ws.stream.wildix.com.
|
|
38
|
-
export const streamUrl = (env = process.env) => base('WILDIX_STREAM_URL', `https://${region(env)}.stream.wildix.com`, env, {wildixOverride: true});
|
|
39
|
-
export const streamWsUrl = (env = process.env) => base('WILDIX_STREAM_WS_URL', `wss://${region(env)}-ws.stream.wildix.com`, env);
|
|
40
|
-
|
|
41
|
-
// Wildix Data Analytics: history, insights, reports (https://wda.wildix.com), the live stream (wss://wda-ws...).
|
|
42
|
-
export const wdaUrl = (env = process.env) => base('WILDIX_WDA_URL', 'https://wda.wildix.com', env, {wildixOverride: true});
|
|
43
|
-
export const wdaWsUrl = (env = process.env) => base('WILDIX_WDA_WS_URL', 'wss://wda-ws.wildix.com', env);
|
|
44
|
-
|
|
45
|
-
// The x-bees API (users, conversations of the older service, token check).
|
|
46
|
-
export const xbeesUrl = (env = process.env) => base('WILDIX_XBEES_URL', XBEES_API, env);
|
|
47
|
-
|
|
48
|
-
// The user's PBX: the login token names it (custom:pbx_domain, custom:pbx_port) and the PBX accepts that token.
|
|
49
|
-
export function pbxUrl(token, env = process.env) {
|
|
50
|
-
if (env.WILDIX_PBX_URL) return base('WILDIX_PBX_URL', undefined, env);
|
|
51
|
-
const claims = jwtClaims(token);
|
|
52
|
-
const host = claims['custom:pbx_domain'];
|
|
53
|
-
const port = claims['custom:pbx_port'];
|
|
54
|
-
if (!host) throw new UsageError('This login is not linked to a PBX (the token has no custom:pbx_domain): this needs a PBX user');
|
|
55
|
-
if (!HOST.test(host)) throw new UsageError(`The PBX address in the login is not a host name: ${host}`);
|
|
56
|
-
if (port !== undefined && port !== '' && !/^[0-9]{1,5}$/.test(String(port))) throw new UsageError(`The PBX port in the login is not a number: ${port}`);
|
|
57
|
-
return `https://${host}${port && String(port) !== '443' ? `:${port}` : ''}`;
|
|
58
|
-
}
|