@vex-chat/cli 0.1.6 → 0.3.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.
package/README.md CHANGED
@@ -11,6 +11,8 @@ After that it 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.
15
+
14
16
  Inside the app:
15
17
 
16
18
  - `/accounts` lists local users on this machine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/cli",
3
- "version": "0.1.6",
3
+ "version": "0.3.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.0.0"
34
+ "@vex-chat/libvex": "^7.1.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",
package/src/vex-chat.js CHANGED
@@ -15,6 +15,7 @@ import { unpack } from "msgpackr";
15
15
  const require = createRequire(import.meta.url);
16
16
  const CLI_VERSION = require("../package.json").version;
17
17
  const DEFAULT_HOST = "api.vex.wtf";
18
+ const DEFAULT_PASSKEY_LOGIN_PATH = "/cli/passkey";
18
19
  const LOCAL_HOST = "127.0.0.1:16777";
19
20
  const COLOR = process.env.NO_COLOR === undefined;
20
21
  const ANSI = {
@@ -201,7 +202,16 @@ function parseArgs(argv) {
201
202
  continue;
202
203
  }
203
204
  const key = arg.slice(2);
204
- if (["debug", "http", "help", "local", "no-home"].includes(key)) {
205
+ if (
206
+ [
207
+ "debug",
208
+ "help",
209
+ "http",
210
+ "local",
211
+ "no-browser",
212
+ "no-home",
213
+ ].includes(key)
214
+ ) {
205
215
  flags[key] = true;
206
216
  continue;
207
217
  }
@@ -293,6 +303,14 @@ async function createContext(flags) {
293
303
  ? String(flags.username ?? flags.user).toLowerCase()
294
304
  : undefined,
295
305
  noHome: Boolean(flags["no-home"]),
306
+ openBrowser:
307
+ !flags["no-browser"] && process.env.VEX_CHAT_NO_BROWSER !== "1",
308
+ passkeyLoginUrl:
309
+ flags["passkey-url"] || process.env.VEX_CHAT_PASSKEY_URL
310
+ ? String(
311
+ flags["passkey-url"] ?? process.env.VEX_CHAT_PASSKEY_URL,
312
+ )
313
+ : undefined,
296
314
  password: flags.password ? String(flags.password) : undefined,
297
315
  debug,
298
316
  debugFile,
@@ -565,6 +583,96 @@ function isRemovedStoredDeviceError(err) {
565
583
  return err?.name === "RemovedStoredDeviceError";
566
584
  }
567
585
 
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
+
568
676
  async function removeStoredDeviceAccount(ctx, config, accountRef) {
569
677
  delete config.accounts[accountRef.key];
570
678
  if (config.lastUsername === accountRef.key) {
@@ -602,6 +710,13 @@ async function register(ctx, args) {
602
710
  try {
603
711
  const [, registerErr] = await client.register(username, password);
604
712
  if (registerErr) throw registerErr;
713
+ await persistNewLocalAccount(
714
+ ctx,
715
+ config,
716
+ username,
717
+ privateKey,
718
+ client,
719
+ );
605
720
  await connectAndWait(client, ctx, `register:${username}`);
606
721
  } catch (err) {
607
722
  if (!isDeviceApprovalRequired(err)) throw err;
@@ -620,7 +735,6 @@ async function register(ctx, args) {
620
735
  );
621
736
  return;
622
737
  }
623
- await persistNewLocalAccount(ctx, config, username, privateKey, client);
624
738
  console.log(
625
739
  `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
626
740
  );
@@ -651,6 +765,156 @@ async function persistNewLocalAccount(
651
765
  return { ...config.accounts[accountRef.key], accountKey: accountRef.key };
652
766
  }
653
767
 
768
+ function apiBaseUrl(ctx) {
769
+ return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${ctx.clientOptions.host}`;
770
+ }
771
+
772
+ function defaultPasskeyLoginUrl(ctx) {
773
+ const host = normalizeAccountHost(ctx.clientOptions.host);
774
+ if (isLocalHost(host)) {
775
+ return `http://localhost:5173${DEFAULT_PASSKEY_LOGIN_PATH}`;
776
+ }
777
+ return `${ctx.clientOptions.unsafeHttp ? "http" : "https"}://${host}${DEFAULT_PASSKEY_LOGIN_PATH}`;
778
+ }
779
+
780
+ function buildPasskeyLoginUrl(ctx, { code, pending, username }) {
781
+ const url = new URL(ctx.passkeyLoginUrl ?? defaultPasskeyLoginUrl(ctx));
782
+ url.hash = new URLSearchParams({
783
+ api: apiBaseUrl(ctx),
784
+ code,
785
+ device: ctx.clientOptions.deviceName ?? "vex-chat-cli",
786
+ expires: pending.expiresAt ?? "",
787
+ request: pending.requestID,
788
+ username,
789
+ }).toString();
790
+ return url.toString();
791
+ }
792
+
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
+ async function openExternalUrl(url) {
808
+ const [command, args] =
809
+ process.platform === "darwin"
810
+ ? ["open", [url]]
811
+ : process.platform === "win32"
812
+ ? ["cmd", ["/c", "start", "", url]]
813
+ : ["xdg-open", [url]];
814
+ return new Promise((resolve) => {
815
+ execFile(command, args, { timeout: 2_000 }, (err) => {
816
+ resolve(!err);
817
+ });
818
+ });
819
+ }
820
+
821
+ async function launchPasskeyLogin(ctx, username, pending, code) {
822
+ let url;
823
+ try {
824
+ url = buildPasskeyLoginUrl(ctx, { code, pending, username });
825
+ } catch (err) {
826
+ console.log(
827
+ color(
828
+ "yellow",
829
+ `Could not build passkey login URL: ${err instanceof Error ? err.message : String(err)}`,
830
+ ),
831
+ );
832
+ return;
833
+ }
834
+
835
+ console.log(
836
+ `${color(ROOT_ACCENT, "passkey login")} ${color("dim", "opens in your browser")}`,
837
+ );
838
+ console.log(
839
+ color(
840
+ "dim",
841
+ "Restoring with a passkey will remove other devices from this account.",
842
+ ),
843
+ );
844
+ console.log(`${color("dim", "url")} ${url}`);
845
+ if (!ctx.openBrowser) {
846
+ return;
847
+ }
848
+ const opened = await openExternalUrl(url);
849
+ if (!opened) {
850
+ console.log(
851
+ color("yellow", "Could not open a browser. Copy the URL above."),
852
+ );
853
+ }
854
+ }
855
+
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
+
654
918
  async function persistPendingLocalAccount(
655
919
  ctx,
656
920
  config,
@@ -752,11 +1016,6 @@ async function loginWithDeviceApproval(ctx, username) {
752
1016
  try {
753
1017
  const [, registerErr] = await client.register(accountUsername);
754
1018
  if (!registerErr) {
755
- await connectAndWait(
756
- client,
757
- ctx,
758
- `login-request:${accountUsername}`,
759
- );
760
1019
  await persistNewLocalAccount(
761
1020
  ctx,
762
1021
  config,
@@ -764,6 +1023,11 @@ async function loginWithDeviceApproval(ctx, username) {
764
1023
  privateKey,
765
1024
  client,
766
1025
  );
1026
+ await connectAndWait(
1027
+ client,
1028
+ ctx,
1029
+ `login-request:${accountUsername}`,
1030
+ );
767
1031
  console.log(
768
1032
  `${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), accountUsername)}`,
769
1033
  );
@@ -833,14 +1097,14 @@ async function waitForDeviceApproval(
833
1097
  console.log(
834
1098
  color(
835
1099
  "dim",
836
- "Confirm this code on an existing signed-in device before approving.",
1100
+ "Approve from an existing device, or restore this CLI with a passkey in the browser.",
837
1101
  ),
838
1102
  );
839
1103
 
840
1104
  if (input.isTTY && output.isTTY) {
841
1105
  const rl = createInterface({ input, output });
842
1106
  try {
843
- const answer = (await askText(rl, "notify existing devices?", "Y"))
1107
+ const answer = (await askText(rl, "start login request?", "Y"))
844
1108
  .trim()
845
1109
  .toLowerCase();
846
1110
  if (answer === "n" || answer === "no") {
@@ -873,7 +1137,10 @@ async function waitForDeviceApproval(
873
1137
  );
874
1138
  if (!approvedDeviceID) throw err;
875
1139
  });
876
- console.log(color(ROOT_ACCENT, "waiting for approval..."));
1140
+ await launchPasskeyLogin(ctx, username, pending, code);
1141
+ console.log(
1142
+ color(ROOT_ACCENT, "waiting for browser passkey login or approval..."),
1143
+ );
877
1144
 
878
1145
  for (let attempt = 0; attempt < 300; attempt++) {
879
1146
  await sleep(2000);
@@ -3105,6 +3372,13 @@ async function authenticateOrRegister(ctx, explicitUsername) {
3105
3372
  try {
3106
3373
  const [, registerErr] = await client.register(entered);
3107
3374
  if (registerErr) throw registerErr;
3375
+ await persistNewLocalAccount(
3376
+ ctx,
3377
+ config,
3378
+ entered,
3379
+ privateKey,
3380
+ client,
3381
+ );
3108
3382
  await connectAndWait(client, ctx, `register:${entered}`);
3109
3383
  } catch (err) {
3110
3384
  if (!isDeviceApprovalRequired(err)) throw err;
@@ -3122,13 +3396,11 @@ async function authenticateOrRegister(ctx, explicitUsername) {
3122
3396
  },
3123
3397
  );
3124
3398
  }
3125
- const account = await persistNewLocalAccount(
3126
- ctx,
3127
- config,
3128
- entered,
3129
- privateKey,
3130
- client,
3131
- );
3399
+ const createdAccountRef = parseAccountSelector(ctx, entered);
3400
+ const account = {
3401
+ ...config.accounts[createdAccountRef.key],
3402
+ accountKey: createdAccountRef.key,
3403
+ };
3132
3404
  return { account, client, config };
3133
3405
  } finally {
3134
3406
  rl.close();
@@ -3151,6 +3423,24 @@ async function connectAndWait(client, ctx = null, label = "client") {
3151
3423
  debugLog(ctx, "client.connect.skip.connected", { label });
3152
3424
  return;
3153
3425
  }
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
+ }
3441
+ }
3442
+
3443
+ async function connectOnceAndWait(client, ctx = null, label = "client") {
3154
3444
  debugLog(ctx, "client.connect.start", { label });
3155
3445
  await new Promise((resolve, reject) => {
3156
3446
  const timer = setTimeout(() => {
@@ -4362,6 +4652,8 @@ Flags:
4362
4652
  --host <host:port> API host, default api.vex.wtf
4363
4653
  --local connect to local Spire at 127.0.0.1:16777 over http/ws
4364
4654
  --http use http/ws
4655
+ --passkey-url <url> browser passkey login page, default uses the API host
4656
+ --no-browser print passkey login URL without opening a browser
4365
4657
  --dev-key <key> send x-dev-api-key
4366
4658
  --debug write send/receive/connect diagnostics to a log file
4367
4659
  --debug-file <path> debug log path, default under the CLI data dir