wldx-cli 0.1.0 → 1.0.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 +279 -27
- package/package.json +1 -1
- package/src/auth.mjs +181 -35
- package/src/chat/format.mjs +97 -0
- package/src/chat/lib.mjs +162 -0
- package/src/chat/watch.mjs +251 -0
- package/src/cli.mjs +167 -47
- package/src/commands/calls.mjs +367 -0
- package/src/commands/channel.mjs +326 -0
- package/src/commands/chat.mjs +388 -60
- package/src/commands/conversations.mjs +251 -0
- package/src/commands/file.mjs +72 -0
- package/src/commands/inbox.mjs +101 -0
- package/src/commands/index.mjs +54 -9
- package/src/commands/live.mjs +404 -0
- package/src/commands/login.mjs +126 -0
- package/src/commands/meetings.mjs +299 -0
- package/src/commands/phone.mjs +452 -0
- package/src/commands/profile.mjs +51 -0
- package/src/commands/reports.mjs +431 -0
- package/src/commands/section.mjs +60 -0
- package/src/commands/status.mjs +95 -0
- package/src/commands/user.mjs +130 -0
- package/src/completion.mjs +47 -20
- package/src/files.mjs +98 -0
- package/src/format.mjs +82 -0
- package/src/history/text.mjs +60 -0
- package/src/history/wda.mjs +161 -0
- package/src/http.mjs +30 -13
- package/src/live/socket.mjs +121 -0
- package/src/live/stats.mjs +211 -0
- package/src/live/text.mjs +219 -0
- package/src/live/views.mjs +321 -0
- package/src/phone/control.mjs +377 -0
- package/src/phone/events.mjs +189 -0
- package/src/phone/pbx.mjs +140 -0
- package/src/phone/sip.mjs +316 -0
- package/src/phone/xml.mjs +56 -0
- package/src/services.mjs +58 -0
- package/src/stream.mjs +4 -11
- package/src/commands/auth.mjs +0 -32
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The live feed of the user's chats: the websocket of the stream service (wss://<region>-ws.stream.wildix.com),
|
|
2
|
+
// as the agent skill's watch.mjs. Read only: it sends nothing to any chat and marks nothing read. One event per
|
|
3
|
+
// ctx.emit(); READY when subscribed, RECONNECTING / RECONNECTED while the connection is renewed (the service closes
|
|
4
|
+
// one after two hours; the token, checked only when a connection opens, is renewed with ctx.freshToken()), END at
|
|
5
|
+
// the end (ctx.signal, --count or --for).
|
|
6
|
+
import {jwtClaims} from '../auth.mjs';
|
|
7
|
+
import {AuthError} from '../errors.mjs';
|
|
8
|
+
import {localTime, oneLine, paint} from '../format.mjs';
|
|
9
|
+
import {streamWsUrl} from '../services.mjs';
|
|
10
|
+
import {streamClient} from '../stream.mjs';
|
|
11
|
+
import {displayName, meId} from './lib.mjs';
|
|
12
|
+
|
|
13
|
+
export const EVENT_GROUPS = {
|
|
14
|
+
messages: ['message.new', 'message.updated', 'message.deleted'],
|
|
15
|
+
reactions: ['reaction.new', 'reaction.updated', 'reaction.deleted'],
|
|
16
|
+
reads: ['read.cursor.updated', 'read.receipt.updated'],
|
|
17
|
+
members: ['member.added', 'member.updated', 'member.removed'],
|
|
18
|
+
channels: [
|
|
19
|
+
'channel.created', 'channel.updated', 'channel.truncated', 'channel.added', 'channel.removed', 'channel.hidden',
|
|
20
|
+
'channel.visible', 'channel.muted', 'channel.unmuted', 'channel.pinned', 'channel.unpinned',
|
|
21
|
+
],
|
|
22
|
+
typing: ['typing.start', 'typing.stop'],
|
|
23
|
+
};
|
|
24
|
+
export const EVENT_CHOICES = [...Object.keys(EVENT_GROUPS), ...Object.values(EVENT_GROUPS).flat()];
|
|
25
|
+
|
|
26
|
+
const KEEPALIVE_MS = 4 * 60 * 1000; // the gateway drops a connection that is quiet for 10 minutes
|
|
27
|
+
const BACKOFF = [1, 2, 5, 10]; // seconds, then 30
|
|
28
|
+
const drop = (o) => JSON.parse(JSON.stringify(o)); // removes the undefined fields
|
|
29
|
+
const unreadOf = (counters) => (counters ? Object.values(counters).reduce((n, c) => n + (c?.unmutedUnreadMessageCount ?? 0), 0) : undefined);
|
|
30
|
+
|
|
31
|
+
// Whether an event passes the filters. A reaction event does not say who reacted (its message is someone else's
|
|
32
|
+
// work), so a reaction has no author: it is never taken for the user's own action, and --from leaves it out.
|
|
33
|
+
export function wanted(e, {channels, senders, types, mine, me}) {
|
|
34
|
+
if (types.size && !types.has(e.eventType)) return false;
|
|
35
|
+
if (channels.size && !channels.has(e.channelId)) return false;
|
|
36
|
+
const isReaction = e.eventType.startsWith('reaction.');
|
|
37
|
+
const author = isReaction ? undefined : e.message?.userId ?? e.readerUserId ?? e.typingUserId;
|
|
38
|
+
if (isReaction && senders.size) return false;
|
|
39
|
+
if (senders.size && !senders.has(author)) return false;
|
|
40
|
+
if (!mine) {
|
|
41
|
+
if (e.eventType === 'read.cursor.updated') return false; // always the user's own reading
|
|
42
|
+
if (author === me) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The short form of an event (the fields of the skill's watch.mjs).
|
|
48
|
+
export async function view(e, nameOf) {
|
|
49
|
+
const person = async (id) => (id ? {id, name: await nameOf(id)} : undefined);
|
|
50
|
+
const m = e.message;
|
|
51
|
+
const isReaction = e.eventType.startsWith('reaction.');
|
|
52
|
+
return drop({
|
|
53
|
+
event: e.eventType,
|
|
54
|
+
channelId: e.channelId,
|
|
55
|
+
channel: e.inboxChannel?.subject,
|
|
56
|
+
channelType: e.inboxChannel?.channelType,
|
|
57
|
+
messageId: e.messageId,
|
|
58
|
+
seq: e.timelineSeq,
|
|
59
|
+
from: isReaction ? undefined : await person(m?.userId),
|
|
60
|
+
messageAuthor: isReaction ? await person(m?.userId) : undefined,
|
|
61
|
+
text: m?.text,
|
|
62
|
+
attachments: m?.attachments?.length ? m.attachments.map((f) => drop({name: f.name, mime: f.mime})) : undefined,
|
|
63
|
+
mentions: m?.mentions?.length ? m.mentions : undefined,
|
|
64
|
+
system: m?.system || undefined,
|
|
65
|
+
reactions: isReaction ? m?.reactionCounts : undefined,
|
|
66
|
+
reader: await person(e.readerUserId),
|
|
67
|
+
readUpTo: e.lastReadVisibleSeq,
|
|
68
|
+
member: e.member ? {...e.member, name: await nameOf(e.member.userId)} : undefined,
|
|
69
|
+
typing: await person(e.typingUserId),
|
|
70
|
+
unread: unreadOf(e.sectionCounters),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokenExp = (token) => (Number.isFinite(jwtClaims(token).exp) ? jwtClaims(token).exp * 1000 : 0);
|
|
75
|
+
|
|
76
|
+
// Runs until ctx.signal, `count` events (0: no limit) or `seconds` (0: no limit). Resolves when it stops.
|
|
77
|
+
export async function watch(ctx, {channels = new Set(), senders = new Set(), types = new Set(), mine = false, raw = false, count = 0, seconds = 0} = {}) {
|
|
78
|
+
let token = ctx.token;
|
|
79
|
+
let client = streamClient({token, env: ctx.env, fetchImpl: ctx.fetch});
|
|
80
|
+
const me = await meId(ctx, client);
|
|
81
|
+
const wsBase = streamWsUrl(ctx.env);
|
|
82
|
+
const WS = globalThis.WebSocket;
|
|
83
|
+
if (typeof WS !== 'function') throw new Error('This Node.js has no WebSocket: Node.js 22 or newer is needed');
|
|
84
|
+
|
|
85
|
+
const names = new Map();
|
|
86
|
+
const nameOf = async (id) => {
|
|
87
|
+
if (!id) return undefined;
|
|
88
|
+
if (!names.has(id)) {
|
|
89
|
+
let name;
|
|
90
|
+
try {
|
|
91
|
+
name = displayName((await client.call('POST', '/users/query', {query: {ids: [id]}}, {timeoutMs: 10_000}))?.users?.[0]);
|
|
92
|
+
} catch {
|
|
93
|
+
// a name is a nicety: the id is printed anyway
|
|
94
|
+
}
|
|
95
|
+
names.set(id, name);
|
|
96
|
+
}
|
|
97
|
+
return names.get(id);
|
|
98
|
+
};
|
|
99
|
+
const filters = {channels, senders, types, mine, me};
|
|
100
|
+
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
let ws;
|
|
103
|
+
let stopping = false;
|
|
104
|
+
let attempts = 0;
|
|
105
|
+
let renewedFor; // the token a "token" reconnection was made for: once per token, then the usual waits
|
|
106
|
+
let ready = false;
|
|
107
|
+
let left = count;
|
|
108
|
+
let keepalive;
|
|
109
|
+
let retry;
|
|
110
|
+
let deadline;
|
|
111
|
+
let queue = Promise.resolve();
|
|
112
|
+
|
|
113
|
+
const finish = (error) => {
|
|
114
|
+
if (stopping) return;
|
|
115
|
+
stopping = true;
|
|
116
|
+
clearInterval(keepalive);
|
|
117
|
+
clearTimeout(retry);
|
|
118
|
+
clearTimeout(deadline);
|
|
119
|
+
ctx.signal?.removeEventListener('abort', onAbort);
|
|
120
|
+
try {
|
|
121
|
+
ws?.close(1000);
|
|
122
|
+
} catch {
|
|
123
|
+
// closing a socket that is gone
|
|
124
|
+
}
|
|
125
|
+
queue.then(() => {
|
|
126
|
+
if (error) return reject(error);
|
|
127
|
+
ctx.emit({event: 'END', reason: 'stopped'});
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const onAbort = () => finish();
|
|
132
|
+
if (ctx.signal?.aborted) return finish();
|
|
133
|
+
ctx.signal?.addEventListener('abort', onAbort, {once: true});
|
|
134
|
+
if (seconds > 0) deadline = setTimeout(() => finish(), seconds * 1000);
|
|
135
|
+
|
|
136
|
+
const connect = async () => {
|
|
137
|
+
if (stopping) return;
|
|
138
|
+
if (ws) {
|
|
139
|
+
token = await ctx.freshToken(); // renewed when it ended: the service checks it only when a connection opens
|
|
140
|
+
client = streamClient({token, env: ctx.env, fetchImpl: ctx.fetch});
|
|
141
|
+
}
|
|
142
|
+
if (stopping) return;
|
|
143
|
+
let opened = false;
|
|
144
|
+
let acked = false;
|
|
145
|
+
let socket;
|
|
146
|
+
try {
|
|
147
|
+
socket = new WS(`${wsBase}/?access_token=${encodeURIComponent(token)}`);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error(`Cannot open the websocket of the chat service (${wsBase})`); // never the address with the token
|
|
150
|
+
}
|
|
151
|
+
ws = socket;
|
|
152
|
+
const subscribe = () => socket.send(JSON.stringify({action: 'subscribe', version: '2', topic: 'chat'}));
|
|
153
|
+
socket.onopen = () => {
|
|
154
|
+
opened = true;
|
|
155
|
+
subscribe();
|
|
156
|
+
clearInterval(keepalive);
|
|
157
|
+
keepalive = setInterval(subscribe, KEEPALIVE_MS); // subscribing again is harmless
|
|
158
|
+
};
|
|
159
|
+
socket.onmessage = (m) => {
|
|
160
|
+
if (stopping || socket !== ws) return;
|
|
161
|
+
let d;
|
|
162
|
+
try {
|
|
163
|
+
d = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString('utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (d?.event === 'subscribed') {
|
|
168
|
+
if (acked) return;
|
|
169
|
+
acked = true;
|
|
170
|
+
attempts = 0;
|
|
171
|
+
const shown = drop({
|
|
172
|
+
channels: channels.size ? [...channels] : undefined,
|
|
173
|
+
from: senders.size ? [...senders] : undefined,
|
|
174
|
+
types: types.size ? [...types] : undefined,
|
|
175
|
+
mine: mine || undefined,
|
|
176
|
+
});
|
|
177
|
+
const event = ready ? 'RECONNECTED' : 'READY';
|
|
178
|
+
ready = true;
|
|
179
|
+
queue = queue.then(() => !stopping && ctx.emit({event, user: me, ...shown}));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const e = d?.data;
|
|
183
|
+
if (d?.event !== 'broadcast' || typeof e?.eventType !== 'string' || !wanted(e, filters)) return;
|
|
184
|
+
queue = queue.then(async () => {
|
|
185
|
+
if (stopping) return;
|
|
186
|
+
ctx.emit(raw ? {event: e.eventType, ...e} : await view(e, nameOf));
|
|
187
|
+
if (left > 0 && --left === 0) finish();
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
socket.onerror = () => {
|
|
191
|
+
// onclose follows and decides
|
|
192
|
+
};
|
|
193
|
+
socket.onclose = (ev) => {
|
|
194
|
+
if (socket !== ws) return;
|
|
195
|
+
clearInterval(keepalive);
|
|
196
|
+
if (stopping) return;
|
|
197
|
+
const again = () => connect().catch((err) => finish(err));
|
|
198
|
+
if (tokenExp(token) && tokenExp(token) - ctx.clock() < 2 * 60 * 1000 && renewedFor !== token) {
|
|
199
|
+
renewedFor = token;
|
|
200
|
+
queue = queue.then(() => !stopping && ctx.emit({event: 'RECONNECTING', reason: 'token'}));
|
|
201
|
+
again();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
attempts += 1;
|
|
205
|
+
if (!opened && attempts >= 5) {
|
|
206
|
+
finish(new AuthError(`The chat service did not accept the connection (code ${ev?.code}): log in again (wx login) if this repeats`));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const wait = BACKOFF[attempts - 1] ?? 30;
|
|
210
|
+
queue = queue.then(() => !stopping && ctx.emit({event: 'RECONNECTING', code: ev?.code, attempt: attempts, inSeconds: wait}));
|
|
211
|
+
retry = setTimeout(again, wait * 1000);
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
connect().catch((err) => finish(err));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// One event as a line of text.
|
|
219
|
+
export function eventText(e, ctx) {
|
|
220
|
+
const c = paint(ctx);
|
|
221
|
+
const at = c.dim(localTime(ctx?.clock ? ctx.clock() : Date.now(), {seconds: true}));
|
|
222
|
+
const where = e.channel ? c.bold(oneLine(e.channel, 40)) : e.channelId ?? '';
|
|
223
|
+
const who = (p) => (p ? p.name ?? p.id : '?');
|
|
224
|
+
switch (true) {
|
|
225
|
+
case e.event === 'READY':
|
|
226
|
+
return `${at} ${c.green('watching the chats')}${e.types ? ` (${e.types.join(', ')})` : ''}${e.channels ? ` in ${e.channels.join(', ')}` : ''}${e.from ? ` from ${e.from.join(', ')}` : ''}. Ctrl-C stops.`;
|
|
227
|
+
case e.event === 'RECONNECTING':
|
|
228
|
+
return `${at} ${c.yellow(e.reason === 'token' ? 'reconnecting (renewing the login)' : `reconnecting in ${e.inSeconds}s (code ${e.code ?? '?'})`)}`;
|
|
229
|
+
case e.event === 'RECONNECTED':
|
|
230
|
+
return `${at} ${c.green('reconnected')} (events of the pause are not sent again)`;
|
|
231
|
+
case e.event === 'END':
|
|
232
|
+
return `${at} stopped`;
|
|
233
|
+
case e.event?.startsWith('message.'): {
|
|
234
|
+
const files = (e.attachments ?? []).map((f) => ` [file: ${oneLine(f.name, 60)}]`).join('');
|
|
235
|
+
const text = e.event === 'message.deleted' ? '(deleted)' : `${oneLine(e.text ?? '', 500)}${files}`;
|
|
236
|
+
return `${at} ${e.event.padEnd(15)} ${where} ${c.bold(who(e.from))}: ${text}`;
|
|
237
|
+
}
|
|
238
|
+
case e.event?.startsWith('reaction.'): {
|
|
239
|
+
const counts = Object.entries(e.reactions ?? {}).map(([t, n]) => `${t} ${n}`).join(', ');
|
|
240
|
+
return `${at} ${e.event.padEnd(15)} ${where} on a message of ${who(e.messageAuthor)}: ${counts ? `[${counts}]` : '(none)'}`;
|
|
241
|
+
}
|
|
242
|
+
case e.event?.startsWith('read.'):
|
|
243
|
+
return `${at} ${e.event.padEnd(15)} ${where} ${who(e.reader)} read up to #${e.readUpTo ?? '?'}`;
|
|
244
|
+
case e.event?.startsWith('member.'):
|
|
245
|
+
return `${at} ${e.event.padEnd(15)} ${where} ${e.member?.name ?? e.member?.userId ?? '?'}${e.member?.role ? ` (${e.member.role})` : ''}`;
|
|
246
|
+
case e.event?.startsWith('typing.'):
|
|
247
|
+
return `${at} ${e.event.padEnd(15)} ${where} ${who(e.typing)}`;
|
|
248
|
+
default:
|
|
249
|
+
return `${at} ${String(e.event ?? '').padEnd(15)} ${where}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,29 +1,96 @@
|
|
|
1
|
-
// wx <group> <command> [options]
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// The router: `wx <group> <command> [options]` and `wx <command> [options]`. The commands are listed in
|
|
2
|
+
// src/commands/index.mjs. What run() returns is printed on stdout: as text by default (the command's text()
|
|
3
|
+
// formatter, else toText()), as JSON with --output json (--pretty indents it). A streaming command prints one
|
|
4
|
+
// line per event with ctx.emit() (text, or one JSON object per line) and returns nothing. Like the internal Wildix
|
|
5
|
+
// CLI, --output, --pretty and --no-color may stand anywhere on the command line.
|
|
6
|
+
//
|
|
7
|
+
// The context of run():
|
|
8
|
+
// values, positionals parsed options and arguments
|
|
9
|
+
// token a valid IdToken when the command needs auth (undefined otherwise)
|
|
10
|
+
// freshToken() a valid IdToken again (renewed when it expired): for long-running commands
|
|
11
|
+
// profile the --profile value (or undefined)
|
|
12
|
+
// client(factory) factory({token, env, fetchImpl}): a service client bound to the token
|
|
13
|
+
// emit(data) print one JSON line on stdout now (streaming commands)
|
|
14
|
+
// info(text) print a line for the person on stderr (progress, prompts); never data
|
|
15
|
+
// signal aborted on Ctrl-C / SIGTERM: long-running commands stop and return
|
|
16
|
+
// output ('text'|'json'), color (colors allowed)
|
|
17
|
+
// env, stdin, stdout, stderr, fetch, WebSocket, now (ms at the start), clock() (ms now), cwd
|
|
5
18
|
import path from 'node:path';
|
|
19
|
+
import {parseArgs} from 'node:util';
|
|
6
20
|
import {getToken} from './auth.mjs';
|
|
7
|
-
import {COMMON_OPTIONS,
|
|
21
|
+
import {COMMON_OPTIONS, GLOBAL_OPTIONS, REGISTRY, commandOf, groupOf, singleOf} from './commands/index.mjs';
|
|
8
22
|
import {SHELLS, TOP_LEVEL, complete, completionScript, completionUsage} from './completion.mjs';
|
|
9
23
|
import {AuthError, UsageError} from './errors.mjs';
|
|
24
|
+
import {clean, toText} from './format.mjs';
|
|
10
25
|
import {VERSION} from './version.mjs';
|
|
11
26
|
|
|
27
|
+
const OUTPUTS = GLOBAL_OPTIONS.output.choices;
|
|
28
|
+
|
|
29
|
+
// The value of an option given as `--name value` or `--name=value` at argv[i]; [value, next index] or undefined.
|
|
30
|
+
function valueOf(argv, i, names) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
if (a === `--${name}`) return [argv[i + 1], i + 1, name];
|
|
34
|
+
if (a.startsWith(`--${name}=`)) return [a.slice(name.length + 3), i, name];
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Takes --output, --pretty, --no-color and --profile out of the arguments, wherever they stand (up to a "--").
|
|
40
|
+
export function takeGlobals(argv) {
|
|
41
|
+
const g = {output: 'text', pretty: false, color: true, profile: undefined};
|
|
42
|
+
const rest = [];
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (a === '--') {
|
|
46
|
+
rest.push(...argv.slice(i));
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const profile = valueOf(argv, i, ['profile']);
|
|
50
|
+
if (profile) {
|
|
51
|
+
const [value, next, name] = profile;
|
|
52
|
+
if (!value || value.startsWith('--')) throw new UsageError(`--${name} needs a profile: the e-mail of a login`);
|
|
53
|
+
if (g.profile !== undefined && g.profile.toLowerCase() !== value.toLowerCase()) throw new UsageError(`Two profiles given: ${g.profile} and ${value}`);
|
|
54
|
+
g.profile = value;
|
|
55
|
+
i = next;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a === '--output' || a.startsWith('--output=')) {
|
|
59
|
+
g.output = a === '--output' ? argv[(i += 1)] : a.slice('--output='.length);
|
|
60
|
+
if (!OUTPUTS.includes(g.output)) throw new UsageError(`--output must be one of ${OUTPUTS.join(', ')}${g.output === undefined ? '' : `: ${g.output}`}`);
|
|
61
|
+
} else if (a === '--pretty') g.pretty = true;
|
|
62
|
+
else if (a === '--no-color') g.color = false;
|
|
63
|
+
else rest.push(a);
|
|
64
|
+
}
|
|
65
|
+
return {globals: g, rest};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function render(data, formatter, ctx, pretty) {
|
|
69
|
+
if (ctx.output === 'json') return JSON.stringify(data, null, pretty ? 2 : 0);
|
|
70
|
+
const t = formatter ? formatter(data, ctx) : toText(data);
|
|
71
|
+
return clean(Array.isArray(t) ? t.join('\n') : String(t ?? ''));
|
|
72
|
+
}
|
|
73
|
+
|
|
12
74
|
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
|
|
13
75
|
|
|
14
|
-
function mainUsage() {
|
|
76
|
+
function mainUsage(registry) {
|
|
15
77
|
return [
|
|
16
78
|
'Usage: wx <command> [options]',
|
|
17
79
|
'',
|
|
18
80
|
'Commands:',
|
|
19
|
-
...Object.entries(
|
|
20
|
-
...Object.entries(
|
|
81
|
+
...Object.entries(registry.groups).map(([name, g]) => ` ${pad(name, 14)}${g.summary}`),
|
|
82
|
+
...Object.entries(registry.commands).map(([name, c]) => ` ${pad(name, 14)}${c.summary}`),
|
|
83
|
+
...Object.entries(TOP_LEVEL).map(([name, summary]) => ` ${pad(name, 14)}${summary}`),
|
|
21
84
|
'',
|
|
22
85
|
'Options:',
|
|
23
|
-
' --
|
|
24
|
-
'
|
|
25
|
-
' -
|
|
86
|
+
' --output text|json text for people (default), JSON for scripts and agents',
|
|
87
|
+
' --pretty indent the JSON',
|
|
88
|
+
' --no-color no colors in the text',
|
|
89
|
+
' --profile EMAIL the profile (a logged-in e-mail) to use; else WILDIX_EMAIL, else the default',
|
|
90
|
+
' -h, --help help of wx, a command or a subcommand',
|
|
91
|
+
' -v, --version the version of wx',
|
|
26
92
|
'',
|
|
93
|
+
'Exit codes: 0 done, 1 error, 2 not logged in, 3 nothing to show (no transcript), 64 wrong usage.',
|
|
27
94
|
'Without installing: npx wldx-cli <command>',
|
|
28
95
|
].join('\n');
|
|
29
96
|
}
|
|
@@ -33,21 +100,19 @@ function groupUsage(name, g) {
|
|
|
33
100
|
}
|
|
34
101
|
|
|
35
102
|
function commandUsage(cmd) {
|
|
36
|
-
const options = Object.entries({...cmd.options, ...COMMON_OPTIONS}).map(([name, o]) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
103
|
+
const options = Object.entries({...cmd.options, ...COMMON_OPTIONS, ...GLOBAL_OPTIONS}).map(([name, o]) => {
|
|
104
|
+
const value = o.choices && o.choices.length <= 8 ? o.choices.join('|') : o.placeholder ?? 'VALUE';
|
|
105
|
+
const flag = `${o.short ? `-${o.short}, ` : ''}--${name}${o.type === 'string' ? ` ${value}` : ''}`;
|
|
106
|
+
const values = o.choices && o.choices.length > 8 ? ` (one of: ${o.choices.join(', ')})` : '';
|
|
107
|
+
return ` ${pad(flag, 34)}${o.description ?? ''}${o.multiple ? ' (repeatable)' : ''}${values}`;
|
|
39
108
|
});
|
|
40
|
-
return [`Usage: ${cmd.usage}`, '', cmd.summary, '', 'Options:', ...options].join('\n');
|
|
109
|
+
return [`Usage: ${cmd.usage}`, '', cmd.summary, ...(cmd.details ? ['', cmd.details] : []), '', 'Options:', ...options].join('\n');
|
|
41
110
|
}
|
|
42
111
|
|
|
43
112
|
function line(stream, text) {
|
|
44
113
|
stream.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
45
114
|
}
|
|
46
115
|
|
|
47
|
-
function print(stream, data) {
|
|
48
|
-
line(stream, JSON.stringify(data, null, stream.isTTY ? 2 : 0));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
116
|
function completion(args, io) {
|
|
52
117
|
if (args.includes('-h') || args.includes('--help')) return line(io.stdout, completionUsage());
|
|
53
118
|
if (args.length > 1) throw new UsageError('wx completion takes one shell', completionUsage());
|
|
@@ -57,59 +122,111 @@ function completion(args, io) {
|
|
|
57
122
|
line(io.stdout, script);
|
|
58
123
|
}
|
|
59
124
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!group) throw new UsageError(`Unknown command: ${first}`, mainUsage());
|
|
74
|
-
if (sub === '-h' || sub === '--help') return line(io.stdout, groupUsage(first, group));
|
|
75
|
-
if (!sub) throw new UsageError(`wx ${first} needs a command`, groupUsage(first, group));
|
|
76
|
-
const cmd = commandOf(group, sub);
|
|
77
|
-
if (!cmd) throw new UsageError(`Unknown command: wx ${first} ${sub}`, groupUsage(first, group));
|
|
125
|
+
// Ctrl-C and SIGTERM abort ctx.signal once; a second Ctrl-C leaves it to Node (exit).
|
|
126
|
+
function stopSignal(io) {
|
|
127
|
+
if (io.signal) return {signal: io.signal, dispose() {}};
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const onSignal = () => {
|
|
130
|
+
process.off('SIGINT', onSignal);
|
|
131
|
+
process.off('SIGTERM', onSignal);
|
|
132
|
+
controller.abort();
|
|
133
|
+
};
|
|
134
|
+
process.on('SIGINT', onSignal);
|
|
135
|
+
process.on('SIGTERM', onSignal);
|
|
136
|
+
return {signal: controller.signal, dispose: onSignal};
|
|
137
|
+
}
|
|
78
138
|
|
|
139
|
+
async function runCommand(cmd, args, needsAuth, io) {
|
|
79
140
|
const options = {...COMMON_OPTIONS, ...cmd.options};
|
|
80
141
|
let parsed;
|
|
81
142
|
try {
|
|
82
|
-
parsed = parseArgs({args
|
|
143
|
+
parsed = parseArgs({args, options, allowPositionals: true, strict: true});
|
|
83
144
|
} catch (e) {
|
|
84
145
|
if (String(e.code).startsWith('ERR_PARSE_ARGS')) throw new UsageError(e.message, commandUsage(cmd));
|
|
85
146
|
throw e;
|
|
86
147
|
}
|
|
87
148
|
const {values, positionals} = parsed;
|
|
88
149
|
if (values.help) return line(io.stdout, commandUsage(cmd));
|
|
150
|
+
if (io.globals.profile !== undefined) values.profile = io.globals.profile;
|
|
89
151
|
for (const [name, o] of Object.entries(options)) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
152
|
+
const given = values[name] === undefined ? [] : [values[name]].flat();
|
|
153
|
+
const bad = o.choices && given.find((v) => !o.choices.includes(v));
|
|
154
|
+
if (bad !== undefined) throw new UsageError(`--${name} must be one of ${o.choices.join(', ')}: ${bad}`, commandUsage(cmd));
|
|
93
155
|
}
|
|
94
156
|
const [min, max] = cmd.args;
|
|
95
157
|
if (positionals.length < min || positionals.length > max) {
|
|
96
158
|
throw new UsageError(positionals.length < min ? 'Missing arguments' : `Unexpected argument: ${positionals[max]}`, commandUsage(cmd));
|
|
97
159
|
}
|
|
98
160
|
|
|
99
|
-
const
|
|
161
|
+
const freshToken = () => getToken({profile: values.profile, env: io.env, fetchImpl: io.fetch, now: io.now()});
|
|
162
|
+
const token = needsAuth ? await freshToken() : undefined;
|
|
163
|
+
const stop = stopSignal(io);
|
|
100
164
|
const ctx = {
|
|
101
165
|
values,
|
|
102
166
|
positionals,
|
|
167
|
+
token,
|
|
168
|
+
freshToken,
|
|
169
|
+
profile: values.profile,
|
|
170
|
+
client: (factory) => factory({token, env: io.env, fetchImpl: io.fetch}),
|
|
171
|
+
output: io.globals.output,
|
|
172
|
+
color: io.globals.color && !io.env.NO_COLOR && Boolean(io.stdout.isTTY),
|
|
173
|
+
emit: (data) => {
|
|
174
|
+
const text = io.globals.output === 'json' ? JSON.stringify(data) : render(data, cmd.textEvent ?? cmd.text, ctx);
|
|
175
|
+
if (text) line(io.stdout, text);
|
|
176
|
+
},
|
|
177
|
+
info: (text) => line(io.stderr, clean(text)),
|
|
178
|
+
signal: stop.signal,
|
|
179
|
+
usage: () => commandUsage(cmd),
|
|
103
180
|
env: io.env,
|
|
104
181
|
stdin: io.stdin,
|
|
182
|
+
stdout: io.stdout,
|
|
183
|
+
stderr: io.stderr,
|
|
184
|
+
fetch: io.fetch,
|
|
185
|
+
WebSocket: io.WebSocket, // only when injected (tests); a command falls back to globalThis.WebSocket
|
|
105
186
|
now: io.now(),
|
|
106
|
-
|
|
187
|
+
clock: io.now,
|
|
188
|
+
cwd: io.cwd,
|
|
107
189
|
};
|
|
108
|
-
|
|
109
|
-
|
|
190
|
+
try {
|
|
191
|
+
const result = await cmd.run(ctx);
|
|
192
|
+
if (result !== undefined) {
|
|
193
|
+
const text = render(result, cmd.text, ctx, io.globals.pretty);
|
|
194
|
+
if (text) line(io.stdout, text);
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
stop.dispose();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function dispatch(all, io) {
|
|
202
|
+
const registry = io.registry;
|
|
203
|
+
const {globals, rest: argv} = takeGlobals(all);
|
|
204
|
+
io.globals = globals;
|
|
205
|
+
const [first, sub, ...rest] = argv;
|
|
206
|
+
if (!first || first === '-h' || first === '--help' || first === 'help') return line(io.stdout, mainUsage(registry));
|
|
207
|
+
if (first === '-v' || first === '--version') return line(io.stdout, VERSION);
|
|
208
|
+
if (first === '__complete') {
|
|
209
|
+
const out = complete(argv.slice(sub === '--' ? 2 : 1), io.env, registry);
|
|
210
|
+
if (out.length) line(io.stdout, out.join('\n'));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (first === 'completion') return completion(argv.slice(1), io);
|
|
214
|
+
|
|
215
|
+
const single = singleOf(first, registry);
|
|
216
|
+
if (single) return runCommand(single, argv.slice(1), Boolean(single.auth), io);
|
|
217
|
+
|
|
218
|
+
const group = groupOf(first, registry);
|
|
219
|
+
if (!group) throw new UsageError(`Unknown command: ${first}`, mainUsage(registry));
|
|
220
|
+
if (sub === '-h' || sub === '--help') return line(io.stdout, groupUsage(first, group));
|
|
221
|
+
if (!sub) throw new UsageError(`wx ${first} needs a command`, groupUsage(first, group));
|
|
222
|
+
const cmd = commandOf(group, sub);
|
|
223
|
+
if (!cmd) throw new UsageError(`Unknown command: wx ${first} ${sub}`, groupUsage(first, group));
|
|
224
|
+
return runCommand(cmd, rest, Boolean(cmd.auth ?? group.auth), io);
|
|
110
225
|
}
|
|
111
226
|
|
|
112
|
-
// Runs wx with the arguments and returns the exit code: 0 done, 1 error, 2 not logged in,
|
|
227
|
+
// Runs wx with the arguments and returns the exit code: 0 done, 1 error, 2 not logged in, 3 nothing to show (an
|
|
228
|
+
// error with exitCode 3, e.g. no transcript), 64 wrong usage.
|
|
229
|
+
// io replaces the process streams, environment, fetch, clock and (in tests) the commands themselves.
|
|
113
230
|
export async function main(argv, io = {}) {
|
|
114
231
|
io = {
|
|
115
232
|
stdout: process.stdout,
|
|
@@ -118,18 +235,21 @@ export async function main(argv, io = {}) {
|
|
|
118
235
|
env: process.env,
|
|
119
236
|
fetch: globalThis.fetch,
|
|
120
237
|
now: Date.now,
|
|
238
|
+
cwd: process.cwd(),
|
|
239
|
+
registry: REGISTRY,
|
|
121
240
|
...io,
|
|
122
241
|
};
|
|
123
242
|
try {
|
|
124
243
|
await dispatch(argv, io);
|
|
125
244
|
return 0;
|
|
126
245
|
} catch (e) {
|
|
127
|
-
line(io.stderr, `wx: ${e.message}`);
|
|
246
|
+
line(io.stderr, `wx: ${clean(e.message)}`);
|
|
128
247
|
if (e instanceof UsageError) {
|
|
129
248
|
if (e.usage) line(io.stderr, `\n${e.usage}`);
|
|
130
249
|
return 64;
|
|
131
250
|
}
|
|
132
251
|
if (e instanceof AuthError) return 2;
|
|
252
|
+
if (Number.isInteger(e.exitCode)) return e.exitCode;
|
|
133
253
|
if (io.env.WX_DEBUG === '1' && e.stack) line(io.stderr, e.stack);
|
|
134
254
|
return 1;
|
|
135
255
|
}
|