@vex-chat/cli 0.4.0 → 0.6.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 +48 -198
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.4.0",
3
+ "version": "0.6.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.4.0"
34
+ "@vex-chat/libvex": "^9.0.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",
package/src/vex-chat.js CHANGED
@@ -593,96 +593,6 @@ function isRemovedStoredDeviceError(err) {
593
593
  return err?.name === "RemovedStoredDeviceError";
594
594
  }
595
595
 
596
- function getClientBearerToken(client) {
597
- if (typeof client.token === "string" && client.token.length > 0) {
598
- return client.token;
599
- }
600
- const authorization =
601
- client.http?.defaults?.headers?.common?.Authorization ??
602
- client.http?.defaults?.headers?.common?.authorization;
603
- if (typeof authorization !== "string") {
604
- return null;
605
- }
606
- const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
607
- return match?.[1] ?? null;
608
- }
609
-
610
- function responseBodyMessage(data) {
611
- if (!data || typeof data !== "object" || ArrayBuffer.isView(data)) {
612
- return null;
613
- }
614
- if (data instanceof ArrayBuffer) {
615
- return null;
616
- }
617
- const error = data.error;
618
- if (typeof error === "string") return error;
619
- if (error && typeof error === "object") {
620
- const nestedMessage = error.message;
621
- if (typeof nestedMessage === "string") return nestedMessage;
622
- }
623
- const message = data.message;
624
- return typeof message === "string" ? message : null;
625
- }
626
-
627
- function bytesFromResponseBody(data) {
628
- if (ArrayBuffer.isView(data)) {
629
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
630
- }
631
- if (data instanceof ArrayBuffer) {
632
- return new Uint8Array(data);
633
- }
634
- return null;
635
- }
636
-
637
- function decodeBinaryResponseBody(data) {
638
- const bytes = bytesFromResponseBody(data);
639
- if (!bytes) return null;
640
- try {
641
- return unpack(bytes);
642
- } catch {
643
- // Express JSON error bodies also arrive as ArrayBuffer with fetch.
644
- }
645
- const text = new TextDecoder("utf-8", { fatal: false })
646
- .decode(bytes)
647
- .trim();
648
- if (!text) return null;
649
- if (text.startsWith("{") || text.startsWith("[")) {
650
- try {
651
- return JSON.parse(text);
652
- } catch {
653
- return text;
654
- }
655
- }
656
- return text;
657
- }
658
-
659
- function errorResponseMessage(err) {
660
- const data = err?.response?.data;
661
- const binaryBody = decodeBinaryResponseBody(data);
662
- const binaryMessage = responseBodyMessage(binaryBody);
663
- if (binaryMessage) return binaryMessage;
664
- if (typeof binaryBody === "string") return binaryBody;
665
- const objectMessage = responseBodyMessage(data);
666
- if (objectMessage) return objectMessage;
667
- if (typeof data === "string") {
668
- try {
669
- const parsed = JSON.parse(data);
670
- const jsonMessage = responseBodyMessage(parsed);
671
- if (jsonMessage) return jsonMessage;
672
- } catch {
673
- return data;
674
- }
675
- }
676
- return err instanceof Error ? err.message : String(err ?? "");
677
- }
678
-
679
- function isInitialPasskeySetupRequired(err) {
680
- if (err?.response?.status !== 403) return false;
681
- return /passkey must be registered|passkey setup is required/i.test(
682
- errorResponseMessage(err),
683
- );
684
- }
685
-
686
596
  async function removeStoredDeviceAccount(ctx, config, accountRef) {
687
597
  delete config.accounts[accountRef.key];
688
598
  if (config.lastUsername === accountRef.key) {
@@ -697,11 +607,37 @@ function removedStoredDeviceError(ctx, username) {
697
607
  return err;
698
608
  }
699
609
 
610
+ async function resolveRegistrationPassword(
611
+ ctx,
612
+ args,
613
+ rl = null,
614
+ missingMessage = "Password is required to register a new account. Usage: vex auth register <username> <password> or pass --password <password>.",
615
+ ) {
616
+ const provided = args[1] ?? ctx.password;
617
+ if (typeof provided === "string" && provided.trim().length > 0) {
618
+ return provided;
619
+ }
620
+ if (input.isTTY && output.isTTY) {
621
+ if (rl) {
622
+ const password = await askText(rl, "password");
623
+ if (password) return password;
624
+ } else {
625
+ const prompt = createInterface({ input, output });
626
+ try {
627
+ const password = await askText(prompt, "password");
628
+ if (password) return password;
629
+ } finally {
630
+ prompt.close();
631
+ }
632
+ }
633
+ }
634
+ throw new Error(missingMessage);
635
+ }
636
+
700
637
  async function register(ctx, args) {
701
638
  const requestedUsername = args[0] ?? ctx.username;
702
- const password = args[1] ?? ctx.password;
703
639
  if (!requestedUsername) {
704
- throw new Error("Usage: vex-chat register <username> [password]");
640
+ throw new Error("Usage: vex-chat register <username> <password>");
705
641
  }
706
642
  const config = await readConfig(ctx.configPath);
707
643
  const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
@@ -713,6 +649,7 @@ async function register(ctx, args) {
713
649
  `Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
714
650
  );
715
651
  }
652
+ const password = await resolveRegistrationPassword(ctx, args);
716
653
  const privateKey = Client.generateSecretKey();
717
654
  const client = await Client.create(privateKey, ctx.clientOptions);
718
655
  attachDebugClientEvents(ctx, client, `register:${username}`);
@@ -800,20 +737,6 @@ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
800
737
  return url.toString();
801
738
  }
802
739
 
803
- function buildPasskeyRegistrationUrl(ctx, { token, userID, username }) {
804
- const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
805
- url.hash = new URLSearchParams({
806
- api: apiBaseUrl(ctx),
807
- device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
808
- mode: "register",
809
- name: ctx.clientOptions.deviceName ?? "vex-chat-cli",
810
- token,
811
- user: userID,
812
- username,
813
- }).toString();
814
- return url.toString();
815
- }
816
-
817
740
  async function openExternalUrl(url) {
818
741
  const [command, args] =
819
742
  process.platform === "darwin"
@@ -863,68 +786,6 @@ async function launchPasskeyLogin(ctx, username, pending, code) {
863
786
  }
864
787
  }
865
788
 
866
- async function launchInitialPasskeySetup(ctx, client, username) {
867
- const token = getClientBearerToken(client);
868
- if (!token) {
869
- throw new Error(
870
- "Passkey setup is required, but the CLI could not read the registration token.",
871
- );
872
- }
873
- const userID = client.me.user().userID;
874
- let url;
875
- try {
876
- url = buildPasskeyRegistrationUrl(ctx, {
877
- token,
878
- userID,
879
- username,
880
- });
881
- } catch (err) {
882
- throw new Error(
883
- `Could not build passkey setup URL: ${err instanceof Error ? err.message : String(err)}`,
884
- );
885
- }
886
-
887
- console.log(
888
- `${color(ROOT_ACCENT, "passkey setup")} ${color("dim", "opens in your browser")}`,
889
- );
890
- console.log(
891
- color(
892
- "dim",
893
- "New accounts must add a passkey before the CLI can connect.",
894
- ),
895
- );
896
- console.log(`${color("dim", "url")} ${url}`);
897
- if (ctx.openBrowser) {
898
- const opened = await openExternalUrl(url);
899
- if (!opened) {
900
- console.log(
901
- color(
902
- "yellow",
903
- "Could not open a browser. Copy the URL above.",
904
- ),
905
- );
906
- }
907
- }
908
-
909
- console.log(color(ROOT_ACCENT, "waiting for passkey setup..."));
910
- for (let attempt = 0; attempt < 300; attempt++) {
911
- await sleep(2000);
912
- const passkeys = await client.passkeys.list().catch((err) => {
913
- debugLog(ctx, "passkey.register.poll.error", {
914
- error: err,
915
- username,
916
- });
917
- return [];
918
- });
919
- if (passkeys.length > 0) {
920
- if (input.isTTY && output.isTTY) output.write("\n");
921
- return;
922
- }
923
- if (input.isTTY && output.isTTY) output.write(color("dim", "."));
924
- }
925
- throw new Error("Timed out waiting for passkey setup.");
926
- }
927
-
928
789
  async function persistPendingLocalAccount(
929
790
  ctx,
930
791
  config,
@@ -1020,11 +881,20 @@ async function loginWithDeviceApproval(ctx, username) {
1020
881
  }
1021
882
 
1022
883
  const { username: accountUsername } = accountRef;
884
+ const password = await resolveRegistrationPassword(
885
+ ctx,
886
+ [accountUsername],
887
+ null,
888
+ "Password is required to sign in on a new device. Pass it as the second argument or with --password.",
889
+ );
1023
890
  const privateKey = Client.generateSecretKey();
1024
891
  const client = await Client.create(privateKey, ctx.clientOptions);
1025
892
  attachDebugClientEvents(ctx, client, `login-request:${accountUsername}`);
1026
893
  try {
1027
- const [, registerErr] = await client.register(accountUsername);
894
+ const [, registerErr] = await client.requestDeviceEnrollment(
895
+ accountUsername,
896
+ password,
897
+ );
1028
898
  if (!registerErr) {
1029
899
  await persistNewLocalAccount(
1030
900
  ctx,
@@ -1098,9 +968,7 @@ async function waitForDeviceApproval(
1098
968
  }
1099
969
 
1100
970
  const code = matchingCodeStringForSignKey(client.getKeys().public);
1101
- console.log(
1102
- `${color(ROOT_ACCENT, "device approval required")} ${color("dim", `request=${pending.requestID}`)}`,
1103
- );
971
+ console.log(color(ROOT_ACCENT, "device approval required"));
1104
972
  console.log(
1105
973
  `${color("dim", "matching code")} ${formatDeviceApprovalCode(code)}`,
1106
974
  );
@@ -1275,7 +1143,7 @@ async function authCommand(ctx, args) {
1275
1143
  return;
1276
1144
  default:
1277
1145
  throw new Error(
1278
- "Usage: vex auth register <username> | login <username> [password] | requests | use <username> | accounts | status",
1146
+ "Usage: vex auth register <username> <password> | login <username> [password] | requests | use <username> | accounts | status",
1279
1147
  );
1280
1148
  }
1281
1149
  }
@@ -3417,11 +3285,12 @@ async function authenticateOrRegister(ctx, explicitUsername) {
3417
3285
  if (answer && answer !== "y" && answer !== "yes") {
3418
3286
  throw new Error("No local account selected.");
3419
3287
  }
3288
+ const password = await resolveRegistrationPassword(ctx, [entered], rl);
3420
3289
  const privateKey = Client.generateSecretKey();
3421
3290
  const client = await Client.create(privateKey, ctx.clientOptions);
3422
3291
  attachDebugClientEvents(ctx, client, `register:${entered}`);
3423
3292
  try {
3424
- const [, registerErr] = await client.register(entered);
3293
+ const [, registerErr] = await client.register(entered, password);
3425
3294
  if (registerErr) throw registerErr;
3426
3295
  await persistNewLocalAccount(
3427
3296
  ctx,
@@ -3474,21 +3343,7 @@ async function connectAndWait(client, ctx = null, label = "client") {
3474
3343
  debugLog(ctx, "client.connect.skip.connected", { label });
3475
3344
  return;
3476
3345
  }
3477
- try {
3478
- await connectOnceAndWait(client, ctx, label);
3479
- return;
3480
- } catch (err) {
3481
- if (!ctx || !isInitialPasskeySetupRequired(err)) {
3482
- throw err;
3483
- }
3484
- const username = client.me.user().username ?? "account";
3485
- debugLog(ctx, "client.connect.passkeySetup.required", {
3486
- label,
3487
- username,
3488
- });
3489
- await launchInitialPasskeySetup(ctx, client, username);
3490
- await connectOnceAndWait(client, ctx, `${label}:passkey`);
3491
- }
3346
+ await connectOnceAndWait(client, ctx, label);
3492
3347
  }
3493
3348
 
3494
3349
  async function connectOnceAndWait(client, ctx = null, label = "client") {
@@ -4688,7 +4543,7 @@ Commands:
4688
4543
  vex open the live terminal chat app
4689
4544
  vex <username> open as a specific local user
4690
4545
  vex chat [username] open the live terminal chat app
4691
- vex auth register <username>
4546
+ vex auth register <username> <password>
4692
4547
  vex auth login <username> request approval as a second device
4693
4548
  vex auth requests list pending device login requests
4694
4549
  vex auth accounts
@@ -4699,7 +4554,7 @@ Commands:
4699
4554
  Flags:
4700
4555
  --username <name> local account to use
4701
4556
  --user <name> alias for --username
4702
- --password <password> fallback password for login
4557
+ --password <password> fallback password for register/login
4703
4558
  --api-url <url> API base URL, e.g. http://127.0.0.1:16777
4704
4559
  --host <host:port> API host, default api.vex.wtf
4705
4560
  --local connect to local Spire at 127.0.0.1:16777 over http/ws
@@ -4746,12 +4601,7 @@ main()
4746
4601
  .then(() => {
4747
4602
  process.exit(0);
4748
4603
  })
4749
- .catch((err) => {
4750
- console.error(
4751
- color(
4752
- ROOT_ACCENT,
4753
- err instanceof Error ? err.message : String(err),
4754
- ),
4755
- );
4604
+ .catch(() => {
4605
+ console.error(color(ROOT_ACCENT, "Vex command failed."));
4756
4606
  process.exit(1);
4757
4607
  });