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.
@@ -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]: the router. Groups are listed in src/commands/index.mjs; a group is
2
- // {summary, auth?, commands: {name: {summary, usage, options, args: [min, max], run(ctx)}}}; `auth: true` loads a
3
- // valid token before the command runs. What run() returns is printed as JSON on stdout.
4
- import {parseArgs} from 'node:util';
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, GROUPS, commandOf, groupOf} from './commands/index.mjs';
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(GROUPS).map(([name, g]) => ` ${pad(name, 12)}${g.summary}`),
20
- ...Object.entries(TOP_LEVEL).map(([name, summary]) => ` ${pad(name, 12)}${summary}`),
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
- ' --account EMAIL the logged-in Wildix account to use (or WILDIX_EMAIL)',
24
- ' -h, --help help of wx, a command or a subcommand',
25
- ' -v, --version the version of wx',
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 flag = `${o.short ? `-${o.short}, ` : ''}--${name}${o.type === 'string' ? ` ${o.choices ? o.choices.join('|') : 'VALUE'}` : ''}`;
38
- return ` ${pad(flag, 34)}${o.description ?? ''}`;
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
- async function dispatch(argv, io) {
61
- const [first, sub, ...rest] = argv;
62
- if (!first || first === '-h' || first === '--help' || first === 'help') return line(io.stdout, mainUsage());
63
- if (first === '-v' || first === '--version') return line(io.stdout, VERSION);
64
- if (first === '__complete') {
65
- const words = argv.slice(sub === '--' ? 2 : 1);
66
- const out = complete(words, io.env);
67
- if (out.length) line(io.stdout, out.join('\n'));
68
- return;
69
- }
70
- if (first === 'completion') return completion(argv.slice(1), io);
71
-
72
- const group = groupOf(first);
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: rest, options, allowPositionals: true, strict: true});
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
- if (o.choices && values[name] !== undefined && !o.choices.includes(values[name])) {
91
- throw new UsageError(`--${name} must be one of ${o.choices.join(', ')}: ${values[name]}`, commandUsage(cmd));
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 token = group.auth ? await getToken({account: values.account, env: io.env, fetchImpl: io.fetch, now: io.now()}) : undefined;
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
- client: (factory) => factory({token, env: io.env, fetchImpl: io.fetch}),
187
+ clock: io.now,
188
+ cwd: io.cwd,
107
189
  };
108
- const result = await cmd.run(ctx);
109
- if (result !== undefined) print(io.stdout, result);
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, 64 wrong usage.
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
  }