@vex-chat/cli 0.4.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 +32 -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.4.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.4.0"
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
@@ -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,34 @@ function removedStoredDeviceError(ctx, username) {
697
607
  return err;
698
608
  }
699
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
+
700
634
  async function register(ctx, args) {
701
635
  const requestedUsername = args[0] ?? ctx.username;
702
- const password = args[1] ?? ctx.password;
703
636
  if (!requestedUsername) {
704
- throw new Error("Usage: vex-chat register <username> [password]");
637
+ throw new Error("Usage: vex-chat register <username> <password>");
705
638
  }
706
639
  const config = await readConfig(ctx.configPath);
707
640
  const accountRef = resolveAccountEntry(ctx, config, requestedUsername);
@@ -713,6 +646,7 @@ async function register(ctx, args) {
713
646
  `Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
714
647
  );
715
648
  }
649
+ const password = await resolveRegistrationPassword(ctx, args);
716
650
  const privateKey = Client.generateSecretKey();
717
651
  const client = await Client.create(privateKey, ctx.clientOptions);
718
652
  attachDebugClientEvents(ctx, client, `register:${username}`);
@@ -800,20 +734,6 @@ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
800
734
  return url.toString();
801
735
  }
802
736
 
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
737
  async function openExternalUrl(url) {
818
738
  const [command, args] =
819
739
  process.platform === "darwin"
@@ -863,68 +783,6 @@ async function launchPasskeyLogin(ctx, username, pending, code) {
863
783
  }
864
784
  }
865
785
 
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
786
  async function persistPendingLocalAccount(
929
787
  ctx,
930
788
  config,
@@ -1275,7 +1133,7 @@ async function authCommand(ctx, args) {
1275
1133
  return;
1276
1134
  default:
1277
1135
  throw new Error(
1278
- "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",
1279
1137
  );
1280
1138
  }
1281
1139
  }
@@ -3417,11 +3275,12 @@ async function authenticateOrRegister(ctx, explicitUsername) {
3417
3275
  if (answer && answer !== "y" && answer !== "yes") {
3418
3276
  throw new Error("No local account selected.");
3419
3277
  }
3278
+ const password = await resolveRegistrationPassword(ctx, [entered], rl);
3420
3279
  const privateKey = Client.generateSecretKey();
3421
3280
  const client = await Client.create(privateKey, ctx.clientOptions);
3422
3281
  attachDebugClientEvents(ctx, client, `register:${entered}`);
3423
3282
  try {
3424
- const [, registerErr] = await client.register(entered);
3283
+ const [, registerErr] = await client.register(entered, password);
3425
3284
  if (registerErr) throw registerErr;
3426
3285
  await persistNewLocalAccount(
3427
3286
  ctx,
@@ -3474,21 +3333,7 @@ async function connectAndWait(client, ctx = null, label = "client") {
3474
3333
  debugLog(ctx, "client.connect.skip.connected", { label });
3475
3334
  return;
3476
3335
  }
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
- }
3336
+ await connectOnceAndWait(client, ctx, label);
3492
3337
  }
3493
3338
 
3494
3339
  async function connectOnceAndWait(client, ctx = null, label = "client") {
@@ -4688,7 +4533,7 @@ Commands:
4688
4533
  vex open the live terminal chat app
4689
4534
  vex <username> open as a specific local user
4690
4535
  vex chat [username] open the live terminal chat app
4691
- vex auth register <username>
4536
+ vex auth register <username> <password>
4692
4537
  vex auth login <username> request approval as a second device
4693
4538
  vex auth requests list pending device login requests
4694
4539
  vex auth accounts
@@ -4699,7 +4544,7 @@ Commands:
4699
4544
  Flags:
4700
4545
  --username <name> local account to use
4701
4546
  --user <name> alias for --username
4702
- --password <password> fallback password for login
4547
+ --password <password> fallback password for register/login
4703
4548
  --api-url <url> API base URL, e.g. http://127.0.0.1:16777
4704
4549
  --host <host:port> API host, default api.vex.wtf
4705
4550
  --local connect to local Spire at 127.0.0.1:16777 over http/ws