@vex-chat/cli 0.3.0 → 0.5.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 (3) hide show
  1. package/README.md +11 -5
  2. package/package.json +2 -2
  3. package/src/vex-chat.js +84 -187
package/README.md CHANGED
@@ -6,12 +6,18 @@ Terminal chat client for Vex backed by `@vex-chat/libvex`.
6
6
  pnpm vex
7
7
  ```
8
8
 
9
- On first run, `vex` prompts for a username and registers a local device key.
10
- After that it opens straight into a live chat session.
9
+ On first run, `vex` prompts for a username and password, registers a local
10
+ device key, and opens straight into a live chat session.
11
11
 
12
12
  By default the CLI connects to production at `api.vex.wtf`. For local Spire development, pass `--local` to use `127.0.0.1:16777` over http/ws. Custom targets can use `--api-url <url>` to set both the host and protocol, or `--host <host:port> --http` to set them separately.
13
13
 
14
- When creating a new account, the CLI opens a browser passkey page to attach the account's first passkey before connecting. When adding this machine to an account that already exists, the same page can restore the pending device with a saved passkey. Use `--passkey-url <url>` or `VEX_CHAT_PASSKEY_URL` when the WebAuthn page is hosted somewhere other than the API origin; use `--no-browser` or `VEX_CHAT_NO_BROWSER=1` to print the URL without launching a browser.
14
+ New accounts use the password you set during registration. Passkeys can be added
15
+ later as a supplemental recovery/sign-in method. When adding this machine to an
16
+ account that already exists, the browser passkey page can restore the pending
17
+ device with a saved passkey. Use `--passkey-url <url>` or
18
+ `VEX_CHAT_PASSKEY_URL` when the WebAuthn page is hosted somewhere other than the
19
+ API origin; use `--no-browser` or `VEX_CHAT_NO_BROWSER=1` to print the URL
20
+ without launching a browser.
15
21
 
16
22
  Inside the app:
17
23
 
@@ -43,8 +49,8 @@ Inside the app:
43
49
  Scriptable helpers still exist:
44
50
 
45
51
  ```sh
46
- pnpm vex auth register alice
47
- pnpm vex auth register bob
52
+ pnpm vex auth register alice alice-password
53
+ pnpm vex auth register bob bob-password
48
54
  pnpm vex alice
49
55
  pnpm vex bob
50
56
  pnpm vex auth accounts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal client for vex-chat.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "better-sqlite3": "11.10.0",
33
33
  "msgpackr": "^1.11.9",
34
- "@vex-chat/libvex": "^7.1.2"
34
+ "@vex-chat/libvex": "^8.0.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",
package/src/vex-chat.js CHANGED
@@ -145,6 +145,9 @@ async function main() {
145
145
  case "invite":
146
146
  await inviteCommand(ctx, positionals);
147
147
  break;
148
+ case "entitlements":
149
+ await entitlementsCommand(ctx, positionals);
150
+ break;
148
151
  case "group":
149
152
  await groupCommand(ctx, positionals);
150
153
  break;
@@ -176,6 +179,7 @@ function isCommand(command) {
176
179
  "channels",
177
180
  "chat",
178
181
  "dm",
182
+ "entitlements",
179
183
  "group",
180
184
  "help",
181
185
  "invite",
@@ -285,6 +289,7 @@ async function createContext(flags) {
285
289
  return {
286
290
  dataDir,
287
291
  configPath,
292
+ flags,
288
293
  clientOptions: {
289
294
  dbFolder: path.join(dataDir, "db"),
290
295
  deviceName: "vex-chat-cli",
@@ -377,6 +382,11 @@ function httpFromApiUrl(raw) {
377
382
  }
378
383
  }
379
384
 
385
+ function apiBaseUrl(ctx) {
386
+ const scheme = ctx.clientOptions.unsafeHttp ? "http" : "https";
387
+ return `${scheme}://${ctx.clientOptions.host}`;
388
+ }
389
+
380
390
  function normalizeAccountHost(host) {
381
391
  return String(host ?? DEFAULT_HOST)
382
392
  .trim()
@@ -583,96 +593,6 @@ function isRemovedStoredDeviceError(err) {
583
593
  return err?.name === "RemovedStoredDeviceError";
584
594
  }
585
595
 
586
- function getClientBearerToken(client) {
587
- if (typeof client.token === "string" && client.token.length > 0) {
588
- return client.token;
589
- }
590
- const authorization =
591
- client.http?.defaults?.headers?.common?.Authorization ??
592
- client.http?.defaults?.headers?.common?.authorization;
593
- if (typeof authorization !== "string") {
594
- return null;
595
- }
596
- const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
597
- return match?.[1] ?? null;
598
- }
599
-
600
- function responseBodyMessage(data) {
601
- if (!data || typeof data !== "object" || ArrayBuffer.isView(data)) {
602
- return null;
603
- }
604
- if (data instanceof ArrayBuffer) {
605
- return null;
606
- }
607
- const error = data.error;
608
- if (typeof error === "string") return error;
609
- if (error && typeof error === "object") {
610
- const nestedMessage = error.message;
611
- if (typeof nestedMessage === "string") return nestedMessage;
612
- }
613
- const message = data.message;
614
- return typeof message === "string" ? message : null;
615
- }
616
-
617
- function bytesFromResponseBody(data) {
618
- if (ArrayBuffer.isView(data)) {
619
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
620
- }
621
- if (data instanceof ArrayBuffer) {
622
- return new Uint8Array(data);
623
- }
624
- return null;
625
- }
626
-
627
- function decodeBinaryResponseBody(data) {
628
- const bytes = bytesFromResponseBody(data);
629
- if (!bytes) return null;
630
- try {
631
- return unpack(bytes);
632
- } catch {
633
- // Express JSON error bodies also arrive as ArrayBuffer with fetch.
634
- }
635
- const text = new TextDecoder("utf-8", { fatal: false })
636
- .decode(bytes)
637
- .trim();
638
- if (!text) return null;
639
- if (text.startsWith("{") || text.startsWith("[")) {
640
- try {
641
- return JSON.parse(text);
642
- } catch {
643
- return text;
644
- }
645
- }
646
- return text;
647
- }
648
-
649
- function errorResponseMessage(err) {
650
- const data = err?.response?.data;
651
- const binaryBody = decodeBinaryResponseBody(data);
652
- const binaryMessage = responseBodyMessage(binaryBody);
653
- if (binaryMessage) return binaryMessage;
654
- if (typeof binaryBody === "string") return binaryBody;
655
- const objectMessage = responseBodyMessage(data);
656
- if (objectMessage) return objectMessage;
657
- if (typeof data === "string") {
658
- try {
659
- const parsed = JSON.parse(data);
660
- const jsonMessage = responseBodyMessage(parsed);
661
- if (jsonMessage) return jsonMessage;
662
- } catch {
663
- return data;
664
- }
665
- }
666
- return err instanceof Error ? err.message : String(err ?? "");
667
- }
668
-
669
- function isInitialPasskeySetupRequired(err) {
670
- if (err?.response?.status !== 403) return false;
671
- return /passkey must be registered|passkey setup is required/i.test(
672
- errorResponseMessage(err),
673
- );
674
- }
675
-
676
596
  async function removeStoredDeviceAccount(ctx, config, accountRef) {
677
597
  delete config.accounts[accountRef.key];
678
598
  if (config.lastUsername === accountRef.key) {
@@ -687,11 +607,34 @@ function removedStoredDeviceError(ctx, username) {
687
607
  return err;
688
608
  }
689
609
 
610
+ async function resolveRegistrationPassword(ctx, args, rl = null) {
611
+ const provided = args[1] ?? ctx.password;
612
+ if (typeof provided === "string" && provided.trim().length > 0) {
613
+ return provided;
614
+ }
615
+ if (input.isTTY && output.isTTY) {
616
+ if (rl) {
617
+ const password = await askText(rl, "password");
618
+ if (password) return password;
619
+ } else {
620
+ const prompt = createInterface({ input, output });
621
+ try {
622
+ const password = await askText(prompt, "password");
623
+ if (password) return password;
624
+ } finally {
625
+ prompt.close();
626
+ }
627
+ }
628
+ }
629
+ throw new Error(
630
+ "Password is required to register a new account. Usage: vex auth register <username> <password> or pass --password <password>.",
631
+ );
632
+ }
633
+
690
634
  async function register(ctx, args) {
691
635
  const requestedUsername = args[0] ?? ctx.username;
692
- const password = args[1] ?? ctx.password;
693
636
  if (!requestedUsername) {
694
- throw new Error("Usage: vex-chat register <username> [password]");
637
+ throw new Error("Usage: vex-chat register <username> <password>");
695
638
  }
696
639
  const config = await readConfig(ctx.configPath);
697
640
  const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
@@ -703,6 +646,7 @@ async function register(ctx, args) {
703
646
  `Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
704
647
  );
705
648
  }
649
+ const password = await resolveRegistrationPassword(ctx, args);
706
650
  const privateKey = Client.generateSecretKey();
707
651
  const client = await Client.create(privateKey, ctx.clientOptions);
708
652
  attachDebugClientEvents(ctx, client, `register:${username}`);
@@ -790,20 +734,6 @@ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
790
734
  return url.toString();
791
735
  }
792
736
 
793
- function buildPasskeyRegistrationUrl(ctx, { token, userID, username }) {
794
- const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
795
- url.hash = new URLSearchParams({
796
- api: apiBaseUrl(ctx),
797
- device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
798
- mode: "register",
799
- name: ctx.clientOptions.deviceName ?? "vex-chat-cli",
800
- token,
801
- user: userID,
802
- username,
803
- }).toString();
804
- return url.toString();
805
- }
806
-
807
737
  async function openExternalUrl(url) {
808
738
  const [command, args] =
809
739
  process.platform === "darwin"
@@ -853,68 +783,6 @@ async function launchPasskeyLogin(ctx, username, pending, code) {
853
783
  }
854
784
  }
855
785
 
856
- async function launchInitialPasskeySetup(ctx, client, username) {
857
- const token = getClientBearerToken(client);
858
- if (!token) {
859
- throw new Error(
860
- "Passkey setup is required, but the CLI could not read the registration token.",
861
- );
862
- }
863
- const userID = client.me.user().userID;
864
- let url;
865
- try {
866
- url = buildPasskeyRegistrationUrl(ctx, {
867
- token,
868
- userID,
869
- username,
870
- });
871
- } catch (err) {
872
- throw new Error(
873
- `Could not build passkey setup URL: ${err instanceof Error ? err.message : String(err)}`,
874
- );
875
- }
876
-
877
- console.log(
878
- `${color(ROOT_ACCENT, "passkey setup")} ${color("dim", "opens in your browser")}`,
879
- );
880
- console.log(
881
- color(
882
- "dim",
883
- "New accounts must add a passkey before the CLI can connect.",
884
- ),
885
- );
886
- console.log(`${color("dim", "url")} ${url}`);
887
- if (ctx.openBrowser) {
888
- const opened = await openExternalUrl(url);
889
- if (!opened) {
890
- console.log(
891
- color(
892
- "yellow",
893
- "Could not open a browser. Copy the URL above.",
894
- ),
895
- );
896
- }
897
- }
898
-
899
- console.log(color(ROOT_ACCENT, "waiting for passkey setup..."));
900
- for (let attempt = 0; attempt < 300; attempt++) {
901
- await sleep(2000);
902
- const passkeys = await client.passkeys.list().catch((err) => {
903
- debugLog(ctx, "passkey.register.poll.error", {
904
- error: err,
905
- username,
906
- });
907
- return [];
908
- });
909
- if (passkeys.length > 0) {
910
- if (input.isTTY && output.isTTY) output.write("\n");
911
- return;
912
- }
913
- if (input.isTTY && output.isTTY) output.write(color("dim", "."));
914
- }
915
- throw new Error("Timed out waiting for passkey setup.");
916
- }
917
-
918
786
  async function persistPendingLocalAccount(
919
787
  ctx,
920
788
  config,
@@ -1265,7 +1133,7 @@ async function authCommand(ctx, args) {
1265
1133
  return;
1266
1134
  default:
1267
1135
  throw new Error(
1268
- "Usage: vex auth register <username> | login <username> [password] | requests | use <username> | accounts | status",
1136
+ "Usage: vex auth register <username> <password> | login <username> [password] | requests | use <username> | accounts | status",
1269
1137
  );
1270
1138
  }
1271
1139
  }
@@ -1495,6 +1363,47 @@ async function inviteCommand(ctx, args) {
1495
1363
  });
1496
1364
  }
1497
1365
 
1366
+ async function entitlementsCommand(ctx, args) {
1367
+ const sub = args.shift() ?? "status";
1368
+ if (sub === "set" || sub === "grant") {
1369
+ const userID = requireArg(args, 0, "user id");
1370
+ const tier = requireArg(args, 1, "tier");
1371
+ if (!["free", "plus", "pro"].includes(tier)) {
1372
+ throw new Error("Tier must be one of: free, plus, pro.");
1373
+ }
1374
+ const devKey = ctx.clientOptions.devApiKey;
1375
+ if (!devKey) {
1376
+ throw new Error(
1377
+ "A dev key is required. Pass --dev-key or set DEV_API_KEY.",
1378
+ );
1379
+ }
1380
+ const expiresAt = ctx.flags["expires-at"]
1381
+ ? String(ctx.flags["expires-at"])
1382
+ : null;
1383
+ const res = await fetch(`${apiBaseUrl(ctx)}/__dev/billing/grants`, {
1384
+ body: JSON.stringify({ expiresAt, tier, userID }),
1385
+ headers: {
1386
+ "Content-Type": "application/json",
1387
+ "x-dev-api-key": devKey,
1388
+ },
1389
+ method: "POST",
1390
+ });
1391
+ if (!res.ok) {
1392
+ throw new Error(
1393
+ `Entitlement grant failed with HTTP ${String(res.status)}: ${await res.text()}`,
1394
+ );
1395
+ }
1396
+ const body = await res.json();
1397
+ console.log(
1398
+ `${color(ROOT_ACCENT, "granted")} ${color("bold", tier)} ${color("dim", `to ${userID}`)} ${color("dim", `source=${body.source ?? "unknown"}`)}`,
1399
+ );
1400
+ return;
1401
+ }
1402
+ throw new Error(
1403
+ "Usage: vex entitlements set <user-id> <free|plus|pro> [--expires-at <iso>] --dev-key <key>",
1404
+ );
1405
+ }
1406
+
1498
1407
  async function groupCommand(ctx, args) {
1499
1408
  const sub = args[0];
1500
1409
  if (sub === "send") {
@@ -3366,11 +3275,12 @@ async function authenticateOrRegister(ctx, explicitUsername) {
3366
3275
  if (answer && answer !== "y" && answer !== "yes") {
3367
3276
  throw new Error("No local account selected.");
3368
3277
  }
3278
+ const password = await resolveRegistrationPassword(ctx, [entered], rl);
3369
3279
  const privateKey = Client.generateSecretKey();
3370
3280
  const client = await Client.create(privateKey, ctx.clientOptions);
3371
3281
  attachDebugClientEvents(ctx, client, `register:${entered}`);
3372
3282
  try {
3373
- const [, registerErr] = await client.register(entered);
3283
+ const [, registerErr] = await client.register(entered, password);
3374
3284
  if (registerErr) throw registerErr;
3375
3285
  await persistNewLocalAccount(
3376
3286
  ctx,
@@ -3423,21 +3333,7 @@ async function connectAndWait(client, ctx = null, label = "client") {
3423
3333
  debugLog(ctx, "client.connect.skip.connected", { label });
3424
3334
  return;
3425
3335
  }
3426
- try {
3427
- await connectOnceAndWait(client, ctx, label);
3428
- return;
3429
- } catch (err) {
3430
- if (!ctx || !isInitialPasskeySetupRequired(err)) {
3431
- throw err;
3432
- }
3433
- const username = client.me.user().username ?? "account";
3434
- debugLog(ctx, "client.connect.passkeySetup.required", {
3435
- label,
3436
- username,
3437
- });
3438
- await launchInitialPasskeySetup(ctx, client, username);
3439
- await connectOnceAndWait(client, ctx, `${label}:passkey`);
3440
- }
3336
+ await connectOnceAndWait(client, ctx, label);
3441
3337
  }
3442
3338
 
3443
3339
  async function connectOnceAndWait(client, ctx = null, label = "client") {
@@ -4637,17 +4533,18 @@ Commands:
4637
4533
  vex open the live terminal chat app
4638
4534
  vex <username> open as a specific local user
4639
4535
  vex chat [username] open the live terminal chat app
4640
- vex auth register <username>
4536
+ vex auth register <username> <password>
4641
4537
  vex auth login <username> request approval as a second device
4642
4538
  vex auth requests list pending device login requests
4643
4539
  vex auth accounts
4644
4540
  vex auth use <username>
4541
+ vex entitlements set <user-id> <free|plus|pro> --dev-key <key>
4645
4542
  vex whoami
4646
4543
 
4647
4544
  Flags:
4648
4545
  --username <name> local account to use
4649
4546
  --user <name> alias for --username
4650
- --password <password> fallback password for login
4547
+ --password <password> fallback password for register/login
4651
4548
  --api-url <url> API base URL, e.g. http://127.0.0.1:16777
4652
4549
  --host <host:port> API host, default api.vex.wtf
4653
4550
  --local connect to local Spire at 127.0.0.1:16777 over http/ws