wldx-cli 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladimir Gorobets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # wldx-cli — `wx`, a command line for Wildix
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Version 0.1.1** · unofficial · MIT
4
+
5
+ `wx` works with Wildix as **the logged-in user**: your chats today, more of x-bees later. It is the command-line
6
+ twin of the [Wildix agent skills](https://github.com/gorets/wildix-skills) and shares their login.
7
+
8
+ ```sh
9
+ npx wldx-cli chat list # without installing
10
+ npm i -g wldx-cli && wx chat list # installed: the command is `wx`
11
+ ```
12
+
13
+ Requires Node.js 22 or newer. No dependencies.
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ | --- | --- |
19
+ | `wx chat list [--filter unread\|archived] [--section ID] [--cursor C]` | your channels, newest activity first |
20
+ | `wx chat read <channel> [--unread] [--from SEQ] [--to SEQ]` | messages of a channel, newest first, 30 per page |
21
+ | `wx chat send <channel> <text\|-> [--markdown] [--reply-to MESSAGE]` | send a message (`-` reads the text from stdin) |
22
+ | `wx auth status` | the accounts you are logged in with and until when their token is valid (no network) |
23
+ | `wx completion [bash\|zsh\|fish]` | the shell completion script (default: the shell of `$SHELL`) |
24
+
25
+ A channel is an id or an inbox link; a message is an id or an inbox link to it. Every command takes
26
+ `--account EMAIL` and `-h`. Output is JSON: indented on a terminal, one line in a pipe (`wx chat list | jq`).
27
+
28
+ ## Completion
29
+
30
+ Tab completes commands, options and option values (`--filter unread|archived`, `--account` from the saved
31
+ accounts); zsh and fish show a description next to each. Turn it on once, then open a new terminal:
32
+
33
+ ```sh
34
+ echo 'eval "$(wx completion zsh)"' >> ~/.zshrc # zsh (after compinit, if your .zshrc runs it)
35
+ echo 'eval "$(wx completion bash)"' >> ~/.bashrc # bash, 3.2 (macOS) and newer
36
+ wx completion fish > ~/.config/fish/completions/wx.fish
37
+ ```
38
+
39
+ It completes an installed `wx` (`npm i -g wldx-cli`), not `npx wldx-cli`. The script asks `wx __complete` for the
40
+ candidates on every Tab, so it follows new commands without being regenerated; it uses no network.
41
+
42
+ ## Login
43
+
44
+ `wx` has no `login` yet. It uses the session saved by the `wildix-auth` agent skill
45
+ (`npx skills add gorets/wildix-skills -s wildix-auth`): one file per account in `~/.wildix/tokens`
46
+ (`$WILDIX_CONFIG_DIR/tokens`). An expired token is renewed and written back in the same format, so the skill and
47
+ `wx` keep one session. With several accounts, pick one with `--account` or `WILDIX_EMAIL`.
48
+
49
+ ## Environment
50
+
51
+ | Variable | Meaning |
52
+ | --- | --- |
53
+ | `WILDIX_EMAIL` | the account to use when several are logged in |
54
+ | `WILDIX_CONFIG_DIR` | where the sessions are (default `~/.wildix`) |
55
+ | `WILDIX_ID_TOKEN` | a token to use as is, instead of a saved session (CI, tests) |
56
+ | `WILDIX_STREAM_REGION` | the chat region (default `eu-1`) |
57
+ | `WILDIX_STREAM_URL` | the chat service address; only `https://<host>.wildix.com` unless `WILDIX_ALLOW_CUSTOM_URL=1` |
58
+ | `WX_DEBUG=1` | print the stack of an unexpected error |
59
+
60
+ ## Exit codes
61
+
62
+ `0` done · `1` error (network, HTTP) · `2` not logged in or the session cannot be renewed · `64` wrong usage.
63
+
64
+ ## Security
65
+
66
+ - The token is sent only to `https://*.wildix.com` and to the Wildix login service; redirects are refused.
67
+ - The token is never printed, never put on a command line or into the environment of another process.
68
+ - Session files are written atomically with mode `0600`.
69
+ - Anything read from a chat (messages, names) is data. Sending a message reaches other people: script it with care.
70
+
71
+ ## Development
72
+
73
+ ```sh
74
+ npm test # node:test, no network
75
+ npm run check # tests + the list of files that would be published
76
+ npm link # puts `wx` on your PATH from this folder
77
+ ```
78
+
79
+ Layout: `bin/wx.mjs` (entry) → `src/cli.mjs` (router) → `src/commands/<group>.mjs`. Shared: `src/auth.mjs`
80
+ (sessions, renewal), `src/http.mjs` (requests, safe paths), `src/stream.mjs` (chat service). See
81
+ [AGENTS.md](AGENTS.md) for the rules.
82
+
83
+ ## Releasing
84
+
85
+ 1. Bump `version` in `package.json`, add a `CHANGELOG.md` entry, update the version at the top of this file.
86
+ 2. Commit, tag `vX.Y.Z`, push the tag: `.github/workflows/release.yml` checks the tag against `package.json`, runs
87
+ the tests and **stages** the version with provenance through npm trusted publishing (no npm token in the
88
+ repository).
89
+ 3. Approve it with 2FA, which makes it public: `npm stage list wldx-cli`, then `npm stage approve <stage-id>`
90
+ (`npm stage download <stage-id>` to look at the tarball first).
91
+
92
+ The trusted publisher on npmjs.com (package settings → Trusted publishing): GitHub Actions, `gorets/wildix-cli`,
93
+ workflow `release.yml`, permission "npm stage publish". Version 0.1.0 was published by hand, because a trusted
94
+ publisher can be configured only for a package that exists.
package/bin/wx.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import {main} from '../src/cli.mjs';
3
+
4
+ // exitCode, never process.exit(): an exit right after a write cuts piped output.
5
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "wldx-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Unofficial command line for Wildix (x-bees chats and more), acting as the logged-in user",
5
+ "keywords": [
6
+ "wildix",
7
+ "x-bees",
8
+ "cli",
9
+ "chat"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Vladimir Gorobets",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/gorets/wildix-cli.git"
16
+ },
17
+ "homepage": "https://github.com/gorets/wildix-cli#readme",
18
+ "bugs": "https://github.com/gorets/wildix-cli/issues",
19
+ "type": "module",
20
+ "bin": {
21
+ "wx": "bin/wx.mjs"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test test/*.test.mjs",
32
+ "check": "npm test && npm pack --dry-run"
33
+ }
34
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // The Wildix login of the user, shared with the wildix-auth agent skill: one file per account in
2
+ // $WILDIX_CONFIG_DIR/tokens (default ~/.wildix/tokens), named <e-mail with @ as _at_>.prod.json, holding the Cognito
3
+ // set {IdToken, AccessToken, RefreshToken, ExpiresIn, email, savedAt}. An expired IdToken is renewed with the
4
+ // RefreshToken and written back in the same format, so the skill and the CLI keep using one session.
5
+ //
6
+ // The token is never printed and never put on a command line or into the environment of a child process.
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import {AuthError} from './errors.mjs';
12
+ import {USER_AGENT} from './version.mjs';
13
+
14
+ export const CLIENT_ID = '2un9vsk5ghu0ocurajqe4a6l1e';
15
+ export const COGNITO_URL = 'https://cognito-idp.eu-central-1.amazonaws.com/';
16
+ const RENEW_EARLY_MS = 60_000;
17
+ export const LOGIN_HINT = 'Log in with the wildix-auth agent skill (npx skills add gorets/wildix-skills -s wildix-auth); `wx login` is not there yet.';
18
+
19
+ export const configDir = (env = process.env) => env.WILDIX_CONFIG_DIR || path.join(os.homedir(), '.wildix');
20
+ export const tokensDir = (env = process.env) => path.join(configDir(env), 'tokens');
21
+
22
+ // The e-mail becomes part of a file name: letters, digits and . _ % + - @ only, no "..".
23
+ export function tokenFile(email, env = process.env) {
24
+ if (!/^[A-Za-z0-9._%+@-]+$/.test(email) || email.includes('..')) throw new AuthError(`Not a valid e-mail: ${email}`);
25
+ return path.join(tokensDir(env), `${email.replace(/@/g, '_at_')}.prod.json`);
26
+ }
27
+
28
+ function readJson(file) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ export function jwtClaims(token) {
37
+ try {
38
+ return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ // When the IdToken of a saved set stops being valid, in ms: its `exp` claim, else savedAt + ExpiresIn, else 0.
45
+ export function expiresAt(set) {
46
+ const exp = jwtClaims(set?.IdToken).exp;
47
+ if (Number.isFinite(exp)) return exp * 1000;
48
+ const saved = Date.parse(set?.savedAt ?? '');
49
+ return Number.isFinite(saved) && Number.isFinite(set?.ExpiresIn) ? saved + set.ExpiresIn * 1000 : 0;
50
+ }
51
+
52
+ // The saved sessions: [{email, expiresAt}], never the tokens.
53
+ export function sessions(env = process.env) {
54
+ const dir = tokensDir(env);
55
+ let names;
56
+ try {
57
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.prod.json')).sort();
58
+ } catch {
59
+ return [];
60
+ }
61
+ return names
62
+ .map((n) => readJson(path.join(dir, n)))
63
+ .filter((set) => typeof set?.email === 'string')
64
+ .map((set) => ({email: set.email, expiresAt: expiresAt(set)}));
65
+ }
66
+
67
+ // Which account: --account, else WILDIX_EMAIL, else the only saved session.
68
+ export function pickAccount(env = process.env, account) {
69
+ const email = account || env.WILDIX_EMAIL;
70
+ if (email) return email;
71
+ const list = sessions(env);
72
+ if (!list.length) throw new AuthError(`Not logged in to Wildix. ${LOGIN_HINT}`);
73
+ if (list.length > 1) {
74
+ throw new AuthError(`Several Wildix accounts are logged in. Pick one with --account or WILDIX_EMAIL:\n${list.map((s) => ` ${s.email}`).join('\n')}`);
75
+ }
76
+ return list[0].email;
77
+ }
78
+
79
+ // The format of the skill: whole seconds, no milliseconds (its bash parses it with `date -f %Y-%m-%dT%H:%M:%SZ`).
80
+ export const stamp = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
81
+
82
+ // Writes a token set privately (0600) and atomically; a set without IdToken, RefreshToken and ExpiresIn is refused.
83
+ export function saveTokens(file, set) {
84
+ if (typeof set?.IdToken !== 'string' || typeof set?.RefreshToken !== 'string' || typeof set?.ExpiresIn !== 'number') {
85
+ throw new AuthError('The answer carried no complete set of tokens: nothing was saved.');
86
+ }
87
+ const dir = path.dirname(file);
88
+ fs.mkdirSync(dir, {recursive: true, mode: 0o700});
89
+ const tmp = path.join(dir, `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}`);
90
+ fs.writeFileSync(tmp, `${JSON.stringify(set, null, 2)}\n`, {mode: 0o600, flag: 'wx'});
91
+ fs.renameSync(tmp, file);
92
+ }
93
+
94
+ async function renew(email, set, {env, fetchImpl, now}) {
95
+ const res = await fetchImpl(COGNITO_URL, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'user-agent': USER_AGENT,
99
+ 'content-type': 'application/x-amz-json-1.1',
100
+ 'cache-control': 'no-store',
101
+ 'x-amz-target': 'AWSCognitoIdentityProviderService.InitiateAuth',
102
+ },
103
+ body: JSON.stringify({AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: CLIENT_ID, AuthParameters: {REFRESH_TOKEN: set.RefreshToken}, ClientMetadata: {}}),
104
+ redirect: 'error',
105
+ signal: AbortSignal.timeout(30_000),
106
+ });
107
+ const body = await res.json().catch(() => ({}));
108
+ const result = body?.AuthenticationResult;
109
+ if (!res.ok || body?.__type || typeof result?.IdToken !== 'string') {
110
+ throw new AuthError(`The session of ${email} could not be renewed (${body?.message || body?.__type || `HTTP ${res.status}`}). ${LOGIN_HINT}`);
111
+ }
112
+ // Cognito does not send a new RefreshToken on a refresh: the saved one is kept.
113
+ const next = {...result, email, savedAt: stamp(now), RefreshToken: set.RefreshToken, ExpiresIn: result.ExpiresIn ?? set.ExpiresIn};
114
+ saveTokens(tokenFile(email, env), next);
115
+ return next;
116
+ }
117
+
118
+ // A valid IdToken of the account: WILDIX_ID_TOKEN as given (tests, CI), else the saved session, renewed when it
119
+ // expires within a minute.
120
+ export async function getToken({account, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
121
+ if (env.WILDIX_ID_TOKEN) return env.WILDIX_ID_TOKEN;
122
+ const email = pickAccount(env, account);
123
+ const set = readJson(tokenFile(email, env));
124
+ if (typeof set?.IdToken !== 'string') throw new AuthError(`No saved session for ${email}. ${LOGIN_HINT}`);
125
+ if (expiresAt(set) - RENEW_EARLY_MS > now) return set.IdToken;
126
+ if (typeof set.RefreshToken !== 'string') throw new AuthError(`The session of ${email} has expired. ${LOGIN_HINT}`);
127
+ return (await renew(email, set, {env, fetchImpl, now})).IdToken;
128
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,136 @@
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';
10
+ import {VERSION} from './version.mjs';
11
+
12
+ const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
13
+
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
+ }
30
+
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
+ }
@@ -0,0 +1,32 @@
1
+ // wx auth: the saved Wildix sessions (shared with the wildix-auth agent skill). Never prints a token.
2
+ import {AuthError} from '../errors.mjs';
3
+ import {pickAccount, sessions} from '../auth.mjs';
4
+
5
+ export default {
6
+ summary: 'the Wildix accounts you are logged in with',
7
+ commands: {
8
+ status: {
9
+ summary: 'saved sessions, which one is used and until when its token is valid (no network)',
10
+ usage: 'wx auth status',
11
+ options: {},
12
+ args: [0, 0],
13
+ async run({values, env, now}) {
14
+ let account = null;
15
+ try {
16
+ account = pickAccount(env, values.account);
17
+ } catch (e) {
18
+ if (!(e instanceof AuthError)) throw e;
19
+ }
20
+ return {
21
+ account,
22
+ tokenFromEnvironment: Boolean(env.WILDIX_ID_TOKEN),
23
+ sessions: sessions(env).map((s) => ({
24
+ email: s.email,
25
+ tokenValidUntil: s.expiresAt ? new Date(s.expiresAt).toISOString() : null,
26
+ expired: s.expiresAt <= now,
27
+ })),
28
+ };
29
+ },
30
+ },
31
+ },
32
+ };
@@ -0,0 +1,72 @@
1
+ // wx chat: channels and messages of the Wildix stream service, as the logged-in user.
2
+ import {UsageError} from '../errors.mjs';
3
+ import {apiPath, query} from '../http.mjs';
4
+ import {channelId, messageId, streamClient} from '../stream.mjs';
5
+
6
+ const SEQ_MAX = 999_999_999_999;
7
+
8
+ function seq(name, value) {
9
+ if (value === undefined) return undefined;
10
+ if (!/^\d{1,12}$/.test(value) || Number(value) > SEQ_MAX) throw new UsageError(`--${name} must be a whole number from 0 to ${SEQ_MAX}: ${value}`);
11
+ return Number(value);
12
+ }
13
+
14
+ async function readText(arg, stdin) {
15
+ if (arg !== '-') return arg;
16
+ const chunks = [];
17
+ for await (const c of stdin) chunks.push(c);
18
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
19
+ }
20
+
21
+ export default {
22
+ summary: 'chats: list channels, read and send messages',
23
+ auth: true,
24
+ commands: {
25
+ list: {
26
+ summary: 'your channels, newest activity first',
27
+ usage: 'wx chat list [--filter unread|archived] [--section ID] [--cursor C]',
28
+ options: {
29
+ filter: {type: 'string', description: 'only unread or archived channels', choices: ['unread', 'archived']},
30
+ section: {type: 'string', description: 'only the channels of a section'},
31
+ cursor: {type: 'string', description: 'the next page (nextCursor of the previous one)'},
32
+ },
33
+ args: [0, 0],
34
+ async run({values, client}) {
35
+ return client(streamClient).call('GET', `/channels${query({filter: values.filter, section: values.section, cursor: values.cursor})}`);
36
+ },
37
+ },
38
+ read: {
39
+ summary: 'messages of a channel, newest first, 30 per page',
40
+ usage: 'wx chat read <channel> [--unread] [--from SEQ] [--to SEQ]',
41
+ options: {
42
+ unread: {type: 'boolean', description: 'start at the first unread message'},
43
+ from: {type: 'string', description: 'from this sequence number'},
44
+ to: {type: 'string', description: 'up to this sequence number'},
45
+ },
46
+ args: [1, 1],
47
+ async run({values, positionals, client}) {
48
+ const ch = channelId(positionals[0]);
49
+ const q = query({from: seq('from', values.from), to: seq('to', values.to), anchor: values.unread && 'firstUnread'});
50
+ return client(streamClient).call('GET', apiPath`/channels/${ch}/messages` + q);
51
+ },
52
+ },
53
+ send: {
54
+ summary: 'send a message to a channel ("-" reads the text from stdin)',
55
+ usage: 'wx chat send <channel> <text|-> [--markdown] [--reply-to MESSAGE]',
56
+ options: {
57
+ markdown: {type: 'boolean', description: 'the text is markdown'},
58
+ 'reply-to': {type: 'string', description: 'quote this message (id or inbox link)'},
59
+ },
60
+ args: [2, 2],
61
+ async run({values, positionals, client, stdin}) {
62
+ const ch = channelId(positionals[0]);
63
+ const text = await readText(positionals[1], stdin);
64
+ if (!text.trim()) throw new UsageError('Nothing to send: the text is empty');
65
+ const body = {text};
66
+ if (values.markdown) body.markdown = true;
67
+ if (values['reply-to']) body.quote = {channelId: ch, messageId: messageId(values['reply-to'])};
68
+ return client(streamClient).call('POST', apiPath`/channels/${ch}/messages`, body);
69
+ },
70
+ },
71
+ },
72
+ };
@@ -0,0 +1,15 @@
1
+ // The command groups of wx and the options every command takes. An option is a node:util parseArgs option plus
2
+ // `description` (help and completion), `choices` (the router refuses other values, completion offers them) and
3
+ // `complete: 'accounts'` (completion offers the logged-in accounts).
4
+ import auth from './auth.mjs';
5
+ import chat from './chat.mjs';
6
+
7
+ export const GROUPS = {chat, auth};
8
+
9
+ export const COMMON_OPTIONS = {
10
+ account: {type: 'string', description: 'the logged-in Wildix account to use', complete: 'accounts'},
11
+ help: {type: 'boolean', short: 'h', description: 'help of the command'},
12
+ };
13
+
14
+ export const groupOf = (name) => (Object.hasOwn(GROUPS, name) ? GROUPS[name] : undefined);
15
+ export const commandOf = (group, name) => (group && Object.hasOwn(group.commands, name) ? group.commands[name] : undefined);
@@ -0,0 +1,105 @@
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
+ // accounts for --account), so completion follows the commands without a regenerated script. No network.
5
+ import {sessions} from './auth.mjs';
6
+ import {COMMON_OPTIONS, GROUPS, commandOf, groupOf} 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'};
15
+
16
+ function valuesOf(option, env) {
17
+ if (option.choices) return option.choices.map((v) => [v]);
18
+ if (option.complete === 'accounts') 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) {
24
+ const cur = words.length ? words[words.length - 1] : '';
25
+ const done = words.slice(0, -1);
26
+ const offer = (pairs) => pairs.filter(([v]) => v.startsWith(cur)).map(([v, d]) => (d ? `${v}\t${d}` : v));
27
+
28
+ if (done.length === 0) {
29
+ return offer([
30
+ ...Object.entries(GROUPS).map(([name, g]) => [name, g.summary]),
31
+ ...Object.entries(TOP_LEVEL),
32
+ ...(cur.startsWith('-') ? Object.entries(TOP_OPTIONS) : []),
33
+ ]);
34
+ }
35
+ if (done[0] === 'completion') return done.length === 1 ? offer(SHELLS.map((s) => [s, `the script for ${s}`])) : [];
36
+
37
+ const group = groupOf(done[0]);
38
+ if (!group) return [];
39
+ if (done.length === 1) return offer(Object.entries(group.commands).map(([name, cmd]) => [name, cmd.summary]));
40
+
41
+ const cmd = commandOf(group, done[1]);
42
+ if (!cmd) return [];
43
+ const options = {...cmd.options, ...COMMON_OPTIONS};
44
+ const prev = done[done.length - 1];
45
+ if (done.length > 2 && prev.startsWith('--')) {
46
+ const option = Object.hasOwn(options, prev.slice(2)) ? options[prev.slice(2)] : undefined;
47
+ if (option?.type === 'string') return offer(valuesOf(option, env));
48
+ }
49
+ if (cur.startsWith('-')) {
50
+ const used = new Set(done.slice(2).filter((w) => w.startsWith('--')).map((w) => w.slice(2)));
51
+ return offer(Object.entries(options).filter(([name, o]) => o.multiple || !used.has(name)).map(([name, o]) => [`--${name}`, o.description]));
52
+ }
53
+ return [];
54
+ }
55
+
56
+ const SCRIPTS = {
57
+ bash: `# wx completion for bash. Add to ~/.bashrc: eval "$(wx completion bash)"
58
+ _wx_complete() {
59
+ # The words are taken before IFS changes: bash 3.2 (macOS) joins the elements of a quoted array slice when IFS
60
+ # is a newline.
61
+ local words=("\${COMP_WORDS[@]:1:COMP_CWORD}") out line
62
+ COMPREPLY=()
63
+ out=$(wx __complete -- "\${words[@]}" 2>/dev/null) || return 0
64
+ local IFS=$'\\n'
65
+ for line in $out; do
66
+ COMPREPLY+=("\${line%%$'\\t'*}")
67
+ done
68
+ }
69
+ complete -F _wx_complete wx
70
+ `,
71
+ zsh: `# wx completion for zsh. Add to ~/.zshrc (after compinit): eval "$(wx completion zsh)"
72
+ _wx() {
73
+ local -a lines items
74
+ local line
75
+ lines=("\${(@f)$(wx __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)}")
76
+ for line in $lines; do
77
+ [[ -n $line ]] || continue
78
+ if [[ $line == *$'\\t'* ]]; then items+=("\${\${line%%$'\\t'*}//:/\\\\:}:\${line#*$'\\t'}")
79
+ else items+=("\${line//:/\\\\:}"); fi
80
+ done
81
+ (( \${#items} )) && _describe -t wx-candidates 'wx' items
82
+ }
83
+ (( \${+functions[compdef]} )) || { autoload -Uz compinit && compinit; }
84
+ compdef _wx wx
85
+ `,
86
+ fish: `# wx completion for fish. Save as ~/.config/fish/completions/wx.fish: wx completion fish > ~/.config/fish/completions/wx.fish
87
+ complete -c wx -f -a '(wx __complete -- (commandline -opc)[2..-1] (commandline -ct) 2>/dev/null)'
88
+ `,
89
+ };
90
+
91
+ export function completionScript(shell) {
92
+ return Object.hasOwn(SCRIPTS, shell) ? SCRIPTS[shell] : undefined;
93
+ }
94
+
95
+ export function completionUsage() {
96
+ return [
97
+ 'Usage: wx completion [bash|zsh|fish] (default: the shell of $SHELL)',
98
+ '',
99
+ 'Prints the completion script. To turn completion on for good:',
100
+ ' bash: echo \'eval "$(wx completion bash)"\' >> ~/.bashrc',
101
+ ' zsh: echo \'eval "$(wx completion zsh)"\' >> ~/.zshrc',
102
+ ' fish: wx completion fish > ~/.config/fish/completions/wx.fish',
103
+ 'then open a new terminal. It completes an installed `wx` (npm i -g wldx-cli), not `npx wldx-cli`.',
104
+ ].join('\n');
105
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,30 @@
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/http.mjs ADDED
@@ -0,0 +1,47 @@
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.
19
+ export function query(params) {
20
+ const q = new URLSearchParams();
21
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== false && v !== '') q.append(k, String(v));
22
+ const s = q.toString();
23
+ return s ? `?${s}` : '';
24
+ }
25
+
26
+ // One JSON request. Redirects are refused, so the token never follows one to another host. Returns the parsed
27
+ // answer, or {ok: true} when it is empty; throws HttpError on a status other than 2xx.
28
+ export async function request(url, {method = 'GET', token, body, service, fetchImpl = globalThis.fetch, timeoutMs = 60_000} = {}) {
29
+ const headers = {'user-agent': USER_AGENT, accept: 'application/json'};
30
+ if (token) headers.authorization = `Bearer ${token}`;
31
+ if (body !== undefined) headers['content-type'] = 'application/json';
32
+ const res = await fetchImpl(url, {
33
+ method,
34
+ headers,
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ redirect: 'error',
37
+ signal: AbortSignal.timeout(timeoutMs),
38
+ });
39
+ const text = await res.text();
40
+ if (!res.ok) throw new HttpError(res.status, method, new URL(url).pathname, text, service);
41
+ if (!text) return {ok: true};
42
+ try {
43
+ return JSON.parse(text);
44
+ } catch {
45
+ return {text};
46
+ }
47
+ }
package/src/stream.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // The Wildix stream service (chats): https://<region>.stream.wildix.com/api/v1, region eu-1 by default.
2
+ import {UsageError} from './errors.mjs';
3
+ import {request} from './http.mjs';
4
+
5
+ const WILDIX_HOST = /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.wildix\.com(:[0-9]+)?$/;
6
+ const ID = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
7
+
8
+ // The base address. The token is only ever sent to https://<host>.wildix.com; WILDIX_ALLOW_CUSTOM_URL=1 lifts this
9
+ // for tests against a local server.
10
+ export function streamBase(env = process.env) {
11
+ const base = (env.WILDIX_STREAM_URL || `https://${env.WILDIX_STREAM_REGION || 'eu-1'}.stream.wildix.com`).replace(/\/+$/, '');
12
+ if (env.WILDIX_ALLOW_CUSTOM_URL !== '1' && !WILDIX_HOST.test(base)) {
13
+ throw new UsageError(`The stream service address must be https://<host>.wildix.com, got: ${base}`);
14
+ }
15
+ return base;
16
+ }
17
+
18
+ const valid = (v) => ID.test(v) && !v.includes('..');
19
+
20
+ // A channel id from an id, a "type:id" form or an inbox link (the message part of a link is dropped).
21
+ export function channelId(input) {
22
+ let v = String(input).replace(/[?#].*$/s, '');
23
+ const at = v.indexOf('/inbox/');
24
+ if (at >= 0) v = v.slice(at + '/inbox/'.length);
25
+ v = v.split('/')[0];
26
+ v = v.slice(v.lastIndexOf(':') + 1);
27
+ if (!valid(v)) throw new UsageError(`Not a channel id: ${input}`);
28
+ return v;
29
+ }
30
+
31
+ // A message id from an id or from an inbox link to the message (the last path segment).
32
+ export function messageId(input) {
33
+ const v = String(input).replace(/[?#].*$/s, '').replace(/\/+$/, '').split('/').pop();
34
+ if (!valid(v)) throw new UsageError(`Not a message id: ${input}`);
35
+ return v;
36
+ }
37
+
38
+ export function streamClient({token, env = process.env, fetchImpl}) {
39
+ const api = `${streamBase(env)}/api/v1`;
40
+ return {
41
+ call: (method, path, body) => request(api + path, {method, token, body, fetchImpl, service: 'the stream service'}),
42
+ };
43
+ }
@@ -0,0 +1,4 @@
1
+ import {readFileSync} from 'node:fs';
2
+
3
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
4
+ export const USER_AGENT = `wldx-cli/${VERSION}`;