wldx-cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +19 -22
  2. package/package.json +9 -6
  3. package/src/chat/format.mjs +40 -8
  4. package/src/chat/lib.mjs +71 -23
  5. package/src/chat/watch.mjs +46 -15
  6. package/src/cli.mjs +13 -252
  7. package/src/commands/calls.mjs +135 -32
  8. package/src/commands/channel.mjs +99 -21
  9. package/src/commands/chat.mjs +77 -22
  10. package/src/commands/conversations.mjs +146 -29
  11. package/src/commands/file.mjs +13 -5
  12. package/src/commands/inbox.mjs +20 -13
  13. package/src/commands/index.mjs +5 -29
  14. package/src/commands/live.mjs +82 -24
  15. package/src/commands/meetings.mjs +114 -27
  16. package/src/commands/phone.mjs +145 -33
  17. package/src/commands/reports.mjs +219 -44
  18. package/src/commands/section.mjs +49 -8
  19. package/src/commands/user.mjs +42 -13
  20. package/src/history/text.mjs +30 -4
  21. package/src/history/wda.mjs +20 -10
  22. package/src/live/socket.mjs +25 -7
  23. package/src/live/stats.mjs +41 -9
  24. package/src/live/text.mjs +122 -23
  25. package/src/live/views.mjs +93 -23
  26. package/src/phone/control.mjs +93 -33
  27. package/src/phone/events.mjs +38 -7
  28. package/src/phone/pbx.mjs +29 -11
  29. package/src/phone/sip.mjs +87 -17
  30. package/src/phone/xml.mjs +2 -1
  31. package/src/stream.mjs +10 -5
  32. package/src/version.mjs +0 -1
  33. package/src/auth.mjs +0 -274
  34. package/src/commands/login.mjs +0 -126
  35. package/src/commands/profile.mjs +0 -51
  36. package/src/commands/status.mjs +0 -95
  37. package/src/completion.mjs +0 -132
  38. package/src/errors.mjs +0 -30
  39. package/src/files.mjs +0 -98
  40. package/src/format.mjs +0 -82
  41. package/src/http.mjs +0 -64
  42. package/src/services.mjs +0 -58
package/src/phone/sip.mjs CHANGED
@@ -13,8 +13,37 @@ const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
13
13
  export const randomToken = (n) => [...randomBytes(n)].map((b) => ALPHABET[b % ALPHABET.length]).join('');
14
14
  const md5 = (s) => createHash('md5').update(s).digest('hex');
15
15
 
16
- export const ALLOWED = ['ACK', 'BYE', 'CANCEL', 'INFO', 'INVITE', 'MESSAGE', 'NOTIFY', 'OPTIONS', 'PRACK', 'REFER', 'REGISTER', 'SUBSCRIBE'];
17
- const COMPACT = {i: 'call-id', f: 'from', t: 'to', v: 'via', m: 'contact', l: 'content-length', c: 'content-type', o: 'event', k: 'supported', u: 'allow-events', e: 'content-encoding', s: 'subject', r: 'refer-to', b: 'referred-by', x: 'session-expires'};
16
+ export const ALLOWED = [
17
+ 'ACK',
18
+ 'BYE',
19
+ 'CANCEL',
20
+ 'INFO',
21
+ 'INVITE',
22
+ 'MESSAGE',
23
+ 'NOTIFY',
24
+ 'OPTIONS',
25
+ 'PRACK',
26
+ 'REFER',
27
+ 'REGISTER',
28
+ 'SUBSCRIBE',
29
+ ];
30
+ const COMPACT = {
31
+ i: 'call-id',
32
+ f: 'from',
33
+ t: 'to',
34
+ v: 'via',
35
+ m: 'contact',
36
+ l: 'content-length',
37
+ c: 'content-type',
38
+ o: 'event',
39
+ k: 'supported',
40
+ u: 'allow-events',
41
+ e: 'content-encoding',
42
+ s: 'subject',
43
+ r: 'refer-to',
44
+ b: 'referred-by',
45
+ x: 'session-expires',
46
+ };
18
47
  const TRANSACTION_TIMEOUT_MS = 32_000; // Timer F of RFC 3261
19
48
 
20
49
  // One SIP message from its text: {method, uri} for a request or {status, reason} for a response; header(name) gives
@@ -38,7 +67,8 @@ export function parseMessage(raw) {
38
67
  const msg = {headerList: [], body};
39
68
  let m;
40
69
  if ((m = /^SIP\/2\.0 (\d{3})(?: (.*))?$/.exec(first))) Object.assign(msg, {status: Number(m[1]), reason: m[2] ?? ''});
41
- else if ((m = /^([A-Za-z]+) (\S+) SIP\/2\.0$/.exec(first))) Object.assign(msg, {method: m[1].toUpperCase(), uri: m[2]});
70
+ else if ((m = /^([A-Za-z]+) (\S+) SIP\/2\.0$/.exec(first)))
71
+ Object.assign(msg, {method: m[1].toUpperCase(), uri: m[2]});
42
72
  else throw new Error('not a SIP message');
43
73
  for (const l of lines) {
44
74
  const c = l.indexOf(':');
@@ -61,35 +91,50 @@ export const uriOf = (value) => {
61
91
  };
62
92
  export function paramOf(value, name) {
63
93
  // The display name and the URI may hold ";tag=" themselves: only the parameters after them count.
64
- const v = String(value ?? '').replace(/^\s*"(?:[^"\\]|\\.)*"/, '').replace(/<[^>]*>/, '');
94
+ const v = String(value ?? '')
95
+ .replace(/^\s*"(?:[^"\\]|\\.)*"/, '')
96
+ .replace(/<[^>]*>/, '');
65
97
  const m = new RegExp(`;\\s*${name}\\s*=\\s*(?:"([^"]*)"|([^;,\\s]+))`, 'i').exec(v);
66
98
  return m ? (m[1] ?? m[2]) : undefined;
67
99
  }
68
100
  const branchOf = (via) => /;\s*branch=([^;,\s]+)/i.exec(via ?? '')?.[1];
69
- const splitList = (values) => values.flatMap((v) => v.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)(?![^<]*>)/).map((x) => x.trim())).filter(Boolean);
101
+ const splitList = (values) =>
102
+ values.flatMap((v) => v.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)(?![^<]*>)/).map((x) => x.trim())).filter(Boolean);
70
103
 
71
104
  // A digest challenge (WWW-Authenticate / Proxy-Authenticate) as {realm, nonce, qop, opaque, algorithm, stale}.
72
105
  export function parseChallenge(value) {
73
106
  const v = String(value ?? '');
74
107
  if (!/^\s*Digest\s/i.test(v)) return undefined;
75
108
  const out = {};
76
- for (const m of v.replace(/^\s*Digest\s+/i, '').matchAll(/([A-Za-z-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g)) out[m[1].toLowerCase()] = m[2] ?? m[3];
109
+ for (const m of v.replace(/^\s*Digest\s+/i, '').matchAll(/([A-Za-z-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g))
110
+ out[m[1].toLowerCase()] = m[2] ?? m[3];
77
111
  return out;
78
112
  }
79
113
 
80
114
  // The Authorization value for a challenge (MD5; qop auth when offered), as sip.js computes it.
81
115
  export function digestAuthorization({username, password, method, uri, challenge, nc = 1, cnonce = randomToken(12)}) {
82
116
  const algorithm = challenge.algorithm ?? 'MD5';
83
- if (algorithm.toUpperCase() !== 'MD5') throw new Error(`the PBX asks for a digest algorithm other than MD5: ${algorithm}`);
117
+ if (algorithm.toUpperCase() !== 'MD5')
118
+ throw new Error(`the PBX asks for a digest algorithm other than MD5: ${algorithm}`);
84
119
  if (!challenge.realm || !challenge.nonce) throw new Error('the PBX sent a digest challenge without realm or nonce');
85
120
  const qops = (challenge.qop ?? '').split(',').map((q) => q.trim());
86
121
  const qop = challenge.qop === undefined ? undefined : qops.includes('auth') ? 'auth' : undefined;
87
- if (challenge.qop !== undefined && !qop) throw new Error(`the PBX asks for a digest qop other than auth: ${challenge.qop}`);
122
+ if (challenge.qop !== undefined && !qop)
123
+ throw new Error(`the PBX asks for a digest qop other than auth: ${challenge.qop}`);
88
124
  const ha1 = md5(`${username}:${challenge.realm}:${password}`);
89
125
  const ha2 = md5(`${method}:${uri}`);
90
126
  const ncHex = nc.toString(16).padStart(8, '0');
91
- const response = qop ? md5(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
92
- const parts = [`algorithm=MD5`, `username="${username}"`, `realm="${challenge.realm}"`, `nonce="${challenge.nonce}"`, `uri="${uri}"`, `response="${response}"`];
127
+ const response = qop
128
+ ? md5(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`)
129
+ : md5(`${ha1}:${challenge.nonce}:${ha2}`);
130
+ const parts = [
131
+ `algorithm=MD5`,
132
+ `username="${username}"`,
133
+ `realm="${challenge.realm}"`,
134
+ `nonce="${challenge.nonce}"`,
135
+ `uri="${uri}"`,
136
+ `response="${response}"`,
137
+ ];
93
138
  if (challenge.opaque !== undefined) parts.push(`opaque="${challenge.opaque}"`);
94
139
  if (qop) parts.push(`qop=${qop}`, `cnonce="${cnonce}"`, `nc=${ncHex}`);
95
140
  return `Digest ${parts.join(', ')}`;
@@ -99,7 +144,16 @@ export function digestAuthorization({username, password, method, uri, challenge,
99
144
  // onNotify), 'disconnect' (the socket closed). onNotify(request) returns true when the NOTIFY belongs to a
100
145
  // subscription (answered 200), false otherwise (481).
101
146
  export class SipUserAgent extends EventEmitter {
102
- constructor({wsUrl, domain, extension, password, userAgent, WebSocketImpl = globalThis.WebSocket, keepAliveSeconds = 30, timeoutMs = TRANSACTION_TIMEOUT_MS}) {
147
+ constructor({
148
+ wsUrl,
149
+ domain,
150
+ extension,
151
+ password,
152
+ userAgent,
153
+ WebSocketImpl = globalThis.WebSocket,
154
+ keepAliveSeconds = 30,
155
+ timeoutMs = TRANSACTION_TIMEOUT_MS,
156
+ }) {
103
157
  super();
104
158
  this.wsUrl = wsUrl;
105
159
  this.domain = domain;
@@ -120,7 +174,8 @@ export class SipUserAgent extends EventEmitter {
120
174
 
121
175
  // Opens the WebSocket (subprotocol "sip"); rejects when it cannot.
122
176
  open() {
123
- if (!this.WebSocketImpl) return Promise.reject(new Error('this Node.js has no WebSocket (Node 22 or newer is needed)'));
177
+ if (!this.WebSocketImpl)
178
+ return Promise.reject(new Error('this Node.js has no WebSocket (Node 22 or newer is needed)'));
124
179
  return new Promise((resolve, reject) => {
125
180
  let opened = false;
126
181
  const ws = new this.WebSocketImpl(this.wsUrl, 'sip');
@@ -134,7 +189,9 @@ export class SipUserAgent extends EventEmitter {
134
189
  }
135
190
  resolve();
136
191
  });
137
- ws.addEventListener('message', (e) => this.receive(typeof e.data === 'string' ? e.data : Buffer.from(e.data).toString('utf8')));
192
+ ws.addEventListener('message', (e) =>
193
+ this.receive(typeof e.data === 'string' ? e.data : Buffer.from(e.data).toString('utf8')),
194
+ );
138
195
  ws.addEventListener('error', () => {
139
196
  if (!opened) reject(new Error(`the PBX did not open the SIP WebSocket (${new URL(this.wsUrl).host})`));
140
197
  });
@@ -168,7 +225,7 @@ export class SipUserAgent extends EventEmitter {
168
225
  }
169
226
 
170
227
  write(text) {
171
- if (!this.ws || this.ws.readyState !== 1) throw new Error('the SIP connection is not open');
228
+ if (this.ws?.readyState !== 1) throw new Error('the SIP connection is not open');
172
229
  this.ws.send(text);
173
230
  }
174
231
 
@@ -186,7 +243,14 @@ export class SipUserAgent extends EventEmitter {
186
243
  if (!challenge || round >= (stale ? 2 : 1)) return res;
187
244
  const nc = (this.nc.get(challenge.nonce) ?? 0) + 1;
188
245
  this.nc.set(challenge.nonce, nc);
189
- const value = digestAuthorization({username: this.extension, password: this.password, method, uri: o.ruri, challenge, nc});
246
+ const value = digestAuthorization({
247
+ username: this.extension,
248
+ password: this.password,
249
+ method,
250
+ uri: o.ruri,
251
+ challenge,
252
+ nc,
253
+ });
190
254
  auth = `${res.status === 401 ? 'Authorization' : 'Proxy-Authorization'}: ${value}`;
191
255
  }
192
256
  }
@@ -224,8 +288,14 @@ export class SipUserAgent extends EventEmitter {
224
288
  };
225
289
  this.transactions.set(branch, {
226
290
  method,
227
- ok: (res) => (done(), resolve(res)),
228
- fail: (e) => (done(), reject(e)),
291
+ ok: (res) => {
292
+ done();
293
+ resolve(res);
294
+ },
295
+ fail: (e) => {
296
+ done();
297
+ reject(e);
298
+ },
229
299
  });
230
300
  try {
231
301
  this.write(lines.join('\r\n'));
package/src/phone/xml.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // elements, attributes, text. No DTD, no external entities (a DOCTYPE is refused). Element names are matched
3
3
  // without their namespace prefix, as ltx does.
4
4
 
5
- const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<\/\s*([^\s>]+)\s*>|<([^\s/>!?]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/y;
5
+ const TOKEN =
6
+ /<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<\/\s*([^\s>]+)\s*>|<([^\s/>!?]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/y;
6
7
  const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
7
8
  const NAMED = {lt: '<', gt: '>', amp: '&', quot: '"', apos: "'"};
8
9
 
package/src/stream.mjs CHANGED
@@ -1,7 +1,7 @@
1
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
- import {streamUrl} from './services.mjs';
2
+ import {UsageError} from 'wldx-core/errors';
3
+ import {request} from 'wldx-core/http';
4
+ import {streamUrl} from 'wldx-core/services';
5
5
 
6
6
  const ID = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
7
7
 
@@ -23,7 +23,11 @@ export function channelId(input) {
23
23
 
24
24
  // A message id from an id or from an inbox link to the message (the last path segment).
25
25
  export function messageId(input) {
26
- const v = String(input).replace(/[?#].*$/s, '').replace(/\/+$/, '').split('/').pop();
26
+ const v = String(input)
27
+ .replace(/[?#].*$/s, '')
28
+ .replace(/\/+$/, '')
29
+ .split('/')
30
+ .pop();
27
31
  if (!valid(v)) throw new UsageError(`Not a message id: ${input}`);
28
32
  return v;
29
33
  }
@@ -31,6 +35,7 @@ export function messageId(input) {
31
35
  export function streamClient({token, env = process.env, fetchImpl}) {
32
36
  const api = `${streamBase(env)}/api/v1`;
33
37
  return {
34
- call: (method, path, body, options = {}) => request(api + path, {method, token, body, fetchImpl, service: 'the stream service', ...options}),
38
+ call: (method, path, body, options = {}) =>
39
+ request(api + path, {method, token, body, fetchImpl, service: 'the stream service', ...options}),
35
40
  };
36
41
  }
package/src/version.mjs CHANGED
@@ -1,4 +1,3 @@
1
1
  import {readFileSync} from 'node:fs';
2
2
 
3
3
  export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
4
- export const USER_AGENT = `wldx-cli/${VERSION}`;
package/src/auth.mjs DELETED
@@ -1,274 +0,0 @@
1
- // The Wildix login of the user (x-bees, x-hoppers, Collaboration 7): Cognito CUSTOM_AUTH with a one-time code sent
2
- // by e-mail. Shared with the wildix-auth agent skill: one file per profile (e-mail) in $WILDIX_CONFIG_DIR/tokens (default
3
- // ~/.wildix/tokens), named <e-mail with @ as _at_>.prod.json, holding the Cognito set {IdToken, AccessToken,
4
- // RefreshToken, ExpiresIn, email, savedAt}. An expired IdToken is renewed with the RefreshToken and written back in
5
- // the same format, so the skill and the CLI keep using one session.
6
- //
7
- // The tokens are never printed and never put on a command line or into the environment of a child process.
8
- import crypto from 'node:crypto';
9
- import fs from 'node:fs';
10
- import os from 'node:os';
11
- import path from 'node:path';
12
- import {AuthError} from './errors.mjs';
13
- import {USER_AGENT} from './version.mjs';
14
-
15
- export const CLIENT_ID = '2un9vsk5ghu0ocurajqe4a6l1e';
16
- export const COGNITO_URL = 'https://cognito-idp.eu-central-1.amazonaws.com/';
17
- const RENEW_EARLY_MS = 60_000;
18
- // Cognito keeps the session of a sign-in that waits for its code for 3 minutes.
19
- export const PENDING_TTL_MS = 3 * 60_000;
20
- export const LOGIN_HINT = 'Log in with: wx login <e-mail>';
21
-
22
- export const configDir = (env = process.env) => env.WILDIX_CONFIG_DIR || path.join(os.homedir(), '.wildix');
23
- export const tokensDir = (env = process.env) => path.join(configDir(env), 'tokens');
24
-
25
- const EMAIL = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
26
-
27
- export function checkEmail(email) {
28
- if (typeof email !== 'string' || !EMAIL.test(email) || email.includes('..')) throw new AuthError(`Not a valid e-mail: ${email}`);
29
- return email.toLowerCase();
30
- }
31
-
32
- // The e-mail becomes part of a file name: letters, digits and . _ % + - @ only, no "..".
33
- export function tokenFile(email, env = process.env) {
34
- if (!/^[A-Za-z0-9._%+@-]+$/.test(email) || email.includes('..')) throw new AuthError(`Not a valid e-mail: ${email}`);
35
- return path.join(tokensDir(env), `${email.replace(/@/g, '_at_')}.prod.json`);
36
- }
37
-
38
- // A sign-in that waits for its code (wx login --send-code, then --code): the Cognito session, privately.
39
- export const pendingFile = (email, env = process.env) => path.join(tokensDir(env), `.${email.replace(/@/g, '_at_')}.pending.json`);
40
-
41
- function readJson(file) {
42
- try {
43
- return JSON.parse(fs.readFileSync(file, 'utf8'));
44
- } catch {
45
- return undefined;
46
- }
47
- }
48
-
49
- export function jwtClaims(token) {
50
- try {
51
- return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
52
- } catch {
53
- return {};
54
- }
55
- }
56
-
57
- // When the IdToken of a saved set stops being valid, in ms: its `exp` claim, else savedAt + ExpiresIn, else 0.
58
- export function expiresAt(set) {
59
- const exp = jwtClaims(set?.IdToken).exp;
60
- if (Number.isFinite(exp)) return exp * 1000;
61
- const saved = Date.parse(set?.savedAt ?? '');
62
- return Number.isFinite(saved) && Number.isFinite(set?.ExpiresIn) ? saved + set.ExpiresIn * 1000 : 0;
63
- }
64
-
65
- // The saved sessions: [{email, expiresAt}], never the tokens.
66
- export function sessions(env = process.env) {
67
- const dir = tokensDir(env);
68
- let names;
69
- try {
70
- names = fs.readdirSync(dir).filter((n) => n.endsWith('.prod.json') && !n.startsWith('.')).sort();
71
- } catch {
72
- return [];
73
- }
74
- return names
75
- .map((n) => readJson(path.join(dir, n)))
76
- .filter((set) => typeof set?.email === 'string')
77
- .map((set) => ({email: set.email, expiresAt: expiresAt(set)}));
78
- }
79
-
80
- // ── profiles ─────────────────────────────────────────────────────────────────────────────────────────────────
81
- // A profile is an e-mail logged in to Wildix. The default one is kept in $WILDIX_CONFIG_DIR/wx.json ({"defaultProfile":
82
- // e-mail}, 0600); the wildix-auth skill does not read it.
83
- export const settingsFile = (env = process.env) => path.join(configDir(env), 'wx.json');
84
- const readSettings = (env) => readJson(settingsFile(env)) ?? {};
85
- const sameEmail = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
86
-
87
- // The default profile, when it is still logged in; else undefined.
88
- export function defaultProfile(env = process.env) {
89
- const wanted = readSettings(env).defaultProfile;
90
- return wanted ? sessions(env).find((s) => sameEmail(s.email, wanted))?.email : undefined;
91
- }
92
-
93
- export function setDefaultProfile(email, env = process.env) {
94
- const settings = readSettings(env);
95
- if (email) settings.defaultProfile = email;
96
- else delete settings.defaultProfile;
97
- writePrivate(settingsFile(env), settings);
98
- }
99
-
100
- // Which profile, and why: --profile, else WILDIX_EMAIL, else the default profile, else the only one logged in.
101
- // Returns {email, by: 'option'|'environment'|'default'|'only'}.
102
- export function chooseProfile(env = process.env, profile) {
103
- const list = sessions(env);
104
- const given = profile || env.WILDIX_EMAIL;
105
- // An e-mail given in another case is the saved session of that e-mail.
106
- if (given) return {email: list.find((s) => sameEmail(s.email, given))?.email ?? given, by: profile ? 'option' : 'environment'};
107
- if (!list.length) throw new AuthError(`Not logged in to Wildix. ${LOGIN_HINT}`);
108
- const byDefault = defaultProfile(env);
109
- if (byDefault) return {email: byDefault, by: 'default'};
110
- if (list.length === 1) return {email: list[0].email, by: 'only'};
111
- throw new AuthError(
112
- `Several profiles are logged in and none is the default. Choose one for this command with --profile, or set the default: wx profile use <e-mail>\n${list.map((s) => ` ${s.email}`).join('\n')}`,
113
- );
114
- }
115
-
116
- export const pickProfile = (env = process.env, profile) => chooseProfile(env, profile).email;
117
-
118
- // The format of the skill: whole seconds, no milliseconds (its bash parses it with `date -f %Y-%m-%dT%H:%M:%SZ`).
119
- export const stamp = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
120
-
121
- function writePrivate(file, data) {
122
- const dir = path.dirname(file);
123
- fs.mkdirSync(dir, {recursive: true, mode: 0o700});
124
- try {
125
- fs.chmodSync(dir, 0o700);
126
- } catch {
127
- // not ours to change: the file itself is still 0600
128
- }
129
- const tmp = path.join(dir, `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}`);
130
- fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, {mode: 0o600, flag: 'wx'});
131
- fs.renameSync(tmp, file);
132
- }
133
-
134
- // Writes a token set privately (0600) and atomically; a set without IdToken, RefreshToken and ExpiresIn is refused.
135
- export function saveTokens(file, set) {
136
- if (typeof set?.IdToken !== 'string' || typeof set?.RefreshToken !== 'string' || typeof set?.ExpiresIn !== 'number') {
137
- throw new AuthError('The answer carried no complete set of tokens: nothing was saved.');
138
- }
139
- writePrivate(file, set);
140
- }
141
-
142
- // One call to the Cognito identity provider. Returns the answer; throws AuthError with Cognito's reason.
143
- export async function cognito(target, payload, fetchImpl = globalThis.fetch) {
144
- const res = await fetchImpl(COGNITO_URL, {
145
- method: 'POST',
146
- headers: {
147
- 'user-agent': USER_AGENT,
148
- 'content-type': 'application/x-amz-json-1.1',
149
- 'cache-control': 'no-store',
150
- 'x-amz-target': `AWSCognitoIdentityProviderService.${target}`,
151
- },
152
- body: JSON.stringify(payload),
153
- redirect: 'error',
154
- signal: AbortSignal.timeout(30_000),
155
- });
156
- const body = await res.json().catch(() => ({}));
157
- if (!res.ok || body?.__type) {
158
- const err = new AuthError(body?.message || body?.__type || `HTTP ${res.status}`);
159
- err.type = body?.__type;
160
- throw err;
161
- }
162
- return body ?? {};
163
- }
164
-
165
- async function renew(email, set, {env, fetchImpl, now}) {
166
- let body;
167
- try {
168
- body = await cognito('InitiateAuth', {AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: CLIENT_ID, AuthParameters: {REFRESH_TOKEN: set.RefreshToken}, ClientMetadata: {}}, fetchImpl);
169
- } catch (e) {
170
- if (e instanceof AuthError) throw new AuthError(`The session of ${email} could not be renewed (${e.message}). ${LOGIN_HINT}`);
171
- throw e;
172
- }
173
- const result = body.AuthenticationResult;
174
- if (typeof result?.IdToken !== 'string') throw new AuthError(`The session of ${email} could not be renewed. ${LOGIN_HINT}`);
175
- // Cognito sends no new RefreshToken on a refresh unless rotation is on for the client: then the new one is kept,
176
- // else the saved one.
177
- const next = {...result, email, savedAt: stamp(now), RefreshToken: result.RefreshToken ?? set.RefreshToken, ExpiresIn: result.ExpiresIn ?? set.ExpiresIn};
178
- saveTokens(tokenFile(email, env), next);
179
- return next;
180
- }
181
-
182
- // The saved token set of the profile as it is (not renewed), or undefined.
183
- export function savedSession(email, env = process.env) {
184
- const set = readJson(tokenFile(email, env));
185
- return typeof set?.IdToken === 'string' ? set : undefined;
186
- }
187
-
188
- // The saved token set of the profile, renewed when its IdToken expires within a minute.
189
- export async function getSession({profile, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
190
- const email = pickProfile(env, profile);
191
- const set = readJson(tokenFile(email, env));
192
- if (typeof set?.IdToken !== 'string') throw new AuthError(`No saved session for ${email}. ${LOGIN_HINT}`);
193
- if (expiresAt(set) - RENEW_EARLY_MS > now) return set;
194
- if (typeof set.RefreshToken !== 'string') throw new AuthError(`The session of ${email} has expired. ${LOGIN_HINT}`);
195
- return renew(email, set, {env, fetchImpl, now});
196
- }
197
-
198
- // A valid IdToken of the profile: WILDIX_ID_TOKEN as given (tests, CI), else the saved session (renewed).
199
- export async function getToken(options = {}) {
200
- const env = options.env ?? process.env;
201
- if (env.WILDIX_ID_TOKEN) return env.WILDIX_ID_TOKEN;
202
- return (await getSession(options)).IdToken;
203
- }
204
-
205
- // ── signing in ───────────────────────────────────────────────────────────────────────────────────────────────
206
- // Starts a sign-in: Wildix sends a one-time code to the e-mail. Returns the Cognito session that the code answers.
207
- export async function startLogin(email, fetchImpl = globalThis.fetch) {
208
- let body;
209
- try {
210
- body = await cognito('InitiateAuth', {AuthFlow: 'CUSTOM_AUTH', ClientId: CLIENT_ID, AuthParameters: {USERNAME: email}, ClientMetadata: {}}, fetchImpl);
211
- } catch (e) {
212
- if (e instanceof AuthError) throw new AuthError(`Wildix did not start a sign-in for ${email}: ${e.message}`);
213
- throw e;
214
- }
215
- if (typeof body.Session !== 'string') throw new AuthError(`Wildix did not start a sign-in for ${email}.`);
216
- return body.Session;
217
- }
218
-
219
- // Answers the code. Returns {saved: true} with the session stored, or {saved: false, session} when the code was not
220
- // accepted (Cognito answers a wrong code with a new challenge and a new session, which takes the next try).
221
- export async function finishLogin(email, session, code, {env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
222
- let body;
223
- try {
224
- body = await cognito('RespondToAuthChallenge', {
225
- ChallengeName: 'CUSTOM_CHALLENGE',
226
- ChallengeResponses: {USERNAME: email, ANSWER: code},
227
- ClientId: CLIENT_ID,
228
- Session: session,
229
- }, fetchImpl);
230
- } catch (e) {
231
- if (e instanceof AuthError) throw new AuthError(`The sign-in of ${email} failed: ${e.message}. Start again: wx login ${email}`);
232
- throw e;
233
- }
234
- const result = body.AuthenticationResult;
235
- if (typeof result?.IdToken !== 'string') return {saved: false, session: body.Session};
236
- saveTokens(tokenFile(email, env), {...result, email, savedAt: stamp(now)});
237
- // The first profile becomes the default one; a second login does not change it.
238
- const isDefault = !defaultProfile(env) || sameEmail(defaultProfile(env), email);
239
- if (!defaultProfile(env)) setDefaultProfile(email, env);
240
- return {saved: true, isDefault};
241
- }
242
-
243
- export function savePending(email, session, {env = process.env, now = Date.now()} = {}) {
244
- writePrivate(pendingFile(email, env), {email, session, startedAt: now});
245
- }
246
-
247
- // The waiting sign-in of the e-mail, or undefined when there is none or it is older than Cognito keeps it.
248
- export function readPending(email, {env = process.env, now = Date.now()} = {}) {
249
- const p = readJson(pendingFile(email, env));
250
- if (typeof p?.session !== 'string' || !(now - p.startedAt < PENDING_TTL_MS)) return undefined;
251
- return p;
252
- }
253
-
254
- export function dropPending(email, env = process.env) {
255
- fs.rmSync(pendingFile(email, env), {force: true});
256
- }
257
-
258
- // ── signing out ──────────────────────────────────────────────────────────────────────────────────────────────
259
- // Revokes the refresh token of the profile (this device) and deletes its local session. With everywhere, signs the
260
- // profile out of every device (GlobalSignOut, which needs a valid AccessToken). An error leaves the local session
261
- // as it was: it is still valid, and the call can be repeated.
262
- export async function logout(email, {everywhere = false, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
263
- const file = tokenFile(email, env);
264
- const set = readJson(file);
265
- if (!set) throw new AuthError(`No saved session for ${email}.`);
266
- if (everywhere) {
267
- const fresh = await getSession({profile: email, env, fetchImpl, now});
268
- await cognito('GlobalSignOut', {AccessToken: fresh.AccessToken}, fetchImpl);
269
- } else {
270
- await cognito('RevokeToken', {Token: set.RefreshToken, ClientId: CLIENT_ID}, fetchImpl);
271
- }
272
- fs.rmSync(file, {force: true});
273
- if (sameEmail(readSettings(env).defaultProfile ?? '', email)) setDefaultProfile(undefined, env);
274
- }
@@ -1,126 +0,0 @@
1
- // wx login, wx logout: the Wildix login of the user (a one-time code sent by e-mail).
2
- import {createInterface} from 'node:readline/promises';
3
- import {
4
- PENDING_TTL_MS,
5
- checkEmail,
6
- defaultProfile,
7
- dropPending,
8
- finishLogin,
9
- logout as signOut,
10
- pickProfile,
11
- readPending,
12
- savePending,
13
- startLogin,
14
- } from '../auth.mjs';
15
- import {AuthError, UsageError} from '../errors.mjs';
16
-
17
- const TRIES = 3;
18
-
19
- // The answer of a login: the profile, and whether it is the default one (the first login makes it so).
20
- const loggedIn = (ctx, email, r) => ({profile: email, loggedIn: true, default: r.isDefault, defaultProfile: defaultProfile(ctx.env) ?? null});
21
-
22
- function checkCode(code) {
23
- const c = String(code).trim();
24
- if (!/^[A-Za-z0-9]{4,12}$/.test(c)) throw new UsageError(`Not a code from the e-mail: ${code}`);
25
- return c;
26
- }
27
-
28
- // The prompt goes to stderr (stdout is data); the answer is the next line of stdin (lines are buffered, so typed
29
- // ahead or piped answers are not lost).
30
- async function ask(lines, ctx, question) {
31
- ctx.stderr.write(question);
32
- const next = await lines.next();
33
- if (next.done) throw new AuthError('No answer: stdin was closed. Nothing was saved.');
34
- return next.value.trim();
35
- }
36
-
37
- async function interactive(ctx, emailArg) {
38
- const rl = createInterface({input: ctx.stdin, terminal: false});
39
- const lines = rl[Symbol.asyncIterator]();
40
- try {
41
- const email = checkEmail(emailArg ?? (await ask(lines, ctx, 'E-mail of your Wildix account: ')));
42
- let session = await startLogin(email, ctx.fetch);
43
- ctx.info(`A code was sent to ${email}.`);
44
- for (let attempt = 1; attempt <= TRIES; attempt += 1) {
45
- const code = checkCode(await ask(lines, ctx, 'Code from the e-mail: '));
46
- const r = await finishLogin(email, session, code, {env: ctx.env, fetchImpl: ctx.fetch, now: ctx.clock()});
47
- if (r.saved) return loggedIn(ctx, email, r);
48
- session = r.session;
49
- if (!session) break;
50
- if (attempt < TRIES) ctx.info('The code was not accepted. Try again.');
51
- }
52
- throw new AuthError(`The code was not accepted. Start again: wx login ${email}`);
53
- } finally {
54
- rl.close();
55
- }
56
- }
57
-
58
- export const login = {
59
- summary: 'log in with a one-time code sent to your e-mail',
60
- usage: 'wx login [<e-mail>] [--send-code | --code CODE]',
61
- details: [
62
- 'In a terminal, wx login asks for the code and waits. Without one (scripts, agents), sign in in two steps:',
63
- ' wx login <e-mail> --send-code Wildix e-mails a code',
64
- ' wx login <e-mail> --code <code> within 3 minutes',
65
- 'The session is shared with the wildix-auth agent skill (~/.wildix/tokens).',
66
- ].join('\n'),
67
- options: {
68
- 'send-code': {type: 'boolean', description: 'only send the code; finish later with --code'},
69
- code: {type: 'string', placeholder: 'CODE', description: 'the code from the e-mail, after --send-code'},
70
- },
71
- args: [0, 1],
72
- async run(ctx) {
73
- const {values, positionals} = ctx;
74
- if (values['send-code'] && values.code) throw new UsageError('Give --send-code or --code, not both', ctx.usage());
75
- if (values['send-code'] || values.code) {
76
- if (!positionals[0]) throw new UsageError('The e-mail is needed', ctx.usage());
77
- const email = checkEmail(positionals[0]);
78
- const now = ctx.clock();
79
- if (values['send-code']) {
80
- savePending(email, await startLogin(email, ctx.fetch), {env: ctx.env, now});
81
- return {profile: email, codeSent: true, expiresAt: new Date(now + PENDING_TTL_MS).toISOString()};
82
- }
83
- const code = checkCode(values.code);
84
- const pending = readPending(email, {env: ctx.env, now});
85
- if (!pending) throw new AuthError(`No sign-in waits for a code for ${email} (or it is older than 3 minutes). Start with: wx login ${email} --send-code`);
86
- const r = await finishLogin(email, pending.session, code, {env: ctx.env, fetchImpl: ctx.fetch, now});
87
- if (r.saved) {
88
- dropPending(email, ctx.env);
89
- return loggedIn(ctx, email, r);
90
- }
91
- if (r.session) savePending(email, r.session, {env: ctx.env, now: pending.startedAt});
92
- else dropPending(email, ctx.env);
93
- throw new AuthError(`The code was not accepted.${r.session ? ` Try again: wx login ${email} --code <code>` : ` Start again: wx login ${email} --send-code`}`);
94
- }
95
- if (!ctx.stdin.isTTY) {
96
- throw new UsageError('No terminal to ask for the code: use wx login <e-mail> --send-code, then wx login <e-mail> --code <code>', ctx.usage());
97
- }
98
- return interactive(ctx, positionals[0]);
99
- },
100
- text(r) {
101
- if (r.codeSent) return `A code was sent to ${r.profile}. Finish before ${r.expiresAt}: wx login ${r.profile} --code <code>`;
102
- if (r.default) return `Logged in as ${r.profile} (the default profile)`;
103
- return [`Logged in as ${r.profile}. The default profile stays ${r.defaultProfile}.`, `Use it with --profile ${r.profile}, or make it the default: wx profile use ${r.profile}`];
104
- },
105
- };
106
-
107
- export const logout = {
108
- summary: 'log out: revoke the session of this device, or of every device',
109
- usage: 'wx logout [--profile EMAIL] [--everywhere]',
110
- options: {
111
- everywhere: {type: 'boolean', description: 'sign out of every device and app, not only this one'},
112
- },
113
- args: [0, 0],
114
- async run(ctx) {
115
- const email = pickProfile(ctx.env, ctx.values.profile);
116
- const everywhere = Boolean(ctx.values.everywhere);
117
- try {
118
- await signOut(email, {everywhere, env: ctx.env, fetchImpl: ctx.fetch, now: ctx.clock()});
119
- } catch (e) {
120
- if (e instanceof AuthError) throw new AuthError(`${email} was NOT logged out (${e.message}). The local session is kept.`);
121
- throw e;
122
- }
123
- return {profile: email, loggedOut: true, everywhere};
124
- },
125
- text: (r) => `Logged out ${r.profile} (${r.everywhere ? 'every device' : 'this device'})`,
126
- };