wldx-cli 0.1.1 → 1.1.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,210 @@
1
+ // Helpers of the chat commands (wx chat, channel, inbox, file, user, section): the checks of the agent skill's
2
+ // lib.sh (whole numbers in a range, ids, reaction types, secret paths), the user id of the logged-in user, names
3
+ // of users for the text output, and the presigned upload and download of files (no token goes to those URLs).
4
+ import {randomUUID} from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {jwtClaims} from 'wldx-core/auth';
8
+ import {UsageError} from 'wldx-core/errors';
9
+ import {readForUpload, refuseUnsafeWrite} from 'wldx-core/files';
10
+ import {apiPath, request} from 'wldx-core/http';
11
+ import {streamClient} from '../stream.mjs';
12
+
13
+ // The operations the service answers with HTTP 400 "Operation <Name> is not implemented." (checked 2026-11-05).
14
+ export const NOT_IMPLEMENTED_CHECKED = '2026-11-05';
15
+ export const NOT_IMPLEMENTED = ' (not implemented by the service yet)';
16
+ export const notImplemented = (operation) =>
17
+ `The service does not implement ${operation} yet (it answers HTTP 400 "Operation ${operation} is not implemented.", checked ${NOT_IMPLEMENTED_CHECKED}). Do not retry it.`;
18
+
19
+ // A whole number from min to max (the service refuses anything else with a 400 that tells little); undefined stays.
20
+ export function checkInt(name, value, min, max) {
21
+ if (value === undefined) return undefined;
22
+ const s = String(value);
23
+ if (!/^\d{1,12}$/.test(s) || Number(s) < min || Number(s) > max)
24
+ throw new UsageError(`--${name} must be a whole number from ${min} to ${max}: ${value}`);
25
+ return Number(s);
26
+ }
27
+
28
+ // An id that goes into a request path or a body: id characters only, never "/", "?", "#", ".." or nothing.
29
+ export function checkId(what, value) {
30
+ const v = String(value ?? '');
31
+ if (!/^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/.test(v) || v.includes('..'))
32
+ throw new UsageError(`${what} is not a valid id: ${value}`);
33
+ return v;
34
+ }
35
+
36
+ export function checkReaction(type) {
37
+ if (!/^[a-z0-9_+-]{1,64}$/.test(String(type)))
38
+ throw new UsageError(`A reaction type must match [a-z0-9_+-] (up to 64): ${type}`);
39
+ return type;
40
+ }
41
+
42
+ // The id of a new group channel: 8-4-4-4-8 characters. Without one the service makes a 36-character id that none
43
+ // of its own calls accepts afterwards.
44
+ export const CHANNEL_ID = /^[0-9a-z_]{8}-[0-9a-z_]{4}-[0-9a-z_]{4}-[0-9a-z_]{4}-[0-9a-z_]{8}$/;
45
+ export const newChannelId = () => randomUUID().slice(0, -4);
46
+
47
+ // The text argument; "-" reads stdin.
48
+ export async function readText(arg, stdin) {
49
+ if (arg !== '-') return arg;
50
+ const chunks = [];
51
+ for await (const c of stdin) chunks.push(Buffer.from(c));
52
+ return Buffer.concat(chunks).toString('utf8');
53
+ }
54
+
55
+ export const api = (ctx) => ctx.client(streamClient);
56
+
57
+ // The stream user id of the logged-in user, found by the e-mail of the token (once per context).
58
+ const ME = Symbol('me');
59
+ export async function meId(ctx, client = api(ctx)) {
60
+ if (ctx[ME]) return ctx[ME];
61
+ const email = jwtClaims(ctx.token).email;
62
+ if (!email) throw new Error('The login token carries no e-mail');
63
+ const r = await client.call('POST', '/users/query', {query: {emails: [email]}});
64
+ const id = r?.users?.[0]?.id;
65
+ if (!id) throw new Error('Could not find your chat user');
66
+ ctx[ME] = id;
67
+ return id;
68
+ }
69
+
70
+ export const displayName = (u) => u?.displayName ?? u?.name;
71
+
72
+ // The users of these ids, one users query per 100 ids: Map id -> user. A lookup that fails gives no users (a name
73
+ // is a nicety of the text output: the id is printed instead).
74
+ export async function usersById(client, ids) {
75
+ const unique = [...new Set(ids.filter((id) => typeof id === 'string' && id))];
76
+ const out = new Map();
77
+ for (let i = 0; i < unique.length; i += 100) {
78
+ try {
79
+ const r = await client.call('POST', '/users/query', {query: {ids: unique.slice(i, i + 100)}});
80
+ for (const u of r?.users ?? []) out.set(u.id, u);
81
+ } catch {
82
+ // names stay ids
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Names for the text output ride on the result under a symbol: JSON output (JSON.stringify) never sees them.
89
+ export const NAMES = Symbol('names');
90
+ export async function withNames(ctx, data, ids, client = api(ctx)) {
91
+ if (ctx.output !== 'text' || !data || typeof data !== 'object') return data;
92
+ const users = await usersById(client, ids);
93
+ Object.defineProperty(data, NAMES, {value: users, enumerable: false});
94
+ return data;
95
+ }
96
+ export const nameIn = (data, id) => (id ? (displayName(data?.[NAMES]?.get(id)) ?? id) : '');
97
+
98
+ // The ids of people a message names: its author, the author of its quote.
99
+ export const messageUserIds = (m) => [m?.userId, m?.quote?.userId, m?.pinnedBy];
100
+
101
+ // ── files ───────────────────────────────────────────────────────────────────────────────────────────────────
102
+
103
+ // The checks of files to attach and to save: src/files.mjs.
104
+ export {refuseSecretPath} from 'wldx-core/files';
105
+
106
+ const MIME = {
107
+ txt: 'text/plain',
108
+ md: 'text/markdown',
109
+ csv: 'text/csv',
110
+ html: 'text/html',
111
+ htm: 'text/html',
112
+ css: 'text/css',
113
+ js: 'text/javascript',
114
+ json: 'application/json',
115
+ xml: 'application/xml',
116
+ pdf: 'application/pdf',
117
+ zip: 'application/zip',
118
+ gz: 'application/gzip',
119
+ tar: 'application/x-tar',
120
+ doc: 'application/msword',
121
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
122
+ xls: 'application/vnd.ms-excel',
123
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
124
+ ppt: 'application/vnd.ms-powerpoint',
125
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
126
+ png: 'image/png',
127
+ jpg: 'image/jpeg',
128
+ jpeg: 'image/jpeg',
129
+ gif: 'image/gif',
130
+ webp: 'image/webp',
131
+ svg: 'image/svg+xml',
132
+ heic: 'image/heic',
133
+ mp3: 'audio/mpeg',
134
+ wav: 'audio/wav',
135
+ ogg: 'audio/ogg',
136
+ m4a: 'audio/mp4',
137
+ mp4: 'video/mp4',
138
+ mov: 'video/quicktime',
139
+ webm: 'video/webm',
140
+ };
141
+ export const mimeOf = (file) => MIME[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
142
+
143
+ // A presigned URL of the storage: https (http only with WILDIX_ALLOW_CUSTOM_URL=1, tests). It gets no token.
144
+ function presigned(url, env) {
145
+ let u;
146
+ try {
147
+ u = new URL(url);
148
+ } catch {
149
+ throw new Error('The service gave no valid storage link');
150
+ }
151
+ if (u.protocol !== 'https:' && !(env.WILDIX_ALLOW_CUSTOM_URL === '1' && u.protocol === 'http:'))
152
+ throw new Error(`The storage link is not https: ${u.protocol}`);
153
+ return u.href;
154
+ }
155
+
156
+ const ATTACHMENT_FIELDS = ['fsId', 'name', 'size', 'mime', 'width', 'height', 'thumbnail'];
157
+ export const attachmentOf = (f) =>
158
+ Object.fromEntries(ATTACHMENT_FIELDS.filter((k) => f?.[k] !== undefined && f?.[k] !== null).map((k) => [k, f[k]]));
159
+
160
+ // Uploads a file to a channel and returns the attachment object to put into a message (fsId, name, size, mime…).
161
+ export async function uploadFile(ctx, channel, file, client = api(ctx)) {
162
+ const data = readForUpload(path.resolve(ctx.cwd ?? process.cwd(), file), ctx.env);
163
+ const name = path.basename(file);
164
+ const r = await client.call('POST', apiPath`/channels/${channel}/files`, {name});
165
+ if (!r?.fsId || !r?.presignedUploadUrl) throw new Error('Unexpected answer of the upload: no fsId or link');
166
+ const fsId = checkId('The file id of the answer', r.fsId);
167
+ await request(presigned(r.presignedUploadUrl, ctx.env), {
168
+ method: 'PUT',
169
+ raw: {data, contentType: mimeOf(file)},
170
+ responseType: 'text',
171
+ fetchImpl: ctx.fetch,
172
+ timeoutMs: 600_000,
173
+ service: 'the storage',
174
+ });
175
+ const info = await client.call('GET', apiPath`/channels/${channel}/files/${fsId}`);
176
+ return attachmentOf(info?.file ?? {fsId, name, size: data.length});
177
+ }
178
+
179
+ // Saves a file of a message to `to`, which must not exist yet (nothing is ever overwritten).
180
+ export async function downloadFile(ctx, channel, message, fsId, to, client = api(ctx)) {
181
+ const out = path.resolve(ctx.cwd ?? process.cwd(), to);
182
+ refuseUnsafeWrite(out, ctx.env);
183
+ if (fs.existsSync(out))
184
+ throw new UsageError(`${to} exists already: choose another path (nothing was overwritten or deleted)`);
185
+ const dir = path.dirname(out);
186
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
187
+ throw new UsageError(`The folder of ${to} does not exist`);
188
+ const r = await client.call(
189
+ 'GET',
190
+ apiPath`/channels/${channel}/messages/${message}/files/${fsId}/download-url` + '?download=1',
191
+ );
192
+ if (!r?.presignedDownloadUrl) throw new Error('No download link in the answer');
193
+ const data = await request(presigned(r.presignedDownloadUrl, ctx.env), {
194
+ responseType: 'buffer',
195
+ fetchImpl: ctx.fetch,
196
+ timeoutMs: 600_000,
197
+ service: 'the storage',
198
+ });
199
+ const tmp = path.join(dir, `.download.${process.pid}.${randomUUID().slice(0, 8)}`);
200
+ fs.writeFileSync(tmp, data, {flag: 'wx', mode: 0o600});
201
+ try {
202
+ fs.linkSync(tmp, out); // fails when the path appeared meanwhile: never overwrites
203
+ } catch (e) {
204
+ if (e.code === 'EEXIST') throw new UsageError(`${to} appeared meanwhile: nothing was overwritten`);
205
+ throw e;
206
+ } finally {
207
+ fs.rmSync(tmp, {force: true});
208
+ }
209
+ return {ok: true, file: out, bytes: data.length};
210
+ }
@@ -0,0 +1,282 @@
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 'wldx-core/auth';
7
+ import {AuthError} from 'wldx-core/errors';
8
+ import {localTime, oneLine, paint} from 'wldx-core/format';
9
+ import {streamWsUrl} from 'wldx-core/services';
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',
20
+ 'channel.updated',
21
+ 'channel.truncated',
22
+ 'channel.added',
23
+ 'channel.removed',
24
+ 'channel.hidden',
25
+ 'channel.visible',
26
+ 'channel.muted',
27
+ 'channel.unmuted',
28
+ 'channel.pinned',
29
+ 'channel.unpinned',
30
+ ],
31
+ typing: ['typing.start', 'typing.stop'],
32
+ };
33
+ export const EVENT_CHOICES = [...Object.keys(EVENT_GROUPS), ...Object.values(EVENT_GROUPS).flat()];
34
+
35
+ const KEEPALIVE_MS = 4 * 60 * 1000; // the gateway drops a connection that is quiet for 10 minutes
36
+ const BACKOFF = [1, 2, 5, 10]; // seconds, then 30
37
+ const drop = (o) => JSON.parse(JSON.stringify(o)); // removes the undefined fields
38
+ const unreadOf = (counters) =>
39
+ counters ? Object.values(counters).reduce((n, c) => n + (c?.unmutedUnreadMessageCount ?? 0), 0) : undefined;
40
+
41
+ // Whether an event passes the filters. A reaction event does not say who reacted (its message is someone else's
42
+ // work), so a reaction has no author: it is never taken for the user's own action, and --from leaves it out.
43
+ export function wanted(e, {channels, senders, types, mine, me}) {
44
+ if (types.size && !types.has(e.eventType)) return false;
45
+ if (channels.size && !channels.has(e.channelId)) return false;
46
+ const isReaction = e.eventType.startsWith('reaction.');
47
+ const author = isReaction ? undefined : (e.message?.userId ?? e.readerUserId ?? e.typingUserId);
48
+ if (isReaction && senders.size) return false;
49
+ if (senders.size && !senders.has(author)) return false;
50
+ if (!mine) {
51
+ if (e.eventType === 'read.cursor.updated') return false; // always the user's own reading
52
+ if (author === me) return false;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ // The short form of an event (the fields of the skill's watch.mjs).
58
+ export async function view(e, nameOf) {
59
+ const person = async (id) => (id ? {id, name: await nameOf(id)} : undefined);
60
+ const m = e.message;
61
+ const isReaction = e.eventType.startsWith('reaction.');
62
+ return drop({
63
+ event: e.eventType,
64
+ channelId: e.channelId,
65
+ channel: e.inboxChannel?.subject,
66
+ channelType: e.inboxChannel?.channelType,
67
+ messageId: e.messageId,
68
+ seq: e.timelineSeq,
69
+ from: isReaction ? undefined : await person(m?.userId),
70
+ messageAuthor: isReaction ? await person(m?.userId) : undefined,
71
+ text: m?.text,
72
+ attachments: m?.attachments?.length ? m.attachments.map((f) => drop({name: f.name, mime: f.mime})) : undefined,
73
+ mentions: m?.mentions?.length ? m.mentions : undefined,
74
+ system: m?.system || undefined,
75
+ reactions: isReaction ? m?.reactionCounts : undefined,
76
+ reader: await person(e.readerUserId),
77
+ readUpTo: e.lastReadVisibleSeq,
78
+ member: e.member ? {...e.member, name: await nameOf(e.member.userId)} : undefined,
79
+ typing: await person(e.typingUserId),
80
+ unread: unreadOf(e.sectionCounters),
81
+ });
82
+ }
83
+
84
+ const tokenExp = (token) => (Number.isFinite(jwtClaims(token).exp) ? jwtClaims(token).exp * 1000 : 0);
85
+
86
+ // Runs until ctx.signal, `count` events (0: no limit) or `seconds` (0: no limit). Resolves when it stops.
87
+ export async function watch(
88
+ ctx,
89
+ {
90
+ channels = new Set(),
91
+ senders = new Set(),
92
+ types = new Set(),
93
+ mine = false,
94
+ raw = false,
95
+ count = 0,
96
+ seconds = 0,
97
+ } = {},
98
+ ) {
99
+ let token = ctx.token;
100
+ let client = streamClient({token, env: ctx.env, fetchImpl: ctx.fetch});
101
+ const me = await meId(ctx, client);
102
+ const wsBase = streamWsUrl(ctx.env);
103
+ const WS = globalThis.WebSocket;
104
+ if (typeof WS !== 'function') throw new Error('This Node.js has no WebSocket: Node.js 22 or newer is needed');
105
+
106
+ const names = new Map();
107
+ const nameOf = async (id) => {
108
+ if (!id) return undefined;
109
+ if (!names.has(id)) {
110
+ let name;
111
+ try {
112
+ name = displayName(
113
+ (await client.call('POST', '/users/query', {query: {ids: [id]}}, {timeoutMs: 10_000}))?.users?.[0],
114
+ );
115
+ } catch {
116
+ // a name is a nicety: the id is printed anyway
117
+ }
118
+ names.set(id, name);
119
+ }
120
+ return names.get(id);
121
+ };
122
+ const filters = {channels, senders, types, mine, me};
123
+
124
+ return new Promise((resolve, reject) => {
125
+ let ws;
126
+ let stopping = false;
127
+ let attempts = 0;
128
+ let renewedFor; // the token a "token" reconnection was made for: once per token, then the usual waits
129
+ let ready = false;
130
+ let left = count;
131
+ let keepalive;
132
+ let retry;
133
+ let deadline;
134
+ let queue = Promise.resolve();
135
+
136
+ const finish = (error) => {
137
+ if (stopping) return;
138
+ stopping = true;
139
+ clearInterval(keepalive);
140
+ clearTimeout(retry);
141
+ clearTimeout(deadline);
142
+ ctx.signal?.removeEventListener('abort', onAbort);
143
+ try {
144
+ ws?.close(1000);
145
+ } catch {
146
+ // closing a socket that is gone
147
+ }
148
+ queue.then(() => {
149
+ if (error) return reject(error);
150
+ ctx.emit({event: 'END', reason: 'stopped'});
151
+ resolve();
152
+ });
153
+ };
154
+ const onAbort = () => finish();
155
+ if (ctx.signal?.aborted) return finish();
156
+ ctx.signal?.addEventListener('abort', onAbort, {once: true});
157
+ if (seconds > 0) deadline = setTimeout(() => finish(), seconds * 1000);
158
+
159
+ const connect = async () => {
160
+ if (stopping) return;
161
+ if (ws) {
162
+ token = await ctx.freshToken(); // renewed when it ended: the service checks it only when a connection opens
163
+ client = streamClient({token, env: ctx.env, fetchImpl: ctx.fetch});
164
+ }
165
+ if (stopping) return;
166
+ let opened = false;
167
+ let acked = false;
168
+ let socket;
169
+ try {
170
+ socket = new WS(`${wsBase}/?access_token=${encodeURIComponent(token)}`);
171
+ } catch {
172
+ throw new Error(`Cannot open the websocket of the chat service (${wsBase})`); // never the address with the token
173
+ }
174
+ ws = socket;
175
+ const subscribe = () => socket.send(JSON.stringify({action: 'subscribe', version: '2', topic: 'chat'}));
176
+ socket.onopen = () => {
177
+ opened = true;
178
+ subscribe();
179
+ clearInterval(keepalive);
180
+ keepalive = setInterval(subscribe, KEEPALIVE_MS); // subscribing again is harmless
181
+ };
182
+ socket.onmessage = (m) => {
183
+ if (stopping || socket !== ws) return;
184
+ let d;
185
+ try {
186
+ d = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString('utf8'));
187
+ } catch {
188
+ return;
189
+ }
190
+ if (d?.event === 'subscribed') {
191
+ if (acked) return;
192
+ acked = true;
193
+ attempts = 0;
194
+ const shown = drop({
195
+ channels: channels.size ? [...channels] : undefined,
196
+ from: senders.size ? [...senders] : undefined,
197
+ types: types.size ? [...types] : undefined,
198
+ mine: mine || undefined,
199
+ });
200
+ const event = ready ? 'RECONNECTED' : 'READY';
201
+ ready = true;
202
+ queue = queue.then(() => !stopping && ctx.emit({event, user: me, ...shown}));
203
+ return;
204
+ }
205
+ const e = d?.data;
206
+ if (d?.event !== 'broadcast' || typeof e?.eventType !== 'string' || !wanted(e, filters)) return;
207
+ queue = queue.then(async () => {
208
+ if (stopping) return;
209
+ ctx.emit(raw ? {event: e.eventType, ...e} : await view(e, nameOf));
210
+ if (left > 0 && --left === 0) finish();
211
+ });
212
+ };
213
+ socket.onerror = () => {
214
+ // onclose follows and decides
215
+ };
216
+ socket.onclose = (ev) => {
217
+ if (socket !== ws) return;
218
+ clearInterval(keepalive);
219
+ if (stopping) return;
220
+ const again = () => connect().catch((err) => finish(err));
221
+ if (tokenExp(token) && tokenExp(token) - ctx.clock() < 2 * 60 * 1000 && renewedFor !== token) {
222
+ renewedFor = token;
223
+ queue = queue.then(() => !stopping && ctx.emit({event: 'RECONNECTING', reason: 'token'}));
224
+ again();
225
+ return;
226
+ }
227
+ attempts += 1;
228
+ if (!opened && attempts >= 5) {
229
+ finish(
230
+ new AuthError(
231
+ `The chat service did not accept the connection (code ${ev?.code}): log in again (wx login) if this repeats`,
232
+ ),
233
+ );
234
+ return;
235
+ }
236
+ const wait = BACKOFF[attempts - 1] ?? 30;
237
+ queue = queue.then(
238
+ () => !stopping && ctx.emit({event: 'RECONNECTING', code: ev?.code, attempt: attempts, inSeconds: wait}),
239
+ );
240
+ retry = setTimeout(again, wait * 1000);
241
+ };
242
+ };
243
+ connect().catch((err) => finish(err));
244
+ });
245
+ }
246
+
247
+ // One event as a line of text.
248
+ export function eventText(e, ctx) {
249
+ const c = paint(ctx);
250
+ const at = c.dim(localTime(ctx?.clock ? ctx.clock() : Date.now(), {seconds: true}));
251
+ const where = e.channel ? c.bold(oneLine(e.channel, 40)) : (e.channelId ?? '');
252
+ const who = (p) => (p ? (p.name ?? p.id) : '?');
253
+ switch (true) {
254
+ case e.event === 'READY':
255
+ 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.`;
256
+ case e.event === 'RECONNECTING':
257
+ return `${at} ${c.yellow(e.reason === 'token' ? 'reconnecting (renewing the login)' : `reconnecting in ${e.inSeconds}s (code ${e.code ?? '?'})`)}`;
258
+ case e.event === 'RECONNECTED':
259
+ return `${at} ${c.green('reconnected')} (events of the pause are not sent again)`;
260
+ case e.event === 'END':
261
+ return `${at} stopped`;
262
+ case e.event?.startsWith('message.'): {
263
+ const files = (e.attachments ?? []).map((f) => ` [file: ${oneLine(f.name, 60)}]`).join('');
264
+ const text = e.event === 'message.deleted' ? '(deleted)' : `${oneLine(e.text ?? '', 500)}${files}`;
265
+ return `${at} ${e.event.padEnd(15)} ${where} ${c.bold(who(e.from))}: ${text}`;
266
+ }
267
+ case e.event?.startsWith('reaction.'): {
268
+ const counts = Object.entries(e.reactions ?? {})
269
+ .map(([t, n]) => `${t} ${n}`)
270
+ .join(', ');
271
+ return `${at} ${e.event.padEnd(15)} ${where} on a message of ${who(e.messageAuthor)}: ${counts ? `[${counts}]` : '(none)'}`;
272
+ }
273
+ case e.event?.startsWith('read.'):
274
+ return `${at} ${e.event.padEnd(15)} ${where} ${who(e.reader)} read up to #${e.readUpTo ?? '?'}`;
275
+ case e.event?.startsWith('member.'):
276
+ return `${at} ${e.event.padEnd(15)} ${where} ${e.member?.name ?? e.member?.userId ?? '?'}${e.member?.role ? ` (${e.member.role})` : ''}`;
277
+ case e.event?.startsWith('typing.'):
278
+ return `${at} ${e.event.padEnd(15)} ${where} ${who(e.typing)}`;
279
+ default:
280
+ return `${at} ${String(e.event ?? '').padEnd(15)} ${where}`;
281
+ }
282
+ }
package/src/cli.mjs CHANGED
@@ -1,136 +1,17 @@
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';
5
- import path from 'node:path';
6
- import {getToken} from './auth.mjs';
7
- import {COMMON_OPTIONS, GROUPS, commandOf, groupOf} from './commands/index.mjs';
8
- import {SHELLS, TOP_LEVEL, complete, completionScript, completionUsage} from './completion.mjs';
9
- import {AuthError, UsageError} from './errors.mjs';
1
+ // wx: the spec of the command line (its commands, its lines in the help) on the router of wldx-core.
2
+ import {createMain} from 'wldx-core/cli';
3
+ import {complete as completeOf} from 'wldx-core/completion';
4
+ import {setProgram} from 'wldx-core/program';
5
+ import {COMMANDS, GROUPS} from './commands/index.mjs';
10
6
  import {VERSION} from './version.mjs';
11
7
 
12
- const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
8
+ setProgram({name: 'wx', pkg: 'wldx-cli', version: VERSION});
13
9
 
14
- function mainUsage() {
15
- return [
16
- 'Usage: wx <command> [options]',
17
- '',
18
- '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}`),
21
- '',
22
- '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',
26
- '',
27
- 'Without installing: npx wldx-cli <command>',
28
- ].join('\n');
29
- }
10
+ export const SPEC = {
11
+ groups: GROUPS,
12
+ commands: COMMANDS,
13
+ help: {exitCodes: 'Exit codes: 0 done, 1 error, 2 not logged in, 3 nothing to show (no transcript), 64 wrong usage.'},
14
+ };
30
15
 
31
- function groupUsage(name, g) {
32
- return [`Usage: wx ${name} <command> [options]`, '', ...Object.values(g.commands).flatMap((cmd) => [` ${cmd.usage}`, ` ${cmd.summary}`])].join('\n');
33
- }
34
-
35
- 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 ?? ''}`;
39
- });
40
- return [`Usage: ${cmd.usage}`, '', cmd.summary, '', 'Options:', ...options].join('\n');
41
- }
42
-
43
- function line(stream, text) {
44
- stream.write(text.endsWith('\n') ? text : `${text}\n`);
45
- }
46
-
47
- function print(stream, data) {
48
- line(stream, JSON.stringify(data, null, stream.isTTY ? 2 : 0));
49
- }
50
-
51
- function completion(args, io) {
52
- if (args.includes('-h') || args.includes('--help')) return line(io.stdout, completionUsage());
53
- if (args.length > 1) throw new UsageError('wx completion takes one shell', completionUsage());
54
- const shell = args[0] ?? path.basename(io.env.SHELL ?? '');
55
- const script = completionScript(shell);
56
- if (!script) throw new UsageError(`No completion for the shell "${shell}": give one of ${SHELLS.join(', ')}`, completionUsage());
57
- line(io.stdout, script);
58
- }
59
-
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));
78
-
79
- const options = {...COMMON_OPTIONS, ...cmd.options};
80
- let parsed;
81
- try {
82
- parsed = parseArgs({args: rest, options, allowPositionals: true, strict: true});
83
- } catch (e) {
84
- if (String(e.code).startsWith('ERR_PARSE_ARGS')) throw new UsageError(e.message, commandUsage(cmd));
85
- throw e;
86
- }
87
- const {values, positionals} = parsed;
88
- if (values.help) return line(io.stdout, commandUsage(cmd));
89
- 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
- }
93
- }
94
- const [min, max] = cmd.args;
95
- if (positionals.length < min || positionals.length > max) {
96
- throw new UsageError(positionals.length < min ? 'Missing arguments' : `Unexpected argument: ${positionals[max]}`, commandUsage(cmd));
97
- }
98
-
99
- const token = group.auth ? await getToken({account: values.account, env: io.env, fetchImpl: io.fetch, now: io.now()}) : undefined;
100
- const ctx = {
101
- values,
102
- positionals,
103
- env: io.env,
104
- stdin: io.stdin,
105
- now: io.now(),
106
- client: (factory) => factory({token, env: io.env, fetchImpl: io.fetch}),
107
- };
108
- const result = await cmd.run(ctx);
109
- if (result !== undefined) print(io.stdout, result);
110
- }
111
-
112
- // Runs wx with the arguments and returns the exit code: 0 done, 1 error, 2 not logged in, 64 wrong usage.
113
- export async function main(argv, io = {}) {
114
- io = {
115
- stdout: process.stdout,
116
- stderr: process.stderr,
117
- stdin: process.stdin,
118
- env: process.env,
119
- fetch: globalThis.fetch,
120
- now: Date.now,
121
- ...io,
122
- };
123
- try {
124
- await dispatch(argv, io);
125
- return 0;
126
- } catch (e) {
127
- line(io.stderr, `wx: ${e.message}`);
128
- if (e instanceof UsageError) {
129
- if (e.usage) line(io.stderr, `\n${e.usage}`);
130
- return 64;
131
- }
132
- if (e instanceof AuthError) return 2;
133
- if (io.env.WX_DEBUG === '1' && e.stack) line(io.stderr, e.stack);
134
- return 1;
135
- }
136
- }
16
+ export const main = createMain(SPEC);
17
+ export const complete = (words, env = process.env) => completeOf(words, env, SPEC);